Compare commits
10 Commits
188-provid
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| efd4f31ba3 | |||
| 68be99e27b | |||
| bef9020159 | |||
| 9f6985291e | |||
| 74210bac2e | |||
| f7bbea2623 | |||
| 65e10a2020 | |||
| eca19819d1 | |||
| 2f45ff5a84 | |||
| 1655cc481e |
16
.github/agents/copilot-instructions.md
vendored
16
.github/agents/copilot-instructions.md
vendored
@ -163,6 +163,16 @@ ## Active Technologies
|
|||||||
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
|
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
|
||||||
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
||||||
|
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
|
||||||
|
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||||
|
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
|
||||||
|
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||||
|
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -197,8 +207,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries
|
- 193-monitoring-action-hierarchy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders
|
||||||
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
|
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||||
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
|
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,24 +1,37 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.0.0 -> 2.1.0
|
- Version change: 2.2.0 -> 2.3.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
- UI-CONST-001: expanded to make TenantPilot's decision-first
|
||||||
with cross-reference to new HDR-001
|
governance identity explicit
|
||||||
|
- UI-REVIEW-001: spec and PR review gates expanded for surface role,
|
||||||
|
human-in-the-loop justification, workflow-vs-storage IA, and
|
||||||
|
attention-load reduction
|
||||||
|
- Immediate Retrofit Priorities: expanded with a classification-first
|
||||||
|
wave for existing surfaces
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Header Action Discipline & Contextual Navigation (HDR-001)
|
- Decision-First Operating Model & Progressive Disclosure
|
||||||
|
(DECIDE-001)
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/memory/constitution.md
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
- ✅ .specify/templates/plan-template.md (Constitution Check updated for
|
||||||
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
decision-first surface roles, workflow-first IA, and calm-surface
|
||||||
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
review)
|
||||||
UI/UX Surface Classification and Operator Surface Contract tables already
|
- ✅ .specify/templates/spec-template.md (surface role classification,
|
||||||
cover header action placement implicitly)
|
operator contract, and requirements updated for decision-first
|
||||||
|
governance)
|
||||||
|
- ✅ .specify/templates/tasks-template.md (implementation task guidance
|
||||||
|
updated for progressive disclosure, single-case context, and
|
||||||
|
attention-load reduction)
|
||||||
|
- ✅ docs/product/standards/README.md (Constitution index updated for
|
||||||
|
DECIDE-001)
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
- None.
|
- Create a dedicated surface / IA classification spec to retrofit
|
||||||
|
existing surfaces against DECIDE-001.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
@ -109,6 +122,15 @@ ### Mandatory Bloat Check for New Specs (BLOAT-001)
|
|||||||
6. Is this current-release truth or future-release preparation?
|
6. Is this current-release truth or future-release preparation?
|
||||||
- Specs that cannot answer these questions clearly MUST NOT merge.
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### Spec Candidate Gate (SPEC-GATE-001)
|
||||||
|
- Every new spec candidate MUST pass the Spec Approval Rubric (`.specify/memory/spec-approval-rubric.md`) before progressing beyond Draft status.
|
||||||
|
- The spec MUST include a filled-out "Spec Candidate Check" section answering the 5 mandatory questions (operator workflow, trust/safety, smallest version, permanent complexity, why now).
|
||||||
|
- The spec MUST be classified into exactly one approval class: Core Enterprise, Workflow Compression, Cleanup, or Defer.
|
||||||
|
- The spec MUST include a scored evaluation (6 dimensions, 0–2 each). Specs scoring below 7/12 MUST NOT be approved without explicit scope reduction.
|
||||||
|
- If two or more red flags from the rubric are triggered, the spec MUST include an explicit defense justifying why it should proceed.
|
||||||
|
- Specs classified as "Defer" or scoring 0–3 MUST NOT be implemented.
|
||||||
|
- This gate applies to all spec-creating agents (speckit.specify, speckit.plan) and manual spec creation alike.
|
||||||
|
|
||||||
### Default Bias (BIAS-001)
|
### Default Bias (BIAS-001)
|
||||||
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||||
|
|
||||||
@ -309,13 +331,189 @@ ### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
|||||||
|
|
||||||
Purpose and scope
|
Purpose and scope
|
||||||
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
||||||
- It defines allowed surface types, allowed interaction models, primary/secondary/destructive action hierarchy, list/detail/queue semantics, scope and context signals, canonical navigation and naming rules, visibility of critical operational truth, scanability and density rules, exception handling, and review and enforcement requirements.
|
- It defines decision-first prominence roles, allowed surface types,
|
||||||
|
allowed interaction models, primary/secondary/destructive action
|
||||||
|
hierarchy, list/detail/queue semantics, scope and context signals,
|
||||||
|
canonical navigation and naming rules, visibility of critical
|
||||||
|
operational truth, scanability and density rules, exception handling,
|
||||||
|
and review and enforcement requirements.
|
||||||
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
||||||
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
||||||
|
|
||||||
|
#### Decision-First Operating Model & Progressive Disclosure (DECIDE-001)
|
||||||
|
|
||||||
|
Goal: TenantPilot is primarily a governance and decision platform, not
|
||||||
|
a browser for internal technical detail objects. This section governs
|
||||||
|
surface prominence and default information depth. It is orthogonal to
|
||||||
|
UI-SURF-001 and ACTSURF-001: every operator-facing surface MUST declare
|
||||||
|
both its interaction model and its decision-role prominence.
|
||||||
|
|
||||||
|
##### Surface prominence roles
|
||||||
|
|
||||||
|
- Every operator-facing surface MUST declare exactly one decision-role
|
||||||
|
prominence:
|
||||||
|
- Primary Decision Surface
|
||||||
|
- Secondary Context Surface
|
||||||
|
- Tertiary Evidence / Diagnostics Surface
|
||||||
|
- Decision-role prominence is separate from action-surface class and
|
||||||
|
detailed surface type.
|
||||||
|
- Prominence determines what deserves top-level navigation, default
|
||||||
|
emphasis, and default-visible information depth.
|
||||||
|
|
||||||
|
##### Primary surfaces are for human decisions
|
||||||
|
|
||||||
|
- Primary Decision Surfaces MUST support a clear human-in-the-loop
|
||||||
|
moment such as attention prioritization, approval, risk acceptance or
|
||||||
|
rejection, drift / findings / exception triage, review completion,
|
||||||
|
evaluation of blocked or failed automations, or execution /
|
||||||
|
escalation of the next governance action.
|
||||||
|
- A prominent surface MUST NOT exist primarily to display internal
|
||||||
|
model objects, raw data, diagnostics, or technical object hubs
|
||||||
|
without clear operator value.
|
||||||
|
- Every proposed primary surface MUST answer: what concrete decision or
|
||||||
|
operator action does this surface support?
|
||||||
|
|
||||||
|
##### Detail surfaces are evidence surfaces
|
||||||
|
|
||||||
|
- OperationRun detail, evidence detail, policy version detail, audit
|
||||||
|
log detail, JSON / payload / diff views, and deep diagnostic contexts
|
||||||
|
are normally Secondary Context or Tertiary Evidence / Diagnostics
|
||||||
|
surfaces.
|
||||||
|
- These surfaces remain essential for verification, diagnosis, and
|
||||||
|
auditability, but they MUST NOT dominate default operator workflows
|
||||||
|
or primary navigation merely because the underlying objects exist.
|
||||||
|
|
||||||
|
##### Default to decisions, not details
|
||||||
|
|
||||||
|
- Default-visible information MUST first answer what happened, why it
|
||||||
|
matters, how urgent it is, what the system recommends, what impact
|
||||||
|
the decision has, and what action or approval is required now.
|
||||||
|
- Internal IDs, relation depth, raw payloads, full snapshot history,
|
||||||
|
debug views, and unstructured technical detail MUST stay secondary
|
||||||
|
unless they are required for the first decision.
|
||||||
|
|
||||||
|
##### Progressive disclosure is the default
|
||||||
|
|
||||||
|
- Depth MUST be preserved but revealed on demand through
|
||||||
|
expand/collapse, drawers, tabs, side panels, explicit "Show details"
|
||||||
|
affordances, or focused drill-downs from a clear decision context.
|
||||||
|
- The default workflow SHOULD let the operator decide before navigating
|
||||||
|
through diagnostic depth.
|
||||||
|
- Primary flows MUST NOT force operators through multiple technical
|
||||||
|
subpages before a single governance decision can be made.
|
||||||
|
|
||||||
|
##### Navigation follows workflows, not storage structures
|
||||||
|
|
||||||
|
- Primary navigation and prominent entry points MUST follow operator
|
||||||
|
workflows such as pending decisions, alerts / escalations, reviews,
|
||||||
|
exceptions / accepted risks, governance priorities, and blocked or
|
||||||
|
failed automations.
|
||||||
|
- Internal persistence terms such as OperationRuns, EvidenceItems,
|
||||||
|
PolicyVersions, StoredReports, or relational chains MAY exist as
|
||||||
|
supporting surfaces, but they do not earn primary information
|
||||||
|
architecture status by default.
|
||||||
|
- Every navigation proposal MUST answer: does this reflect a working
|
||||||
|
task or only an internal storage structure?
|
||||||
|
|
||||||
|
##### Meaning comes before model names
|
||||||
|
|
||||||
|
- Operator-facing surfaces MUST prefer governance language such as
|
||||||
|
"Drift detected", "Exception expires soon", "Evidence incomplete",
|
||||||
|
"Review ready", "Remediation recommended", or "Further review
|
||||||
|
required".
|
||||||
|
- Model names, table/entity language, relation terminology, and
|
||||||
|
implementation-first state labels MUST NOT be the primary UX
|
||||||
|
language when business meaning can be expressed directly.
|
||||||
|
|
||||||
|
##### One case, one decision context
|
||||||
|
|
||||||
|
- A single governance case SHOULD be decidable within one focused
|
||||||
|
context that brings together the problem, risk or relevance,
|
||||||
|
recommendation, impact, ownership, next action, approval options, and
|
||||||
|
optional detail beneath or beside the decision.
|
||||||
|
- Operators MUST NOT be forced to reconstruct one decision across
|
||||||
|
multiple equal-rank Run, Evidence, Policy, Audit, and Finding pages
|
||||||
|
when the product can present one coherent decision context.
|
||||||
|
|
||||||
|
##### Audit depth is mandatory; dominance is not
|
||||||
|
|
||||||
|
- Enterprise-grade evidence, verification, and audit depth MUST remain
|
||||||
|
available.
|
||||||
|
- Audit requirements do NOT justify default surfaces that look or
|
||||||
|
behave like forensic diagnostics consoles.
|
||||||
|
- The standard operator flow SHOULD remain calm, prioritized, and
|
||||||
|
decision-led even when deep proof is available.
|
||||||
|
|
||||||
|
##### New primary surfaces require strict justification
|
||||||
|
|
||||||
|
- Every new top-level or otherwise prominent surface MUST justify:
|
||||||
|
1. which human-in-the-loop moment it supports,
|
||||||
|
2. why an existing surface is insufficient,
|
||||||
|
3. why a drawer, panel, tab, or embedded decision context is
|
||||||
|
insufficient,
|
||||||
|
4. what search, review, or click work it removes.
|
||||||
|
- If those answers are weak, the work MUST reuse an existing decision
|
||||||
|
context or remain secondary/tertiary.
|
||||||
|
|
||||||
|
##### Automation must reduce attention load
|
||||||
|
|
||||||
|
- New automation, notification, or autonomous governance behavior MUST
|
||||||
|
measurably reduce search work, review work, or click load.
|
||||||
|
- Automation that primarily creates extra lists, statuses, surfaces, or
|
||||||
|
detail work is non-conformant even if technically correct.
|
||||||
|
- The review question is: does this make the platform quieter and
|
||||||
|
clearer, or merely larger?
|
||||||
|
|
||||||
|
##### Calm default surfaces
|
||||||
|
|
||||||
|
- The default workspace experience MUST distinguish clearly between
|
||||||
|
immediately actionable work, worth-watching context, and
|
||||||
|
reference-only information.
|
||||||
|
- Unranked warning floods, parallel attention entry points, and
|
||||||
|
perpetual visual escalation are forbidden on primary surfaces.
|
||||||
|
- A surface that only creates noise instead of priority is
|
||||||
|
non-conformant.
|
||||||
|
|
||||||
|
##### Retrofit requirement
|
||||||
|
|
||||||
|
- DECIDE-001 applies to existing as well as new surfaces.
|
||||||
|
- Existing surfaces MUST be reclassified as Primary Decision,
|
||||||
|
Secondary Context, or Tertiary Evidence / Diagnostics surfaces and
|
||||||
|
then reviewed for prominence, disclosure, consolidation, and
|
||||||
|
workflow alignment.
|
||||||
|
- Surface retrofit work SHOULD prefer reclassification and
|
||||||
|
consolidation before creating new navigation branches.
|
||||||
|
|
||||||
|
##### Review gate
|
||||||
|
|
||||||
|
Every operator-facing spec or PR that changes a surface MUST answer:
|
||||||
|
1. What concrete decision or operator action does this support?
|
||||||
|
2. Who is the human in the loop?
|
||||||
|
3. What MUST be immediately visible for the first decision?
|
||||||
|
4. What is preserved but only revealed on demand?
|
||||||
|
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||||
|
Tertiary Evidence / Diagnostics Surface?
|
||||||
|
6. If it is primary, why can it not live inside an existing decision
|
||||||
|
context?
|
||||||
|
7. Does the navigation reflect a workflow or only storage structure?
|
||||||
|
8. Does this reduce search, review, or click work?
|
||||||
|
9. Does this make the product calmer and clearer instead of louder?
|
||||||
|
|
||||||
#### Surface Taxonomy (UI-SURF-001)
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||||
|
class before implementation. Ad-hoc interaction models are forbidden.
|
||||||
|
|
||||||
|
The allowed broad action-surface classes are:
|
||||||
|
- Record / Detail / Edit
|
||||||
|
- Monitoring / Queue / Workbench
|
||||||
|
- List / Table / Bulk
|
||||||
|
- Wizard / Flow
|
||||||
|
- Utility / System
|
||||||
|
|
||||||
|
Operator-facing surfaces MUST also declare exactly one detailed surface
|
||||||
|
type from the taxonomy below. The broad class determines the action
|
||||||
|
hierarchy first; the detailed surface type refines it.
|
||||||
|
|
||||||
##### CRUD / List-first Resource
|
##### CRUD / List-first Resource
|
||||||
- Purpose: scan, find, open, and selectively mutate many business records.
|
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||||
@ -368,6 +566,157 @@ ##### Detail-first Operational Surface
|
|||||||
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||||
- Row click and explicit View/Inspect: not applicable.
|
- Row click and explicit View/Inspect: not applicable.
|
||||||
|
|
||||||
|
#### Action Surface Discipline (ACTSURF-001)
|
||||||
|
|
||||||
|
Goal: actions across all surfaces MUST make the next sensible operator
|
||||||
|
step obvious, keep safe navigation distinct from mutation, and prevent
|
||||||
|
dangerous or governance-relevant actions from sitting casually beside
|
||||||
|
harmless context changes.
|
||||||
|
|
||||||
|
##### Surface class first
|
||||||
|
|
||||||
|
- Every new or materially changed surface MUST declare exactly one broad
|
||||||
|
action-surface class before actions are designed.
|
||||||
|
- Different surface classes MAY use different action models only when
|
||||||
|
the difference is deliberate, documented, and justified by the
|
||||||
|
workflow.
|
||||||
|
- Detailed surface types refine the rule set; they do not replace the
|
||||||
|
broad class requirement.
|
||||||
|
|
||||||
|
##### Record / Detail / Edit surfaces
|
||||||
|
|
||||||
|
- Classic record/detail/edit pages MUST expose at most one visible
|
||||||
|
primary header action.
|
||||||
|
- Pure navigation MUST NOT live in the header when it can be placed
|
||||||
|
inline at summary, field, badge, status, or related-context level.
|
||||||
|
- Secondary, rare, or administrative actions MUST be grouped.
|
||||||
|
- Multiple equally weighted mutation buttons in the header are
|
||||||
|
forbidden.
|
||||||
|
- Destructive, irreversible, or governance-relevant actions MUST be
|
||||||
|
clearly separated from routine actions.
|
||||||
|
- The likely next operator step MUST be recognizable within seconds.
|
||||||
|
- HDR-001 is the binding specialization for record/detail/edit headers.
|
||||||
|
|
||||||
|
##### Monitoring / Queue / Workbench surfaces
|
||||||
|
|
||||||
|
- Surface-level context, scope context, navigation, selection actions,
|
||||||
|
and object actions MUST NOT be mixed as one flat header strip.
|
||||||
|
- Scope indicators are context signals, not ordinary calls to action.
|
||||||
|
- Selection-dependent actions SHOULD become prominent only when a
|
||||||
|
selection or focused object actually exists.
|
||||||
|
- Record-page header rules MUST NOT be copied blindly onto workbench
|
||||||
|
surfaces.
|
||||||
|
- Workbench surfaces MAY use a different action model, but that model
|
||||||
|
MUST be explicit, repeatable, and internally consistent.
|
||||||
|
|
||||||
|
##### List / Table / Bulk surfaces
|
||||||
|
|
||||||
|
- Inspect/open affordances MUST remain consistent within the same
|
||||||
|
surface class.
|
||||||
|
- Bulk actions are allowed only for genuine multi-record work.
|
||||||
|
- Row actions MUST NOT dominate reading and scanning.
|
||||||
|
- Rare, destructive, or governance-relevant actions MUST NOT accumulate
|
||||||
|
casually in default row actions.
|
||||||
|
- Tables exist primarily to scan, filter, compare, and decide; they
|
||||||
|
MUST NOT become unstructured action stockpiles.
|
||||||
|
|
||||||
|
##### Wizard / Flow surfaces
|
||||||
|
|
||||||
|
- Wizard actions MUST reflect staged progression, explicit back/cancel
|
||||||
|
semantics, and safe confirmation at the step where risk becomes real.
|
||||||
|
- Wizard pages MAY expose more than one visible action when the flow
|
||||||
|
genuinely requires progression, backtracking, or guarded cancellation.
|
||||||
|
- Even in a wizard, the next primary step MUST remain obvious.
|
||||||
|
|
||||||
|
##### Utility / System surfaces
|
||||||
|
|
||||||
|
- Utility and system pages MAY use narrower tooling-oriented action
|
||||||
|
sets, but they MUST still separate safe navigation, routine control,
|
||||||
|
and dangerous intervention.
|
||||||
|
- System or recovery status does not justify casual placement of
|
||||||
|
destructive or governance-changing actions.
|
||||||
|
|
||||||
|
##### Action grouping and order
|
||||||
|
|
||||||
|
- Actions MUST be ordered by meaning, frequency, and risk.
|
||||||
|
- The preferred order is:
|
||||||
|
1. primary next step
|
||||||
|
2. common secondary action
|
||||||
|
3. rare or contextual action
|
||||||
|
4. dangerous or irreversible action
|
||||||
|
- An `ActionGroup` / More menu is not a junk drawer. Navigation,
|
||||||
|
mutation, external links, and destructive actions inside a group MUST
|
||||||
|
still be named, ordered, and separated coherently.
|
||||||
|
|
||||||
|
##### Navigation vs mutation
|
||||||
|
|
||||||
|
- Navigation and mutation are different intent classes and MUST NOT
|
||||||
|
appear as equal-weight peers without explicit hierarchy.
|
||||||
|
- Harmless context switches MUST NOT visually overpower
|
||||||
|
governance-relevant actions.
|
||||||
|
- Pure context navigation SHOULD live near the content it concerns
|
||||||
|
rather than as header filler.
|
||||||
|
|
||||||
|
##### Governance friction
|
||||||
|
|
||||||
|
- Actions with risk, blast radius, or irreversible effect MUST use
|
||||||
|
shared governance-friction rules rather than per-surface improvisation.
|
||||||
|
- Depending on impact, the required friction is confirmation, optional
|
||||||
|
reason, mandatory reason, typed confirmation, or staged flow.
|
||||||
|
- Clear danger semantics and separated placement are mandatory for
|
||||||
|
dangerous or governance-changing actions.
|
||||||
|
|
||||||
|
##### Exceptions require explicit reason
|
||||||
|
|
||||||
|
- New surfaces MAY deviate only when the surface class or workflow truly
|
||||||
|
requires it.
|
||||||
|
- Allowed justification labels are:
|
||||||
|
- Special type
|
||||||
|
- Workflow hub
|
||||||
|
- Wizard
|
||||||
|
- Utility / System surface
|
||||||
|
- Another clearly defined exception documented in the governing spec
|
||||||
|
- "Historically grew this way" and "it was easy to add to the header"
|
||||||
|
are invalid reasons.
|
||||||
|
|
||||||
|
##### Reuse before invention
|
||||||
|
|
||||||
|
- New features MUST reuse existing disciplined patterns, reference
|
||||||
|
architectures, and shared primitives when they fit the chosen surface
|
||||||
|
class.
|
||||||
|
- Reference patterns are reuse baselines, not automatic mandates for
|
||||||
|
every surface.
|
||||||
|
|
||||||
|
##### Constitution over convenience
|
||||||
|
|
||||||
|
- Local implementation speed MUST NOT override consistent action
|
||||||
|
hierarchy.
|
||||||
|
- No new feature may introduce:
|
||||||
|
- multiple equal-rank header mutations without a clear primary
|
||||||
|
- navigation as casual header filler
|
||||||
|
- unreflective mixing of record, workbench, and governance patterns
|
||||||
|
- new local exceptions without explicit rationale
|
||||||
|
|
||||||
|
##### Review gate
|
||||||
|
|
||||||
|
Every new or materially changed surface with actions MUST answer:
|
||||||
|
1. What broad action-surface class is it?
|
||||||
|
2. What is the one most likely next operator action?
|
||||||
|
3. Is navigation cleanly separated from mutation?
|
||||||
|
4. Are rare or risky actions removed from the primary plane?
|
||||||
|
5. Is the hierarchy scanable in a few seconds?
|
||||||
|
6. Is this a real special type or just an unordered exception?
|
||||||
|
|
||||||
|
If those answers are not clear, the surface is non-conformant.
|
||||||
|
|
||||||
|
##### Canonical outcome
|
||||||
|
|
||||||
|
- The goal is not the smallest possible number of buttons.
|
||||||
|
- A conformant surface highlights the next sensible step, separates
|
||||||
|
context, navigation, mutation, and danger cleanly, remains structured
|
||||||
|
as capability grows, and applies the same principles consistently
|
||||||
|
across the product.
|
||||||
|
|
||||||
#### Hard Rules (UI-HARD-001)
|
#### Hard Rules (UI-HARD-001)
|
||||||
|
|
||||||
##### Primary inspect model
|
##### Primary inspect model
|
||||||
@ -500,6 +849,8 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
|
|
||||||
Behavior over declaration
|
Behavior over declaration
|
||||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||||
|
- Every changed operator-facing surface MUST declare its broad
|
||||||
|
action-surface class and the one most likely next operator action.
|
||||||
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||||
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||||
|
|
||||||
@ -523,7 +874,10 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
|||||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||||
|
|
||||||
Actions and flows
|
Actions and flows
|
||||||
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
- Record / Detail / Edit pages MUST expose at most one visible primary
|
||||||
|
header action. Any additional visible secondary header action requires
|
||||||
|
explicit justification under ACTSURF-001 / HDR-001; the rest belong in
|
||||||
|
groups or contextual placement.
|
||||||
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||||
- Destructive actions remain non-primary and confirmed.
|
- Destructive actions remain non-primary and confirmed.
|
||||||
|
|
||||||
@ -536,9 +890,12 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
|||||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||||
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||||
|
|
||||||
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
#### Record / Detail / Edit Header Discipline & Contextual Navigation (HDR-001)
|
||||||
|
|
||||||
|
Goal: record, detail, and edit pages MUST be comprehensible within
|
||||||
|
seconds. HDR-001 is the binding record/detail/edit specialization of
|
||||||
|
ACTSURF-001.
|
||||||
|
|
||||||
Goal: record and detail pages MUST be comprehensible within seconds.
|
|
||||||
Header actions are reserved for the primary workflow of the current page
|
Header actions are reserved for the primary workflow of the current page
|
||||||
and MUST NOT become a dumping ground for every available action or
|
and MUST NOT become a dumping ground for every available action or
|
||||||
navigation jump.
|
navigation jump.
|
||||||
@ -556,6 +913,8 @@ ##### Maximum one primary visible header action
|
|||||||
primary visible header action.
|
primary visible header action.
|
||||||
- That action MUST represent the most obvious next operator step on
|
- That action MUST represent the most obvious next operator step on
|
||||||
exactly this page.
|
exactly this page.
|
||||||
|
- Multiple equally weighted mutation buttons in the header are
|
||||||
|
forbidden.
|
||||||
|
|
||||||
##### Navigation does not belong in headers
|
##### Navigation does not belong in headers
|
||||||
|
|
||||||
@ -585,6 +944,8 @@ ##### Rare secondary actions belong in an Action Group
|
|||||||
or are only occasionally needed MUST NOT appear as equally weighted
|
or are only occasionally needed MUST NOT appear as equally weighted
|
||||||
visible header buttons.
|
visible header buttons.
|
||||||
- They MUST be placed in an Action Group.
|
- They MUST be placed in an Action Group.
|
||||||
|
- The Action Group itself MUST remain structured; it MUST NOT become an
|
||||||
|
unlabelled mix of navigation, external links, mutations, and danger.
|
||||||
|
|
||||||
##### Header clarity over implementation convenience
|
##### Header clarity over implementation convenience
|
||||||
|
|
||||||
@ -735,17 +1096,58 @@ #### Spec Scope Fields (SCOPE-002)
|
|||||||
#### Enforcement Model (UI-REVIEW-001)
|
#### Enforcement Model (UI-REVIEW-001)
|
||||||
|
|
||||||
Spec review requirements
|
Spec review requirements
|
||||||
- Every spec that changes an operator-facing surface MUST answer: surface type, primary inspect/open model, row-click rule, whether explicit View/Inspect exists or is forbidden, where secondary actions live, where destructive actions live, canonical collection route, canonical detail route, scope signals and their exact meaning, canonical noun, critical truth visible by default, and whether an exception type is used.
|
- Every spec that changes an operator-facing surface MUST answer:
|
||||||
|
decision-role prominence, human-in-the-loop moment, immediate-visible
|
||||||
|
decision information, on-demand evidence/diagnostics boundary,
|
||||||
|
whether a new primary surface is actually justified, broad
|
||||||
|
action-surface class, detailed surface type, one likely next operator
|
||||||
|
action, primary inspect/open model, row-click rule, whether explicit
|
||||||
|
View/Inspect exists or is forbidden, where navigation lives, where
|
||||||
|
secondary actions live, where destructive actions live, how grouped
|
||||||
|
actions are ordered, canonical collection route, canonical detail
|
||||||
|
route, scope signals and their exact meaning, canonical noun,
|
||||||
|
critical truth visible by default, workflow-vs-storage IA
|
||||||
|
justification, attention-load reduction, and whether an exception
|
||||||
|
type is used.
|
||||||
- Missing any of those answers makes the spec incomplete.
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
PR review requirements
|
PR review requirements
|
||||||
- A PR MUST NOT pass when it introduces more than one primary inspect model, redundant View beside row click, destructive inline actions beside inspect on standard lists, empty overflow or bulk groups, long workflow labels in dense rows, misleading scope chips, drifting domain nouns, hidden critical operational truth, or undocumented exceptions without dedicated tests.
|
- A PR MUST NOT pass when it introduces more than one primary inspect
|
||||||
|
model, redundant View beside row click, destructive inline actions
|
||||||
|
beside inspect on standard lists, empty overflow or bulk groups, long
|
||||||
|
workflow labels in dense rows, misleading scope chips, drifting domain
|
||||||
|
nouns, hidden critical operational truth, flat record headers with
|
||||||
|
multiple equal-weight mutations, workbench headers that mix scope,
|
||||||
|
selection, navigation, and object actions as peers, a primary surface
|
||||||
|
with no clear human-in-the-loop purpose, detail/evidence objects
|
||||||
|
promoted into primary navigation without justification, one case
|
||||||
|
fragmented across multiple equal-rank pages, new automation that adds
|
||||||
|
attention surfaces without reducing operator work, noisy default
|
||||||
|
surfaces with no action/watch/reference hierarchy, or undocumented
|
||||||
|
exceptions without dedicated tests.
|
||||||
|
|
||||||
Guard tests
|
Guard tests
|
||||||
- Repository guards SHOULD validate: declared surface type, conformant primary inspect model, absence of redundant View actions, presence of explicit Inspect on Queue / Review and History / Audit surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, correct placement of destructive actions, truthful scope signals, stable canonical nouns across shells, and dedicated tests for every approved exception.
|
- Repository guards SHOULD validate: declared surface type, declared
|
||||||
|
decision-role prominence where specs or metadata expose it,
|
||||||
|
conformant primary inspect model, absence of redundant View actions,
|
||||||
|
presence of explicit Inspect on Queue / Review and History / Audit
|
||||||
|
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||||
|
correct placement of destructive actions, truthful scope signals,
|
||||||
|
stable canonical nouns across shells, and dedicated tests for every
|
||||||
|
approved exception.
|
||||||
|
|
||||||
#### Immediate Retrofit Priorities
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
|
Wave 0 - Surface role classification
|
||||||
|
- First classify existing surfaces as Primary Decision, Secondary
|
||||||
|
Context, or Tertiary Evidence / Diagnostics surfaces.
|
||||||
|
- For each surface, determine whether its current prominence is
|
||||||
|
justified, which detail can move into progressive disclosure, and
|
||||||
|
whether several technical pages should collapse into one decision
|
||||||
|
context.
|
||||||
|
- Wave 0 is done only when primary navigation candidates are grounded
|
||||||
|
in workflows rather than storage structures.
|
||||||
|
|
||||||
Wave 1 - Interaction normalization
|
Wave 1 - Interaction normalization
|
||||||
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
||||||
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||||
@ -759,6 +1161,23 @@ #### Immediate Retrofit Priorities
|
|||||||
|
|
||||||
#### Appendix A - One-page Condensed Constitution
|
#### Appendix A - One-page Condensed Constitution
|
||||||
|
|
||||||
|
- Every operator-facing surface declares one decision role:
|
||||||
|
Primary Decision, Secondary Context, or Tertiary Evidence /
|
||||||
|
Diagnostics.
|
||||||
|
- Primary surfaces exist to help a human prioritize, judge, approve,
|
||||||
|
reject, escalate, or act.
|
||||||
|
- Evidence and diagnostics remain available but do not dominate the
|
||||||
|
default workflow.
|
||||||
|
- Default to decisions, not details.
|
||||||
|
- Progressive disclosure preserves depth without forcing it into the
|
||||||
|
first decision.
|
||||||
|
- Navigation follows workflows, not storage structures.
|
||||||
|
- One governance case should be decidable in one focused context.
|
||||||
|
- Automation must reduce attention load.
|
||||||
|
- Default surfaces stay calm, prioritized, and explicit about what is
|
||||||
|
actionable, worth watching, and reference-only.
|
||||||
|
- Every new or materially changed surface declares one broad
|
||||||
|
action-surface class first.
|
||||||
- Every admin surface has one surface type.
|
- Every admin surface has one surface type.
|
||||||
- Every list has exactly one primary inspect/open model.
|
- Every list has exactly one primary inspect/open model.
|
||||||
- CRUD and Registry surfaces use one-click open.
|
- CRUD and Registry surfaces use one-click open.
|
||||||
@ -768,6 +1187,10 @@ #### Appendix A - One-page Condensed Constitution
|
|||||||
- Destructive actions never sit openly beside inspect on standard lists.
|
- Destructive actions never sit openly beside inspect on standard lists.
|
||||||
- Overflow is standardized per surface class and is never empty.
|
- Overflow is standardized per surface class and is never empty.
|
||||||
- Bulk exists only when it is genuinely useful.
|
- Bulk exists only when it is genuinely useful.
|
||||||
|
- Navigation and mutation do not share equal visual weight without
|
||||||
|
explicit hierarchy.
|
||||||
|
- Monitoring and workbench surfaces separate scope/context, selection,
|
||||||
|
navigation, and object actions.
|
||||||
- Scope chips must be truthful.
|
- Scope chips must be truthful.
|
||||||
- Domain nouns are canonical and stable.
|
- Domain nouns are canonical and stable.
|
||||||
- Critical operational truth is default-visible.
|
- Critical operational truth is default-visible.
|
||||||
@ -779,11 +1202,24 @@ #### Appendix A - One-page Condensed Constitution
|
|||||||
|
|
||||||
#### Appendix B - Feature Review Checklist
|
#### Appendix B - Feature Review Checklist
|
||||||
|
|
||||||
- Surface type is declared.
|
- Decision-role prominence is declared.
|
||||||
|
- The human-in-the-loop moment is explicit.
|
||||||
|
- Immediate-visible decision information is explicit.
|
||||||
|
- On-demand evidence / diagnostics boundaries are explicit.
|
||||||
|
- Any new primary surface is justified against an existing decision
|
||||||
|
context.
|
||||||
|
- Navigation reflects a workflow rather than storage structure.
|
||||||
|
- One governance case stays decidable in one focused context.
|
||||||
|
- The feature reduces search, review, or click work.
|
||||||
|
- The resulting surface is calmer and clearer, not merely larger.
|
||||||
|
- Broad action-surface class is declared.
|
||||||
|
- Detailed surface type is declared.
|
||||||
|
- The one most likely next operator action is explicit.
|
||||||
- Primary inspect/open model is defined.
|
- Primary inspect/open model is defined.
|
||||||
- Row-click rule is decided.
|
- Row-click rule is decided.
|
||||||
- View/Inspect is correctly present or correctly forbidden.
|
- View/Inspect is correctly present or correctly forbidden.
|
||||||
- Edit-as-inspect is used only when allowed.
|
- Edit-as-inspect is used only when allowed.
|
||||||
|
- Navigation and mutation are separated intentionally.
|
||||||
- Secondary actions are grouped correctly.
|
- Secondary actions are grouped correctly.
|
||||||
- Destructive actions are placed correctly.
|
- Destructive actions are placed correctly.
|
||||||
- Overflow is not empty.
|
- Overflow is not empty.
|
||||||
@ -797,18 +1233,32 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- Header passes the 5-second scan rule (HDR-001).
|
- Header passes the 5-second scan rule (HDR-001).
|
||||||
- No pure navigation in the header.
|
- No pure navigation in the header.
|
||||||
- Governance-changing actions have extra friction.
|
- Governance-changing actions have extra friction.
|
||||||
|
- Any special type or workflow-hub exception is real and justified.
|
||||||
|
|
||||||
#### Appendix C - Red Flags for Future PRs
|
#### Appendix C - Red Flags for Future PRs
|
||||||
|
|
||||||
|
- A primary surface has no clear human-in-the-loop moment.
|
||||||
|
- A technical object hub is promoted into primary navigation without
|
||||||
|
workflow justification.
|
||||||
|
- Default-visible content behaves like a diagnostics console instead of
|
||||||
|
a decision surface.
|
||||||
|
- The operator must assemble one decision from multiple equal-rank Run,
|
||||||
|
Evidence, Policy, Audit, or Finding pages.
|
||||||
|
- A feature adds automation, alerts, or statuses that increase net
|
||||||
|
attention load.
|
||||||
|
- The surface creates more noise than priority.
|
||||||
- Row click and View open the same destination.
|
- Row click and View open the same destination.
|
||||||
- A row becomes a control center.
|
- A row becomes a control center.
|
||||||
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||||
- More menus or bulk menus are empty.
|
- More menus or bulk menus are empty.
|
||||||
|
- A More menu becomes a mixed junk drawer with no ordering logic.
|
||||||
- Scope chips have no real scope effect.
|
- Scope chips have no real scope effect.
|
||||||
- Runs and Operations are used as competing primary collection nouns.
|
- Runs and Operations are used as competing primary collection nouns.
|
||||||
- Long workflow labels live in dense tables.
|
- Long workflow labels live in dense tables.
|
||||||
- Edit is used as default inspect even though a true View surface exists.
|
- Edit is used as default inspect even though a true View surface exists.
|
||||||
- Queue surfaces throw the operator out of context through row click.
|
- Queue surfaces throw the operator out of context through row click.
|
||||||
|
- A workbench surface mixes scope, selection, navigation, and object
|
||||||
|
actions as one flat header rail.
|
||||||
- Critical health or operability truth is hidden by default.
|
- Critical health or operability truth is hidden by default.
|
||||||
- A contract claims conformance while the rendered UI behaves differently.
|
- A contract claims conformance while the rendered UI behaves differently.
|
||||||
- Header has multiple equally weighted buttons without clear prioritization.
|
- Header has multiple equally weighted buttons without clear prioritization.
|
||||||
@ -884,6 +1334,9 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
|
Decision Surface or workflow-first navigation change.
|
||||||
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||||
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||||
|
|
||||||
@ -897,4 +1350,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
**Version**: 2.3.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-12
|
||||||
|
|||||||
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 |
|
||||||
@ -58,6 +58,14 @@ ## Constitution Check
|
|||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
|
operator-facing surface is classified as Primary Decision,
|
||||||
|
Secondary Context, or Tertiary Evidence / Diagnostics; primary
|
||||||
|
surfaces justify the human-in-the-loop moment, default-visible info
|
||||||
|
is limited to first-decision needs, deep proof is progressive
|
||||||
|
disclosed, one governance case stays decidable in one context where
|
||||||
|
practical, navigation follows workflows not storage structures, and
|
||||||
|
automation / alerts reduce attention load instead of adding noise
|
||||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
@ -71,7 +79,14 @@ ## Constitution Check
|
|||||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
- Action-surface discipline (ACTSURF-001 / HDR-001): every changed
|
||||||
|
surface declares one broad action-surface class; the spec names the
|
||||||
|
one likely next operator action; navigation is separated from
|
||||||
|
mutation; record/detail/edit pages keep at most one visible primary
|
||||||
|
header action; monitoring/workbench surfaces separate scope/context,
|
||||||
|
selection actions, navigation, and object actions; risky or rare
|
||||||
|
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||||
|
type or workflow-hub exception is explicit and justified
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
|
|||||||
@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
|
|||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
<!-- This section MUST be completed before the spec progresses beyond Draft.
|
||||||
|
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
|
||||||
|
|
||||||
|
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||||
|
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||||
|
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||||
|
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||||
|
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||||
|
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||||
|
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||||
|
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||||
|
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||||
|
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
|
||||||
|
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||||
|
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
- **Scope**: [workspace | tenant | canonical-view]
|
- **Scope**: [workspace | tenant | canonical-view]
|
||||||
@ -17,22 +35,37 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing surface,
|
||||||
|
fill out one row per affected surface. This role is orthogonal to the
|
||||||
|
Action Surface Class / Surface Type below.
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
fill out one row per affected surface.
|
fill out one row per affected surface. Declare the broad Action Surface
|
||||||
|
Class first, then the detailed Surface Type. Keep this table in sync
|
||||||
|
with the Decision-First Surface Role section above.
|
||||||
|
|
||||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Tenant policies page | CRUD / List-first Resource | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
| e.g. Tenant policies page | List / Table / Bulk | CRUD / List-first Resource | Open policy for review | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
If this feature adds a new operator-facing page or materially refactors
|
||||||
|
one, fill out one row per affected page/surface. The contract MUST show
|
||||||
|
how one governance case or operator task becomes decidable without
|
||||||
|
unnecessary cross-page reconstruction.
|
||||||
|
|
||||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
| e.g. Tenant policies page | Tenant operator | Decide whether policy state needs follow-up | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
@ -181,19 +214,50 @@ ## Requirements *(mandatory)*
|
|||||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||||
- the chosen surface type and why it is the correct classification,
|
- whether each affected surface is a Primary Decision Surface,
|
||||||
|
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||||
|
Surface, and why,
|
||||||
|
- which human-in-the-loop moment each primary surface supports,
|
||||||
|
- what MUST be visible immediately for the first decision,
|
||||||
|
- what is preserved but only revealed on demand,
|
||||||
|
- why any new primary surface cannot live inside an existing decision
|
||||||
|
context,
|
||||||
|
- how navigation follows operator workflows rather than storage
|
||||||
|
structures,
|
||||||
|
- how one governance case remains decidable in one focused context,
|
||||||
|
- how any new automation, notifications, or autonomous governance logic
|
||||||
|
reduce search/review/click load,
|
||||||
|
- and how the resulting default experience is calmer and clearer rather
|
||||||
|
than merely larger.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||||
|
- the chosen broad action-surface class and why it is the correct classification,
|
||||||
|
- the chosen detailed surface type and why it is the correct refinement,
|
||||||
|
- the one most likely next operator action,
|
||||||
- the one and only primary inspect/open model,
|
- the one and only primary inspect/open model,
|
||||||
- whether row click is required, allowed, or forbidden,
|
- whether row click is required, allowed, or forbidden,
|
||||||
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where pure navigation lives and why it is not competing with mutation,
|
||||||
- where secondary actions live,
|
- where secondary actions live,
|
||||||
- where destructive actions live,
|
- where destructive actions live,
|
||||||
|
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||||
- the canonical collection route and canonical detail route,
|
- the canonical collection route and canonical detail route,
|
||||||
- the scope signals shown to the operator and what real effect each one has,
|
- the scope signals shown to the operator and what real effect each one has,
|
||||||
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||||
- which critical operational truth is visible by default,
|
- which critical operational truth is visible by default,
|
||||||
- and any catalogued exception type, rationale, and dedicated test coverage.
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||||
|
feature adds or materially changes header actions, row actions, bulk
|
||||||
|
actions, or workbench controls, the spec MUST describe:
|
||||||
|
- how navigation, mutation, context signals, selection actions, and
|
||||||
|
dangerous actions are separated,
|
||||||
|
- why any visible secondary action deserves primary-plane placement,
|
||||||
|
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||||
|
- and why any workflow-hub, wizard, system, or other special-type
|
||||||
|
exception is genuine rather than a convenience shortcut.
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
- which diagnostics are secondary and how they are explicitly revealed,
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
|||||||
@ -39,30 +39,62 @@ # Tasks: [FEATURE NAME]
|
|||||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
- removing implementation-first wording from primary operator-facing copy.
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
|
- classifying each affected surface as Primary Decision, Secondary
|
||||||
|
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||||
|
sync with the governing spec,
|
||||||
|
- defining the human-in-the-loop moment and justifying any new Primary
|
||||||
|
Decision Surface against existing decision contexts,
|
||||||
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||||
- filling the spec’s Operator Surface Contract for every affected page,
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
|
- keeping default-visible content limited to first-decision needs and
|
||||||
|
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- keeping each governance case decidable in one focused context where
|
||||||
|
practical instead of forcing cross-page reconstruction,
|
||||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||||
|
- keeping navigation aligned to operator workflows rather than storage
|
||||||
|
structures,
|
||||||
|
- ensuring new automation, alerts, or autonomous flows reduce
|
||||||
|
search/review/click load instead of adding noise, extra lists, or
|
||||||
|
extra detail work,
|
||||||
|
- preserving a calm, prioritized default state that distinguishes
|
||||||
|
actionable work from worth-watching context and reference-only
|
||||||
|
information,
|
||||||
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||||
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
|
- assigning exactly one broad action-surface class to every changed
|
||||||
|
operator-facing surface and keeping the detailed surface type in sync
|
||||||
|
with the spec,
|
||||||
|
- identifying the one likely next operator action for each changed
|
||||||
|
surface and shaping the visible hierarchy around it,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||||
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||||
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||||
|
- separating navigation from mutation so pure context changes do not
|
||||||
|
compete visually with state-changing actions,
|
||||||
- moving additional secondary actions into More or the detail header,
|
- moving additional secondary actions into More or the detail header,
|
||||||
|
- ordering visible actions and grouped actions by meaning, frequency,
|
||||||
|
and risk rather than append order,
|
||||||
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||||
|
- ensuring workbench and monitoring surfaces separate scope/context,
|
||||||
|
selection actions, navigation, and object actions instead of mixing
|
||||||
|
them into one flat header zone,
|
||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any workflow-hub, wizard, utility/system, or other
|
||||||
|
special-type exception in the spec/PR and adding dedicated test
|
||||||
|
coverage,
|
||||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
@ -71,8 +103,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
- enforcing ACTSURF-001 / HDR-001 action discipline: record/detail/edit
|
||||||
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
|
pages keep at most 1 visible primary header action; pure navigation
|
||||||
|
moves to contextual placement; destructive or governance-changing
|
||||||
|
actions are separated and require friction; monitoring/workbench
|
||||||
|
surfaces use their own layered hierarchy; rare actions live in
|
||||||
|
structured Action Groups; every affected surface passes the few-second
|
||||||
|
scan rule,
|
||||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, mixed>|null */
|
/** @var array<string, mixed>|null */
|
||||||
public ?array $summaryAssessment = null;
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
|
public ?int $matrixBaselineProfileId = null;
|
||||||
|
|
||||||
|
public ?string $matrixSubjectKey = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -130,6 +138,12 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
$baselineProfileId = request()->query('baseline_profile_id');
|
||||||
|
$subjectKey = request()->query('subject_key');
|
||||||
|
|
||||||
|
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
|
||||||
|
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||||
$this->refreshStats();
|
$this->refreshStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,6 +258,9 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
||||||
|
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
||||||
|
'matrixSubjectKey' => $this->matrixSubjectKey,
|
||||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
$this->compareNowAction(),
|
$navigationContext = $this->navigationContext();
|
||||||
];
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('backToOrigin')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = $this->compareNowAction();
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function compareNowAction(): Action
|
private function compareNowAction(): Action
|
||||||
@ -389,7 +416,7 @@ private function compareNowAction(): Action
|
|||||||
->actions($run instanceof OperationRun ? [
|
->actions($run instanceof OperationRun ? [
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('Open operation')
|
->label('Open operation')
|
||||||
->url(OperationRunLinks::view($run, $tenant)),
|
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
|
||||||
] : [])
|
] : [])
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
|
|||||||
|
|
||||||
return $aggregate;
|
return $aggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::fromRequest($request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
756
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
756
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
@ -0,0 +1,756 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
|
||||||
|
use Filament\Resources\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class BaselineCompareMatrix extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithRecord;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
protected static ?string $breadcrumb = 'Compare matrix';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.baseline-compare-matrix';
|
||||||
|
|
||||||
|
public string $requestedMode = 'auto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedStates = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedSeverities = [];
|
||||||
|
|
||||||
|
public string $tenantSort = 'tenant_name';
|
||||||
|
|
||||||
|
public string $subjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
|
public ?string $focusedSubjectKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedStates = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedSeverities = [];
|
||||||
|
|
||||||
|
public string $draftTenantSort = 'tenant_name';
|
||||||
|
|
||||||
|
public string $draftSubjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $matrix = [];
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
$this->record = $this->resolveRecord($record);
|
||||||
|
$this->hydrateFiltersFromRequest();
|
||||||
|
$this->refreshMatrix();
|
||||||
|
$this->form->fill($this->filterFormState());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Grid::make([
|
||||||
|
'default' => 1,
|
||||||
|
'xl' => 2,
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
Grid::make([
|
||||||
|
'default' => 1,
|
||||||
|
'lg' => 5,
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
Select::make('draftSelectedPolicyTypes')
|
||||||
|
->label('Policy types')
|
||||||
|
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All policy types')
|
||||||
|
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
|
||||||
|
? 'Policy type filters appear after a usable reference snapshot is available.'
|
||||||
|
: null)
|
||||||
|
->extraFieldWrapperAttributes([
|
||||||
|
'data-testid' => 'matrix-policy-type-filter',
|
||||||
|
])
|
||||||
|
->columnSpan([
|
||||||
|
'lg' => 2,
|
||||||
|
]),
|
||||||
|
Select::make('draftSelectedStates')
|
||||||
|
->label('Technical states')
|
||||||
|
->options(fn (): array => $this->matrixOptions('stateOptions'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All technical states')
|
||||||
|
->columnSpan([
|
||||||
|
'lg' => 2,
|
||||||
|
]),
|
||||||
|
Select::make('draftSelectedSeverities')
|
||||||
|
->label('Severity')
|
||||||
|
->options(fn (): array => $this->matrixOptions('severityOptions'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All severities'),
|
||||||
|
])
|
||||||
|
->columnSpan([
|
||||||
|
'xl' => 1,
|
||||||
|
]),
|
||||||
|
Grid::make([
|
||||||
|
'default' => 1,
|
||||||
|
'md' => 2,
|
||||||
|
'xl' => 1,
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
Select::make('draftTenantSort')
|
||||||
|
->label('Tenant sort')
|
||||||
|
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||||
|
->default('tenant_name')
|
||||||
|
->native(false)
|
||||||
|
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
|
||||||
|
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
|
||||||
|
Select::make('draftSubjectSort')
|
||||||
|
->label('Subject sort')
|
||||||
|
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
|
||||||
|
->default('deviation_breadth')
|
||||||
|
->native(false)
|
||||||
|
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
|
||||||
|
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
|
||||||
|
])
|
||||||
|
->columnSpan([
|
||||||
|
'xl' => 1,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
return 'Compare matrix: '.$profile->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
|
||||||
|
->label('Compare assigned tenants')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Compare assigned tenants')
|
||||||
|
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
||||||
|
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
||||||
|
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
||||||
|
->action(fn (): mixed => $this->compareAssignedTenants());
|
||||||
|
|
||||||
|
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
|
||||||
|
$compareAssignedTenantsAction,
|
||||||
|
fn (): ?Workspace => $this->workspace(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
|
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('backToBaselineProfile')
|
||||||
|
->label('Back to baseline profile')
|
||||||
|
->color('gray')
|
||||||
|
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
|
||||||
|
$compareAssignedTenantsAction,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyFilters(): void
|
||||||
|
{
|
||||||
|
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
|
||||||
|
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
|
||||||
|
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
|
||||||
|
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
|
||||||
|
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
|
||||||
|
|
||||||
|
$this->redirect($this->filterUrl(), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->selectedPolicyTypes = [];
|
||||||
|
$this->selectedStates = [];
|
||||||
|
$this->selectedSeverities = [];
|
||||||
|
$this->tenantSort = 'tenant_name';
|
||||||
|
$this->subjectSort = 'deviation_breadth';
|
||||||
|
$this->focusedSubjectKey = null;
|
||||||
|
$this->draftSelectedPolicyTypes = [];
|
||||||
|
$this->draftSelectedStates = [];
|
||||||
|
$this->draftSelectedSeverities = [];
|
||||||
|
$this->draftTenantSort = 'tenant_name';
|
||||||
|
$this->draftSubjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
|
$this->redirect($this->filterUrl(), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshMatrix(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
|
||||||
|
'policyTypes' => $this->selectedPolicyTypes,
|
||||||
|
'states' => $this->selectedStates,
|
||||||
|
'severities' => $this->selectedSeverities,
|
||||||
|
'tenantSort' => $this->tenantSort,
|
||||||
|
'subjectSort' => $this->subjectSort,
|
||||||
|
'focusedSubjectKey' => $this->focusedSubjectKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pollMatrix(): void
|
||||||
|
{
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareLanding::getUrl(
|
||||||
|
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||||
|
panel: 'tenant',
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant($tenantId);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl(
|
||||||
|
'view',
|
||||||
|
[
|
||||||
|
'record' => $findingId,
|
||||||
|
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
|
||||||
|
],
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
|
||||||
|
{
|
||||||
|
return OperationRunLinks::tenantlessView(
|
||||||
|
$runId,
|
||||||
|
$this->navigationContext(
|
||||||
|
$tenantId !== null ? $this->tenant($tenantId) : null,
|
||||||
|
$subjectKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearSubjectFocusUrl(): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters([
|
||||||
|
'subject_key' => null,
|
||||||
|
]), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modeUrl(string $mode): string
|
||||||
|
{
|
||||||
|
return $this->filterUrl([
|
||||||
|
'mode' => $this->normalizeRequestedMode($mode),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterUrl(array $overrides = []): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeFilterCount(): int
|
||||||
|
{
|
||||||
|
return count($this->selectedPolicyTypes)
|
||||||
|
+ count($this->selectedStates)
|
||||||
|
+ count($this->selectedSeverities)
|
||||||
|
+ ($this->focusedSubjectKey !== null ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasStagedFilterChanges(): bool
|
||||||
|
{
|
||||||
|
return $this->draftFilterState() !== $this->appliedFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canUseCompactMode(): bool
|
||||||
|
{
|
||||||
|
return $this->visibleTenantCount() <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function presentationModeLabel(string $mode): string
|
||||||
|
{
|
||||||
|
return match ($mode) {
|
||||||
|
'dense' => 'Dense mode',
|
||||||
|
'compact' => 'Compact mode',
|
||||||
|
default => 'Auto mode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
public function activeFilterSummary(): array
|
||||||
|
{
|
||||||
|
$summary = [];
|
||||||
|
|
||||||
|
if ($this->selectedPolicyTypes !== []) {
|
||||||
|
$summary['Policy types'] = count($this->selectedPolicyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->selectedStates !== []) {
|
||||||
|
$summary['Technical states'] = count($this->selectedStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->selectedSeverities !== []) {
|
||||||
|
$summary['Severity'] = count($this->selectedSeverities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->focusedSubjectKey !== null) {
|
||||||
|
$summary['Focused subject'] = $this->focusedSubjectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
public function stagedFilterSummary(): array
|
||||||
|
{
|
||||||
|
$summary = [];
|
||||||
|
|
||||||
|
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
|
||||||
|
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSelectedStates !== $this->selectedStates) {
|
||||||
|
$summary['Technical states'] = count($this->draftSelectedStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
|
||||||
|
$summary['Severity'] = count($this->draftSelectedSeverities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftTenantSort !== $this->tenantSort) {
|
||||||
|
$summary['Tenant sort'] = $this->draftTenantSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSubjectSort !== $this->subjectSort) {
|
||||||
|
$summary['Subject sort'] = $this->draftSubjectSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
return array_merge($this->matrix, [
|
||||||
|
'profile' => $this->getRecord(),
|
||||||
|
'currentFilters' => [
|
||||||
|
'mode' => $this->requestedMode,
|
||||||
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
|
'state' => $this->selectedStates,
|
||||||
|
'severity' => $this->selectedSeverities,
|
||||||
|
'tenant_sort' => $this->tenantSort,
|
||||||
|
'subject_sort' => $this->subjectSort,
|
||||||
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
|
],
|
||||||
|
'draftFilters' => [
|
||||||
|
'policy_type' => $this->draftSelectedPolicyTypes,
|
||||||
|
'state' => $this->draftSelectedStates,
|
||||||
|
'severity' => $this->draftSelectedSeverities,
|
||||||
|
'tenant_sort' => $this->draftTenantSort,
|
||||||
|
'subject_sort' => $this->draftSubjectSort,
|
||||||
|
],
|
||||||
|
'presentationState' => $this->presentationState(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateFiltersFromRequest(): void
|
||||||
|
{
|
||||||
|
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
|
||||||
|
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
|
||||||
|
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
|
||||||
|
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
|
||||||
|
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
|
||||||
|
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
|
||||||
|
$subjectKey = request()->query('subject_key');
|
||||||
|
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||||
|
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
|
||||||
|
$this->draftSelectedStates = $this->selectedStates;
|
||||||
|
$this->draftSelectedSeverities = $this->selectedSeverities;
|
||||||
|
$this->draftTenantSort = $this->tenantSort;
|
||||||
|
$this->draftSubjectSort = $this->subjectSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function filterFormState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
|
||||||
|
'draftSelectedStates' => $this->draftSelectedStates,
|
||||||
|
'draftSelectedSeverities' => $this->draftSelectedSeverities,
|
||||||
|
'draftTenantSort' => $this->draftTenantSort,
|
||||||
|
'draftSubjectSort' => $this->draftSubjectSort,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function matrixOptions(string $key): array
|
||||||
|
{
|
||||||
|
$options = $this->matrix[$key] ?? null;
|
||||||
|
|
||||||
|
return is_array($options) ? $options : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selectedPolicyTypes: list<string>,
|
||||||
|
* selectedStates: list<string>,
|
||||||
|
* selectedSeverities: list<string>,
|
||||||
|
* tenantSort: string,
|
||||||
|
* subjectSort: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function draftFilterState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
|
||||||
|
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
|
||||||
|
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
|
||||||
|
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
|
||||||
|
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selectedPolicyTypes: list<string>,
|
||||||
|
* selectedStates: list<string>,
|
||||||
|
* selectedSeverities: list<string>,
|
||||||
|
* tenantSort: string,
|
||||||
|
* subjectSort: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function appliedFilterState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||||
|
'selectedStates' => $this->selectedStates,
|
||||||
|
'selectedSeverities' => $this->selectedSeverities,
|
||||||
|
'tenantSort' => $this->tenantSort,
|
||||||
|
'subjectSort' => $this->subjectSort,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeQueryList(mixed $value): array
|
||||||
|
{
|
||||||
|
$values = is_array($value) ? $value : [$value];
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
|
||||||
|
if (! is_string($item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($item);
|
||||||
|
|
||||||
|
return $normalized !== '' ? $normalized : null;
|
||||||
|
}, $values))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRequestedMode(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTenantSort(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'tenant_name';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSubjectSort(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'deviation_breadth';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenantsDisabledReason(): ?string
|
||||||
|
{
|
||||||
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
|
|
||||||
|
if (($reference['referenceState'] ?? null) !== 'ready') {
|
||||||
|
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
|
||||||
|
return 'No visible assigned tenants are available for compare.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenants(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
|
||||||
|
$summary = sprintf(
|
||||||
|
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
|
||||||
|
(int) $result['queuedCount'],
|
||||||
|
(int) $result['alreadyQueuedCount'],
|
||||||
|
(int) $result['blockedCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
$toast = (int) $result['queuedCount'] > 0
|
||||||
|
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||||
|
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||||
|
|
||||||
|
$toast
|
||||||
|
->body($summary.' Open Operations for progress and next steps.')
|
||||||
|
->actions([
|
||||||
|
Action::make('open_operations')
|
||||||
|
->label('Open operations')
|
||||||
|
->url(OperationRunLinks::index(
|
||||||
|
context: $this->navigationContext(),
|
||||||
|
allTenants: true,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('No baseline compares were started')
|
||||||
|
->body($summary)
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function routeParameters(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'record' => $this->getRecord(),
|
||||||
|
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
|
||||||
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
|
'state' => $this->selectedStates,
|
||||||
|
'severity' => $this->selectedSeverities,
|
||||||
|
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
|
||||||
|
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
|
||||||
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
|
...$overrides,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
$subjectKey ??= $this->focusedSubjectKey;
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::forBaselineCompareMatrix(
|
||||||
|
profile: $profile,
|
||||||
|
filters: $this->routeParameters(),
|
||||||
|
tenant: $tenant,
|
||||||
|
subjectKey: $subjectKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(int $tenantId): ?Tenant
|
||||||
|
{
|
||||||
|
return Tenant::query()
|
||||||
|
->whereKey($tenantId)
|
||||||
|
->where('workspace_id', (int) $this->getRecord()->workspace_id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function presentationState(): array
|
||||||
|
{
|
||||||
|
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'requestedMode' => $this->requestedMode,
|
||||||
|
'resolvedMode' => $resolvedMode,
|
||||||
|
'visibleTenantCount' => $this->visibleTenantCount(),
|
||||||
|
'activeFilterCount' => $this->activeFilterCount(),
|
||||||
|
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
|
||||||
|
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
|
||||||
|
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
|
||||||
|
'canOverrideMode' => $this->visibleTenantCount() > 0,
|
||||||
|
'compactModeAvailable' => $this->canUseCompactMode(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visibleTenantCount(): int
|
||||||
|
{
|
||||||
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
|
|
||||||
|
return (int) ($reference['visibleTenantCount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePresentationMode(int $visibleTenantCount): string
|
||||||
|
{
|
||||||
|
if ($this->requestedMode === 'dense') {
|
||||||
|
return 'dense';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
|
||||||
|
return 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $visibleTenantCount > 1 ? 'dense' : 'compact';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -37,6 +41,16 @@ class Alerts extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.alerts';
|
protected string $view = 'filament.pages.monitoring.alerts';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header keeps alerts scope and origin navigation quiet on the page-level overview.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The alerts overview is a page-level monitoring summary and does not inspect records inline.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The alerts overview does not render row-level secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The alerts overview does not expose bulk actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'The overview always renders KPI widgets and downstream drilldown navigation instead of a list-style empty state.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -93,7 +94,6 @@ public function mount(): void
|
|||||||
if ($requestedEventId !== null) {
|
if ($requestedEventId !== null) {
|
||||||
$this->resolveAuditLog($requestedEventId);
|
$this->resolveAuditLog($requestedEventId);
|
||||||
$this->selectedAuditLogId = $requestedEventId;
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,10 +102,24 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
array_splice($actions, 1, 0, [
|
||||||
|
Action::make('operate_hub_back_to_origin_audit_log')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
@ -164,30 +165,32 @@ protected function getHeaderActions(): array
|
|||||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
});
|
});
|
||||||
|
|
||||||
$actions[] = Action::make('clear_selected_exception')
|
$selectedContextActions = [
|
||||||
->label('Close details')
|
Action::make('clear_selected_exception')
|
||||||
->color('gray')
|
->label('Close details')
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
->color('gray')
|
||||||
->action(function (): void {
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
$this->selectedFindingExceptionId = null;
|
->action(function (): void {
|
||||||
$this->showSelectedExceptionSummary = false;
|
$this->clearSelectedException();
|
||||||
});
|
}),
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_exception')
|
Action::make('open_selected_exception')
|
||||||
->label('Open tenant detail')
|
->label('Open tenant detail')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_finding')
|
Action::make('open_selected_finding')
|
||||||
->label('Open finding')
|
->label('Open finding')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
->url(fn (): ?string => $this->selectedFindingUrl()),
|
||||||
|
];
|
||||||
|
|
||||||
$actions[] = Action::make('approve_selected_exception')
|
$selectedDecisionActions = [
|
||||||
|
Action::make('approve_selected_exception')
|
||||||
->label('Approve exception')
|
->label('Approve exception')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||||
@ -223,38 +226,51 @@ protected function getHeaderActions(): array
|
|||||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
});
|
}),
|
||||||
|
|
||||||
$actions[] = Action::make('reject_selected_exception')
|
Action::make('reject_selected_exception')
|
||||||
->label('Reject exception')
|
->label('Reject exception')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('rejection_reason')
|
Textarea::make('rejection_reason')
|
||||||
->label('Rejection reason')
|
->label('Rejection reason')
|
||||||
->rows(3)
|
->rows(3)
|
||||||
->required()
|
->required()
|
||||||
->maxLength(2000),
|
->maxLength(2000),
|
||||||
])
|
])
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
->action(function (array $data, FindingExceptionService $service): void {
|
||||||
$record = $this->selectedFindingException();
|
$record = $this->selectedFindingException();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$wasRenewalRequest = $record->isPendingRenewal();
|
$wasRenewalRequest = $record->isPendingRenewal();
|
||||||
$updated = $service->reject($record, $user, $data);
|
$updated = $service->reject($record, $user, $data);
|
||||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
});
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make($selectedContextActions)
|
||||||
|
->label('Selected context')
|
||||||
|
->icon('heroicon-o-rectangle-stack')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null);
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make($selectedDecisionActions)
|
||||||
|
->label('Review selected')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false);
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
@ -409,6 +425,12 @@ public function selectedFindingUrl(): ?string
|
|||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function clearSelectedException(): void
|
||||||
|
{
|
||||||
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->showSelectedExceptionSummary = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -142,6 +142,49 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* scope_label: string,
|
||||||
|
* scope_body: string,
|
||||||
|
* return_label: ?string,
|
||||||
|
* return_body: ?string,
|
||||||
|
* scope_reset_label: ?string,
|
||||||
|
* scope_reset_body: ?string,
|
||||||
|
* inspect_body: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function landingHierarchySummary(): array
|
||||||
|
{
|
||||||
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
$returnLabel = null;
|
||||||
|
$returnBody = null;
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$returnLabel = $navigationContext->backLinkLabel;
|
||||||
|
$returnBody = 'Return to the originating monitoring surface without competing with the current tab, filters, or row inspection flow.';
|
||||||
|
} elseif ($activeTenant instanceof Tenant) {
|
||||||
|
$returnLabel = 'Back to '.$activeTenant->name;
|
||||||
|
$returnBody = 'Return to the tenant dashboard when you need tenant-specific context outside this workspace monitoring landing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||||
|
'scope_body' => $activeTenant instanceof Tenant
|
||||||
|
? 'The landing is currently narrowed to one tenant inside the active workspace.'
|
||||||
|
: 'The landing is currently showing workspace-wide monitoring across all entitled tenants.',
|
||||||
|
'return_label' => $returnLabel,
|
||||||
|
'return_body' => $returnBody,
|
||||||
|
'scope_reset_label' => $activeTenant instanceof Tenant ? 'Show all tenants' : null,
|
||||||
|
'scope_reset_body' => $activeTenant instanceof Tenant
|
||||||
|
? 'Reset the landing back to workspace-wide monitoring when tenant-specific context is no longer needed.'
|
||||||
|
: null,
|
||||||
|
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function navigationContext(): ?CanonicalNavigationContext
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
{
|
{
|
||||||
if (! is_array($this->navigationContextPayload)) {
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
|
|||||||
@ -123,7 +123,7 @@ protected function getHeaderActions(): array
|
|||||||
$actions[] = Action::make('refresh')
|
$actions[] = Action::make('refresh')
|
||||||
->label('Refresh')
|
->label('Refresh')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('primary')
|
||||||
->url(fn (): string => isset($this->run)
|
->url(fn (): string => isset($this->run)
|
||||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||||
: route('admin.operations.index'));
|
: route('admin.operations.index'));
|
||||||
@ -155,6 +155,57 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* scope_label: string,
|
||||||
|
* scope_body: string,
|
||||||
|
* navigation_label: string,
|
||||||
|
* navigation_body: string,
|
||||||
|
* utility_body: string,
|
||||||
|
* related_body: string,
|
||||||
|
* follow_up_body: string,
|
||||||
|
* follow_up_label: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function monitoringDetailSummary(): array
|
||||||
|
{
|
||||||
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||||
|
|
||||||
|
$navigationLabel = 'Back to Operations';
|
||||||
|
$navigationBody = 'Return to the operations landing when this review is complete.';
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$navigationLabel = $navigationContext->backLinkLabel;
|
||||||
|
$navigationBody = 'Return to the originating surface while keeping refresh and follow-up work separate from navigation.';
|
||||||
|
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||||
|
$navigationLabel = 'Back to '.$activeTenant->name;
|
||||||
|
$navigationBody = 'Return to the active tenant dashboard, then widen back to the workspace view only when you need broader monitoring context.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$relatedLabels = array_values(array_keys($this->relatedLinks()));
|
||||||
|
$relatedBody = $relatedLabels === []
|
||||||
|
? 'Open keeps secondary drilldowns grouped under one control when downstream context exists.'
|
||||||
|
: 'Open keeps secondary drilldowns grouped under one control: '.implode(', ', $relatedLabels).'.';
|
||||||
|
|
||||||
|
$followUpLabel = $this->canResumeCapture() ? 'Resume capture' : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||||
|
'scope_body' => 'The current workspace or tenant scope remains visible without behaving like a primary task action.',
|
||||||
|
'navigation_label' => $navigationLabel,
|
||||||
|
'navigation_body' => $navigationBody,
|
||||||
|
'utility_body' => 'Refresh keeps the current run state accurate without changing scope.',
|
||||||
|
'related_body' => $relatedBody,
|
||||||
|
'follow_up_body' => $followUpLabel !== null
|
||||||
|
? 'Resume capture only appears when this run supports additional evidence collection.'
|
||||||
|
: 'No run-specific follow-up is currently available.',
|
||||||
|
'follow_up_label' => $followUpLabel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
public function mount(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -364,6 +415,7 @@ private function resumeCaptureAction(): Action
|
|||||||
return Action::make('resumeCapture')
|
return Action::make('resumeCapture')
|
||||||
->label('Resume capture')
|
->label('Resume capture')
|
||||||
->icon('heroicon-o-forward')
|
->icon('heroicon-o-forward')
|
||||||
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Resume capture')
|
->modalHeading('Resume capture')
|
||||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
||||||
@ -532,9 +584,16 @@ private function relatedLinks(bool $fresh = false): array
|
|||||||
|
|
||||||
$resolver = app(RelatedNavigationResolver::class);
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
return $fresh
|
$links = $fresh
|
||||||
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||||
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||||
|
|
||||||
|
unset(
|
||||||
|
$links[OperationRunLinks::collectionLabel()],
|
||||||
|
$links[OperationRunLinks::openCollectionLabel()],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||||
|
|||||||
@ -94,7 +94,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->resetTable();
|
$this->clearRegisterFilters();
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -209,7 +209,7 @@ public function table(Table $table): Table
|
|||||||
->label('Clear filters')
|
->label('Clear filters')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(fn (): mixed => $this->resetTable()),
|
->action(fn (): mixed => $this->clearRegisterFilters()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,9 +311,29 @@ private function applyRequestedTenantPrefilter(): void
|
|||||||
|
|
||||||
private function hasActiveFilters(): bool
|
private function hasActiveFilters(): bool
|
||||||
{
|
{
|
||||||
$filters = array_filter((array) $this->tableFilters);
|
return $this->currentTenantFilterId() !== null
|
||||||
|
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||||
|
|| is_string(data_get($this->tableFilters, 'completeness_state.value'))
|
||||||
|
|| is_string(data_get($this->tableFilters, 'published_state.value'))
|
||||||
|
|| filled(data_get($this->tableFilters, 'review_date.from'))
|
||||||
|
|| filled(data_get($this->tableFilters, 'review_date.until'));
|
||||||
|
}
|
||||||
|
|
||||||
return $filters !== [];
|
private function clearRegisterFilters(): void
|
||||||
|
{
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||||
|
$this->removeTableFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentTenantFilterId(): ?int
|
||||||
|
{
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
private function workspace(): ?Workspace
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListAlertDeliveries extends ListRecords
|
class ListAlertDeliveries extends ListRecords
|
||||||
@ -22,9 +24,23 @@ public function mount(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_alerts',
|
scopeActionName: 'operate_hub_scope_alerts',
|
||||||
returnActionName: 'operate_hub_return_alerts',
|
returnActionName: 'operate_hub_return_alerts',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
array_splice($actions, 1, 0, [
|
||||||
|
Action::make('operate_hub_back_to_origin_alert_deliveries')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -25,6 +26,9 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
@ -43,6 +47,7 @@
|
|||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -135,7 +140,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -318,6 +323,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (BaselineProfile $record): array => self::detailRelatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
@ -333,6 +347,37 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function detailRelatedContextEntries(BaselineProfile $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
$snapshotEntry = app(RelatedNavigationResolver::class)
|
||||||
|
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $record)[0] ?? null;
|
||||||
|
|
||||||
|
if ($snapshotEntry instanceof RelatedContextEntry) {
|
||||||
|
$entries[] = $snapshotEntry->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'compare_matrix',
|
||||||
|
label: 'Compare matrix',
|
||||||
|
value: 'Review compare matrix',
|
||||||
|
secondaryValue: $record->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot
|
||||||
|
? 'Use the latest consumable snapshot to inspect compare outcomes.'
|
||||||
|
: 'Open the matrix to inspect compare readiness and previous results.',
|
||||||
|
targetUrl: self::compareMatrixUrl($record),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open compare matrix',
|
||||||
|
contextBadge: 'Comparison',
|
||||||
|
)->toArray();
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$workspace = self::resolveWorkspace();
|
$workspace = self::resolveWorkspace();
|
||||||
@ -447,10 +492,16 @@ public static function getPages(): array
|
|||||||
'index' => Pages\ListBaselineProfiles::route('/'),
|
'index' => Pages\ListBaselineProfiles::route('/'),
|
||||||
'create' => Pages\CreateBaselineProfile::route('/create'),
|
'create' => Pages\CreateBaselineProfile::route('/create'),
|
||||||
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
||||||
|
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
|
||||||
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function compareMatrixUrl(BaselineProfile|int $profile): string
|
||||||
|
{
|
||||||
|
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,15 +16,13 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -37,24 +35,19 @@ class ViewBaselineProfile extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('view_active_snapshot')
|
|
||||||
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
|
|
||||||
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
|
|
||||||
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
|
|
||||||
->color('gray'),
|
|
||||||
$this->captureAction(),
|
$this->captureAction(),
|
||||||
$this->compareNowAction(),
|
$this->compareNowAction(),
|
||||||
EditAction::make()
|
ActionGroup::make([
|
||||||
->visible(fn (): bool => $this->hasManageCapability()),
|
$this->compareAssignedTenantsAction(),
|
||||||
|
EditAction::make()
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability()),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-m-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeSnapshotEntry(): ?RelatedContextEntry
|
|
||||||
{
|
|
||||||
return app(RelatedNavigationResolver::class)
|
|
||||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function captureAction(): Action
|
private function captureAction(): Action
|
||||||
{
|
{
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
@ -76,6 +69,7 @@ private function captureAction(): Action
|
|||||||
->label($label)
|
->label($label)
|
||||||
->icon('heroicon-o-camera')
|
->icon('heroicon-o-camera')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
@ -188,6 +182,8 @@ private function compareNowAction(): Action
|
|||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! $this->profileHasConsumableSnapshot())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
@ -307,6 +303,71 @@ private function compareNowAction(): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenantsAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('compareAssignedTenants')
|
||||||
|
->label('Compare assigned tenants')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Compare assigned tenants')
|
||||||
|
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
|
||||||
|
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
|
||||||
|
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
|
||||||
|
$summary = sprintf(
|
||||||
|
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
|
||||||
|
(int) $result['queuedCount'],
|
||||||
|
(int) $result['alreadyQueuedCount'],
|
||||||
|
(int) $result['blockedCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'],
|
||||||
|
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
$toast = (int) $result['queuedCount'] > 0
|
||||||
|
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||||
|
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||||
|
|
||||||
|
$toast
|
||||||
|
->body($summary.' Open Operations for progress and next steps.')
|
||||||
|
->actions([
|
||||||
|
Action::make('open_operations')
|
||||||
|
->label('Open operations')
|
||||||
|
->url(OperationRunLinks::index(allTenants: true)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('No baseline compares were started')
|
||||||
|
->body($summary)
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return WorkspaceUiEnforcement::forAction(
|
||||||
|
$action,
|
||||||
|
fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
|
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -407,4 +468,48 @@ private function profileHasConsumableSnapshot(): bool
|
|||||||
|
|
||||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenantsDisabledReason(): ?string
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
if (! $this->profileHasConsumableSnapshot()) {
|
||||||
|
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->visibleAssignedTenantCount($profile) === 0) {
|
||||||
|
return 'No visible assigned tenants are available for compare.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visibleAssignedTenantCount(BaselineProfile $profile): int
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
|
->get(['id'])
|
||||||
|
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||||
|
->count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -115,7 +117,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -181,6 +183,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (EvidenceSnapshot $record): array => static::relatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Evidence dimensions')
|
Section::make('Evidence dimensions')
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('items')
|
RepeatableEntry::make('items')
|
||||||
@ -213,6 +224,48 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if (is_numeric($record->operation_run_id)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'operation_run',
|
||||||
|
label: 'Operation',
|
||||||
|
value: sprintf('#%d', (int) $record->operation_run_id),
|
||||||
|
secondaryValue: 'Open the latest evidence refresh operation.',
|
||||||
|
targetUrl: OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: OperationRunLinks::openLabel(),
|
||||||
|
contextBadge: 'Operations',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack = $record->reviewPacks()
|
||||||
|
->latest('created_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'review_pack',
|
||||||
|
label: 'Review pack',
|
||||||
|
value: sprintf('#%d', (int) $pack->getKey()),
|
||||||
|
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||||
|
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'View review pack',
|
||||||
|
contextBadge: 'Reporting',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -5,12 +5,9 @@
|
|||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -29,30 +26,11 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
|
||||||
Actions\Action::make('view_review_pack')
|
|
||||||
->label('View review pack')
|
|
||||||
->icon('heroicon-o-document-text')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$pack = $this->latestReviewPack();
|
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('refresh_snapshot')
|
Actions\Action::make('refresh_snapshot')
|
||||||
->label('Refresh evidence')
|
->label('Refresh evidence')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -92,11 +70,4 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewPack(): ?ReviewPack
|
|
||||||
{
|
|
||||||
return $this->record->reviewPacks()
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -34,6 +36,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -115,7 +118,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -217,6 +220,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(3),
|
->columns(3),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Evidence references')
|
Section::make('Evidence references')
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('evidenceReferences')
|
RepeatableEntry::make('evidenceReferences')
|
||||||
@ -245,6 +257,44 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(FindingException $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if ($record->finding && $record->tenant instanceof Tenant) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'finding',
|
||||||
|
label: 'Finding',
|
||||||
|
value: static::findingSummary($record),
|
||||||
|
secondaryValue: 'Return to the linked finding detail.',
|
||||||
|
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: 'Open finding',
|
||||||
|
contextBadge: 'Governance',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'approval_queue',
|
||||||
|
label: 'Approval queue',
|
||||||
|
value: 'Review pending exception requests',
|
||||||
|
secondaryValue: 'Return to the queue for the rest of this tenant’s governance workload.',
|
||||||
|
targetUrl: static::approvalQueueUrl($record->tenant),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open approval queue',
|
||||||
|
contextBadge: 'Queue',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -34,40 +33,10 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('open_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}),
|
|
||||||
Action::make('open_approval_queue')
|
|
||||||
->label('Open approval queue')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(function (): bool {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
|
||||||
})
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
|
||||||
: null;
|
|
||||||
}),
|
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('primary')
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||||
->fillForm(fn (): array => [
|
->fillForm(fn (): array => [
|
||||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||||
|
|||||||
@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
|
|||||||
|
|
||||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||||
{
|
{
|
||||||
|
$incomingContext = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
if (
|
||||||
|
$incomingContext instanceof CanonicalNavigationContext
|
||||||
|
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
|
||||||
|
&& $incomingContext->backLinkUrl !== null
|
||||||
|
) {
|
||||||
|
return $incomingContext;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
return new CanonicalNavigationContext(
|
return new CanonicalNavigationContext(
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Actions\Action::make('back_to_origin')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($actions, [
|
||||||
Actions\Action::make('primary_related')
|
Actions\Action::make('primary_related')
|
||||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||||
@ -53,11 +64,16 @@ protected function getHeaderActions(): array
|
|||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSubheading(): string|Htmlable|null
|
public function getSubheading(): string|Htmlable|null
|
||||||
{
|
{
|
||||||
return FindingResource::findingSubheading($this->getRecord());
|
return FindingResource::findingSubheading($this->getRecord());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -18,49 +18,54 @@ class EditTenant extends EditRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return array_values(array_filter([
|
||||||
Actions\ViewAction::make(),
|
Actions\ActionGroup::make([
|
||||||
Actions\Action::make('related_onboarding')
|
UiEnforcement::forAction(
|
||||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
Action::make('restore')
|
||||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->color('success')
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
UiEnforcement::forAction(
|
->requiresConfirmation()
|
||||||
Action::make('restore')
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
->color('success')
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
|
||||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
->requiresConfirmation()
|
TenantResource::restoreTenant($record, $auditLogger);
|
||||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
|
})
|
||||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
)
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
->tooltip('You do not have permission to restore tenants.')
|
||||||
TenantResource::restoreTenant($record, $auditLogger);
|
->preserveVisibility()
|
||||||
})
|
->destructive()
|
||||||
)
|
->apply(),
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
UiEnforcement::forAction(
|
||||||
->tooltip('You do not have permission to restore tenants.')
|
Action::make('archive')
|
||||||
->preserveVisibility()
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
|
||||||
->destructive()
|
->color('danger')
|
||||||
->apply(),
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
UiEnforcement::forAction(
|
->requiresConfirmation()
|
||||||
Action::make('archive')
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
->color('danger')
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
|
||||||
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
->requiresConfirmation()
|
TenantResource::archiveTenant($record, $auditLogger);
|
||||||
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
|
})
|
||||||
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
)
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
->tooltip('You do not have permission to archive tenants.')
|
||||||
TenantResource::archiveTenant($record, $auditLogger);
|
->preserveVisibility()
|
||||||
})
|
->destructive()
|
||||||
)
|
->apply(),
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
])
|
||||||
->tooltip('You do not have permission to archive tenants.')
|
->label('Lifecycle')
|
||||||
->preserveVisibility()
|
->icon('heroicon-o-archive-box')
|
||||||
->destructive()
|
->color('gray')
|
||||||
->apply(),
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
];
|
&& in_array(
|
||||||
|
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
||||||
|
['archive', 'restore'],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ protected function getTableEmptyStateHeading(): ?string
|
|||||||
protected function getTableEmptyStateDescription(): ?string
|
protected function getTableEmptyStateDescription(): ?string
|
||||||
{
|
{
|
||||||
if ($this->hasActiveTriageEmptyState()) {
|
if ($this->hasActiveTriageEmptyState()) {
|
||||||
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
|
return 'Try a different backup posture, recovery evidence, or review-state filter, or return to the default calm-browsing order.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getTableEmptyStateDescription();
|
return parent::getTableEmptyStateDescription();
|
||||||
@ -85,6 +85,7 @@ private function applyRequestedTriageIntent(): void
|
|||||||
{
|
{
|
||||||
$hasIntent = request()->query->has('backup_posture')
|
$hasIntent = request()->query->has('backup_posture')
|
||||||
|| request()->query->has('recovery_evidence')
|
|| request()->query->has('recovery_evidence')
|
||||||
|
|| request()->query->has('review_state')
|
||||||
|| request()->query->has('triage_sort');
|
|| request()->query->has('triage_sort');
|
||||||
|
|
||||||
if (! $hasIntent) {
|
if (! $hasIntent) {
|
||||||
@ -93,9 +94,10 @@ private function applyRequestedTriageIntent(): void
|
|||||||
|
|
||||||
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
||||||
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
||||||
|
$reviewStates = TenantResource::sanitizeReviewStates(request()->query('review_state'));
|
||||||
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
|
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
|
||||||
|
|
||||||
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
|
foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
|
||||||
data_forget($this->tableFilters, $filterName);
|
data_forget($this->tableFilters, $filterName);
|
||||||
data_forget($this->tableDeferredFilters, $filterName);
|
data_forget($this->tableDeferredFilters, $filterName);
|
||||||
}
|
}
|
||||||
@ -110,6 +112,11 @@ private function applyRequestedTriageIntent(): void
|
|||||||
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
|
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($reviewStates !== []) {
|
||||||
|
$this->tableFilters['review_state']['values'] = $reviewStates;
|
||||||
|
$this->tableDeferredFilters['review_state']['values'] = $reviewStates;
|
||||||
|
}
|
||||||
|
|
||||||
if ($triageSort !== null) {
|
if ($triageSort !== null) {
|
||||||
$this->tableFilters['triage_sort']['value'] = $triageSort;
|
$this->tableFilters['triage_sort']['value'] = $triageSort;
|
||||||
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
|
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
|
||||||
@ -122,17 +129,19 @@ private function hasActiveTriageEmptyState(): bool
|
|||||||
|
|
||||||
return $state['backup_posture'] !== []
|
return $state['backup_posture'] !== []
|
||||||
|| $state['recovery_evidence'] !== []
|
|| $state['recovery_evidence'] !== []
|
||||||
|
|| $state['review_state'] !== []
|
||||||
|| $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
|| $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, triage_sort: string|null}
|
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, review_state: list<string>, triage_sort: string|null}
|
||||||
*/
|
*/
|
||||||
public function currentPortfolioTriageReturnState(): array
|
public function currentPortfolioTriageReturnState(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
|
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
|
||||||
'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
|
'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
|
||||||
|
'review_state' => TenantResource::sanitizeReviewStates(data_get($this->tableFilters, 'review_state.values', [])),
|
||||||
'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
|
'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
@ -56,29 +55,8 @@ protected function getHeaderWidgets(): array
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return array_values(array_filter([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('provider_connections')
|
|
||||||
->label('Provider connections')
|
|
||||||
->icon('heroicon-o-link')
|
|
||||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
Actions\Action::make('related_onboarding')
|
|
||||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
|
||||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Grant admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
@ -91,6 +69,13 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
])
|
||||||
|
->label('External links')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label(self::verificationHeaderActionLabel())
|
->label(self::verificationHeaderActionLabel())
|
||||||
@ -156,10 +141,6 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label(OperationRunLinks::openLabel())
|
->label(OperationRunLinks::openLabel())
|
||||||
@ -283,6 +264,13 @@ protected function getHeaderActions(): array
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('Setup')
|
||||||
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||||
@ -318,9 +306,11 @@ protected function getHeaderActions(): array
|
|||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Lifecycle')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('gray'),
|
->color('gray')
|
||||||
];
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -570,6 +570,7 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
|
'context_links' => static::summaryContextLinks($record),
|
||||||
'metrics' => [
|
'metrics' => [
|
||||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
@ -579,6 +580,43 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||||
|
*/
|
||||||
|
private static function summaryContextLinks(TenantReview $record): array
|
||||||
|
{
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
if (is_numeric($record->operation_run_id)) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Operation',
|
||||||
|
'label' => 'Open operation',
|
||||||
|
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||||
|
'description' => 'Inspect the latest review composition or refresh run.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->currentExportReviewPack && $record->tenant) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Executive pack',
|
||||||
|
'label' => 'View executive pack',
|
||||||
|
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||||
|
'description' => 'Open the current export that belongs to this review.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Evidence snapshot',
|
||||||
|
'label' => 'View evidence snapshot',
|
||||||
|
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||||
|
'description' => 'Return to the evidence basis behind this review.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -53,153 +52,235 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$secondaryActions = $this->secondaryLifecycleActions();
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id
|
|
||||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_export')
|
|
||||||
->label('View executive pack')
|
|
||||||
->icon('heroicon-o-document-arrow-down')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
|
||||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
|
||||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_evidence')
|
|
||||||
->label('View evidence snapshot')
|
|
||||||
->icon('heroicon-o-shield-check')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
|
||||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
|
||||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('refresh_review')
|
|
||||||
->label('Refresh review')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
return array_values(array_filter([
|
||||||
abort(403);
|
$this->primaryLifecycleAction(),
|
||||||
}
|
Actions\ActionGroup::make($secondaryActions)
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Refresh review queued')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('publish_review')
|
|
||||||
->label('Publish review')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
|
||||||
Notification::make()->success()->title('Review published')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_next_review')
|
|
||||||
->label('Create next review')
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive_review')
|
|
||||||
->label('Archive review')
|
|
||||||
->icon('heroicon-o-archive-box')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
|
||||||
$this->refreshFormData(['status', 'archived_at']);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Review archived')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('More')
|
->label('More')
|
||||||
->icon('heroicon-m-ellipsis-vertical')
|
->icon('heroicon-m-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray')
|
||||||
];
|
->visible(fn (): bool => $secondaryActions !== []),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
|
$this->archiveReviewAction(),
|
||||||
|
])
|
||||||
|
->label('Danger')
|
||||||
|
->icon('heroicon-o-archive-box')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryLifecycleAction(): ?Actions\Action
|
||||||
|
{
|
||||||
|
return match ($this->primaryLifecycleActionName()) {
|
||||||
|
'refresh_review' => $this->refreshReviewAction(),
|
||||||
|
'publish_review' => $this->publishReviewAction(),
|
||||||
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryLifecycleActionName(): ?string
|
||||||
|
{
|
||||||
|
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||||
|
return 'export_executive_pack';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
|
||||||
|
return 'publish_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->record->isMutable()) {
|
||||||
|
return 'refresh_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Actions\Action>
|
||||||
|
*/
|
||||||
|
private function secondaryLifecycleActions(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn (string $name): ?Actions\Action => match ($name) {
|
||||||
|
'refresh_review' => $this->refreshReviewAction(),
|
||||||
|
'publish_review' => $this->publishReviewAction(),
|
||||||
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||||
|
'create_next_review' => $this->createNextReviewAction(),
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
$this->secondaryLifecycleActionNames(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function secondaryLifecycleActionNames(): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
if ($this->record->isMutable()) {
|
||||||
|
$names[] = 'refresh_review';
|
||||||
|
$names[] = 'publish_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array((string) $this->record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true)) {
|
||||||
|
$names[] = 'export_executive_pack';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->record->isPublished()) {
|
||||||
|
$names[] = 'create_next_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$names,
|
||||||
|
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('refresh_review')
|
||||||
|
->label('Refresh review')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(TenantReviewService::class)->refresh($this->record, $user);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Refresh review queued')->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('publish_review')
|
||||||
|
->label('Publish review')
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
||||||
|
Notification::make()->success()->title('Review published')->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true))
|
||||||
|
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNextReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('create_next_review')
|
||||||
|
->label('Create next review')
|
||||||
|
->icon('heroicon-o-document-duplicate')
|
||||||
|
->hidden(fn (): bool => ! $this->record->isPublished())
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archiveReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive_review')
|
||||||
|
->label('Archive review')
|
||||||
|
->icon('heroicon-o-archive-box')
|
||||||
|
->color('danger')
|
||||||
|
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
||||||
|
$this->refreshFormData(['status', 'archived_at']);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Review archived')->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,18 +5,51 @@
|
|||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantTriageReview;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\PortfolioTriage\PortfolioArrivalContext;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
|
||||||
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||||
|
use Filament\Schemas\Contracts\HasSchemas;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
class TenantTriageArrivalContinuity extends Widget
|
class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
|
||||||
{
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithSchemas;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $arrivalState = null;
|
||||||
|
|
||||||
protected static bool $isLazy = false;
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
|
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->arrivalState = PortfolioArrivalContextToken::decode(
|
||||||
|
request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -25,11 +58,210 @@ protected function getViewData(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return ['context' => null];
|
return ['context' => null, 'reviewState' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->resolveArrivalContext($tenant);
|
||||||
|
|
||||||
|
if ($context === null) {
|
||||||
|
return ['context' => null, 'reviewState' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant),
|
'context' => $context,
|
||||||
|
'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function markReviewedAction(): Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Action::make('markReviewed')
|
||||||
|
->label('Mark reviewed')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Mark reviewed')
|
||||||
|
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED))
|
||||||
|
->visible(fn (): bool => $this->canShowReviewActions())
|
||||||
|
->action(function (TenantTriageReviewService $service): void {
|
||||||
|
$this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFollowUpNeededAction(): Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Action::make('markFollowUpNeeded')
|
||||||
|
->label('Mark follow-up needed')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Mark follow-up needed')
|
||||||
|
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED))
|
||||||
|
->visible(fn (): bool => $this->canShowReviewActions())
|
||||||
|
->action(function (TenantTriageReviewService $service): void {
|
||||||
|
$this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canShowReviewActions(): bool
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->resolveArrivalContext($tenant);
|
||||||
|
|
||||||
|
if ($context === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewModalDescription(string $targetManualState): \Closure
|
||||||
|
{
|
||||||
|
return function () use ($targetManualState): string {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return 'This triage session is no longer available.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->resolveArrivalContext($tenant);
|
||||||
|
|
||||||
|
if ($context === null) {
|
||||||
|
return 'This triage session is no longer available.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
|
||||||
|
|
||||||
|
if (($reviewState['current_concern_present'] ?? false) !== true) {
|
||||||
|
return 'This triage session no longer points at a current concern.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentLabel = BadgeRenderer::spec(
|
||||||
|
BadgeDomain::TenantTriageReviewState,
|
||||||
|
(string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED),
|
||||||
|
)->label;
|
||||||
|
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
|
||||||
|
|
||||||
|
return implode("\n\n", [
|
||||||
|
'Concern family: '.$this->concernFamilyLabel($context->concernFamily),
|
||||||
|
'Current review state: '.$currentLabel,
|
||||||
|
'Target state: '.$targetLabel,
|
||||||
|
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->resolveArrivalContext($tenant);
|
||||||
|
|
||||||
|
if ($context === null) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No triage session available')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
|
||||||
|
|
||||||
|
if (($reviewState['current_concern_present'] ?? false) !== true) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No current concern to update')
|
||||||
|
->body('This arrival context no longer maps to an active concern.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||||
|
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||||
|
$actor = auth()->user();
|
||||||
|
|
||||||
|
$review = match ($targetManualState) {
|
||||||
|
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
|
||||||
|
tenant: $tenant,
|
||||||
|
concernFamily: $context->concernFamily,
|
||||||
|
backupHealth: $backupHealth,
|
||||||
|
recoveryEvidence: $recoveryEvidence,
|
||||||
|
actor: $actor instanceof User ? $actor : null,
|
||||||
|
),
|
||||||
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
|
||||||
|
tenant: $tenant,
|
||||||
|
concernFamily: $context->concernFamily,
|
||||||
|
backupHealth: $backupHealth,
|
||||||
|
recoveryEvidence: $recoveryEvidence,
|
||||||
|
actor: $actor instanceof User ? $actor : null,
|
||||||
|
),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $review instanceof TenantTriageReview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Review state updated')
|
||||||
|
->body(sprintf(
|
||||||
|
'%s is now %s for %s.',
|
||||||
|
$tenant->name,
|
||||||
|
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
|
||||||
|
$this->concernFamilyLabel($context->concernFamily),
|
||||||
|
))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
|
||||||
|
{
|
||||||
|
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||||
|
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||||
|
|
||||||
|
return app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
tenantIds: [(int) $tenant->getKey()],
|
||||||
|
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
|
||||||
|
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
|
||||||
|
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function concernFamilyLabel(string $concernFamily): string
|
||||||
|
{
|
||||||
|
return match ($concernFamily) {
|
||||||
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
|
||||||
|
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
|
||||||
|
default => 'Portfolio concern',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
|
||||||
|
{
|
||||||
|
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,11 @@ class WorkspaceNeedsAttention extends Widget
|
|||||||
*/
|
*/
|
||||||
public array $emptyState = [];
|
public array $emptyState = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $triageReviewProgress = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{
|
* @param array<int, array{
|
||||||
* key: string,
|
* key: string,
|
||||||
@ -71,10 +76,12 @@ class WorkspaceNeedsAttention extends Widget
|
|||||||
* action_label: string,
|
* action_label: string,
|
||||||
* action_url: string
|
* action_url: string
|
||||||
* } $emptyState
|
* } $emptyState
|
||||||
|
* @param array<int, array<string, mixed>> $triageReviewProgress
|
||||||
*/
|
*/
|
||||||
public function mount(array $items = [], array $emptyState = []): void
|
public function mount(array $items = [], array $emptyState = [], array $triageReviewProgress = []): void
|
||||||
{
|
{
|
||||||
$this->items = $items;
|
$this->items = $items;
|
||||||
$this->emptyState = $emptyState;
|
$this->emptyState = $emptyState;
|
||||||
|
$this->triageReviewProgress = $triageReviewProgress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'assigned_by_user_id');
|
return $this->belongsTo(User::class, 'assigned_by_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder
|
||||||
|
{
|
||||||
|
$profileId = $profile instanceof BaselineProfile
|
||||||
|
? (int) $profile->getKey()
|
||||||
|
: (int) $profile;
|
||||||
|
|
||||||
|
return $query->where('baseline_profile_id', $profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeInWorkspace(Builder $query, int $workspaceId): Builder
|
||||||
|
{
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId))
|
||||||
|
->forBaselineProfile($profile)
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->map(static fn (mixed $tenantId): int => (int) $tenantId)
|
||||||
|
->filter(static fn (int $tenantId): bool => $tenantId > 0)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -274,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder
|
|||||||
->openWorkflow();
|
->openWorkflow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
|
||||||
|
{
|
||||||
|
$profileId = $profile instanceof BaselineProfile
|
||||||
|
? (int) $profile->getKey()
|
||||||
|
: (int) $profile;
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->drift()
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', 'baseline_profile:'.$profileId);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeOverdueOpen(Builder $query): Builder
|
public function scopeOverdueOpen(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query
|
return $query
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder
|
|||||||
->where('outcome', OperationRunOutcome::Failed->value);
|
->where('outcome', OperationRunOutcome::Failed->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
|
||||||
|
{
|
||||||
|
$profileId = $profile instanceof BaselineProfile
|
||||||
|
? (int) $profile->getKey()
|
||||||
|
: (int) $profile;
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('type', OperationRunType::BaselineCompare->value)
|
||||||
|
->where('context->baseline_profile_id', $profileId);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||||
{
|
{
|
||||||
$policy ??= app(OperationLifecyclePolicy::class);
|
$policy ??= app(OperationLifecyclePolicy::class);
|
||||||
@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool
|
|||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $tenantIds
|
||||||
|
* @return \Illuminate\Support\Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function latestBaselineCompareRunsForProfile(
|
||||||
|
BaselineProfile|int $profile,
|
||||||
|
array $tenantIds,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
bool $completedOnly = false,
|
||||||
|
): \Illuminate\Support\Collection {
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs = static::query()
|
||||||
|
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->baselineCompareForProfile($profile)
|
||||||
|
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $runs
|
||||||
|
->unique(static fn (self $run): int => (int) $run->tenant_id)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
||||||
{
|
{
|
||||||
if ($tenantId <= 0) {
|
if ($tenantId <= 0) {
|
||||||
|
|||||||
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
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->singleton(CapabilityResolver::class);
|
$this->app->scoped(CapabilityResolver::class);
|
||||||
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
use Filament\Navigation\NavigationItem;
|
use Filament\Navigation\NavigationItem;
|
||||||
@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->homeUrl(fn (): string => route('admin.home'))
|
->homeUrl(fn (): string => route('admin.home'))
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
@ -182,7 +184,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->unsavedChangesAlerts()
|
->unsavedChangesAlerts()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -31,11 +32,12 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('system')
|
->path('system')
|
||||||
->authGuard('platform')
|
->authGuard('platform')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_START,
|
PanelsRenderHook::BODY_START,
|
||||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogo(fn () => view('filament.admin.logo'))
|
->brandLogo(fn () => view('filament.admin.logo'))
|
||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix(null)
|
->tenantRoutePrefix(null)
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
@ -93,7 +95,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
@ -54,6 +54,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
Capabilities::TENANT_REVIEW_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
Capabilities::TENANT_REVIEW_MANAGE,
|
||||||
|
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||||
Capabilities::EVIDENCE_VIEW,
|
Capabilities::EVIDENCE_VIEW,
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
Capabilities::EVIDENCE_MANAGE,
|
||||||
],
|
],
|
||||||
@ -94,6 +95,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
Capabilities::TENANT_REVIEW_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
Capabilities::TENANT_REVIEW_MANAGE,
|
||||||
|
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||||
Capabilities::EVIDENCE_VIEW,
|
Capabilities::EVIDENCE_VIEW,
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
Capabilities::EVIDENCE_MANAGE,
|
||||||
],
|
],
|
||||||
@ -121,6 +123,7 @@ class RoleCapabilityMap
|
|||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
Capabilities::TENANT_REVIEW_VIEW,
|
||||||
|
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||||
Capabilities::EVIDENCE_VIEW,
|
Capabilities::EVIDENCE_VIEW,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
@ -28,6 +29,7 @@ public function __construct(
|
|||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,12 +49,34 @@ public function startCompare(
|
|||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = $assignment->baselineProfile;
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function startCompareForProfile(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
Tenant $tenant,
|
||||||
|
User $initiator,
|
||||||
|
?int $baselineSnapshotId = null,
|
||||||
|
): array {
|
||||||
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
|
}
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$precondition = $this->validatePreconditions($profile);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
@ -124,6 +148,103 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* baselineProfileId: int,
|
||||||
|
* visibleAssignedTenantCount: int,
|
||||||
|
* queuedCount: int,
|
||||||
|
* alreadyQueuedCount: int,
|
||||||
|
* blockedCount: int,
|
||||||
|
* targets: list<array{tenantId: int, runId: ?int, launchState: string, reasonCode: ?string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array
|
||||||
|
{
|
||||||
|
$assignments = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->with('tenant')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$queuedCount = 0;
|
||||||
|
$alreadyQueuedCount = 0;
|
||||||
|
$blockedCount = 0;
|
||||||
|
$targets = [];
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
$tenant = $assignment->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->isMember($initiator, $tenant)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) {
|
||||||
|
$blockedCount++;
|
||||||
|
$targets[] = [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'runId' => null,
|
||||||
|
'launchState' => 'blocked',
|
||||||
|
'reasonCode' => 'tenant_sync_required',
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->startCompareForProfile($profile, $tenant, $initiator);
|
||||||
|
$run = $result['run'] ?? null;
|
||||||
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null;
|
||||||
|
|
||||||
|
if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) {
|
||||||
|
$blockedCount++;
|
||||||
|
$targets[] = [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'runId' => null,
|
||||||
|
'launchState' => 'blocked',
|
||||||
|
'reasonCode' => $reasonCode,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
$queuedCount++;
|
||||||
|
$targets[] = [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'runId' => (int) $run->getKey(),
|
||||||
|
'launchState' => 'queued',
|
||||||
|
'reasonCode' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyQueuedCount++;
|
||||||
|
$targets[] = [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'runId' => (int) $run->getKey(),
|
||||||
|
'launchState' => 'already_queued',
|
||||||
|
'reasonCode' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'baselineProfileId' => (int) $profile->getKey(),
|
||||||
|
'visibleAssignedTenantCount' => count($targets),
|
||||||
|
'queuedCount' => $queuedCount,
|
||||||
|
'alreadyQueuedCount' => $alreadyQueuedCount,
|
||||||
|
'blockedCount' => $blockedCount,
|
||||||
|
'targets' => $targets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -95,6 +95,8 @@ enum AuditActionId: string
|
|||||||
case TenantReviewArchived = 'tenant_review.archived';
|
case TenantReviewArchived = 'tenant_review.archived';
|
||||||
case TenantReviewExported = 'tenant_review.exported';
|
case TenantReviewExported = 'tenant_review.exported';
|
||||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||||
|
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||||
|
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||||
|
|
||||||
// Workspace selection / switch events (Spec 107).
|
// Workspace selection / switch events (Spec 107).
|
||||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||||
@ -228,6 +230,8 @@ private static function labels(): array
|
|||||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||||
self::TenantReviewExported->value => 'Tenant review exported',
|
self::TenantReviewExported->value => 'Tenant review exported',
|
||||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||||
|
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||||
|
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||||
'baseline.capture.started' => 'Baseline capture started',
|
'baseline.capture.started' => 'Baseline capture started',
|
||||||
'baseline.capture.completed' => 'Baseline capture completed',
|
'baseline.capture.completed' => 'Baseline capture completed',
|
||||||
'baseline.capture.failed' => 'Baseline capture failed',
|
'baseline.capture.failed' => 'Baseline capture failed',
|
||||||
|
|||||||
@ -143,6 +143,9 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
|
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
|
||||||
|
|
||||||
|
// Portfolio triage review progress
|
||||||
|
public const TENANT_TRIAGE_REVIEW_MANAGE = 'tenant_triage_review.manage';
|
||||||
|
|
||||||
// Evidence snapshots
|
// Evidence snapshots
|
||||||
public const EVIDENCE_VIEW = 'evidence.view';
|
public const EVIDENCE_VIEW = 'evidence.view';
|
||||||
|
|
||||||
|
|||||||
@ -62,9 +62,13 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||||
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
||||||
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
|
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
|
||||||
|
BadgeDomain::TenantTriageReviewState->value => Domains\TenantTriageReviewStateBadge::class,
|
||||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||||
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
||||||
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
||||||
|
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
|
||||||
|
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
|
||||||
|
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -53,7 +53,11 @@ enum BadgeDomain: string
|
|||||||
case EvidenceCompleteness = 'evidence_completeness';
|
case EvidenceCompleteness = 'evidence_completeness';
|
||||||
case TenantReviewStatus = 'tenant_review_status';
|
case TenantReviewStatus = 'tenant_review_status';
|
||||||
case TenantReviewCompleteness = 'tenant_review_completeness';
|
case TenantReviewCompleteness = 'tenant_review_completeness';
|
||||||
|
case TenantTriageReviewState = 'tenant_triage_review_state';
|
||||||
case SystemHealth = 'system_health';
|
case SystemHealth = 'system_health';
|
||||||
case ReferenceResolutionState = 'reference_resolution_state';
|
case ReferenceResolutionState = 'reference_resolution_state';
|
||||||
case DiffRowStatus = 'diff_row_status';
|
case DiffRowStatus = 'diff_row_status';
|
||||||
|
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
|
||||||
|
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
|
||||||
|
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
* @param list<array<string, mixed>> $buckets
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
@ -18,6 +19,36 @@ public function __construct(
|
|||||||
private readonly ReasonPresenter $reasonPresenter,
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function trustLevelForRun(?OperationRun $run): string
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return TrustworthinessLevel::Unusable->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [];
|
||||||
|
$coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : [];
|
||||||
|
$evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : [];
|
||||||
|
$reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null;
|
||||||
|
$proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null;
|
||||||
|
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : [];
|
||||||
|
$evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0;
|
||||||
|
|
||||||
|
if ($run->status !== 'completed' || $run->outcome === 'failed') {
|
||||||
|
return TrustworthinessLevel::Unusable->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($proof === false || $reasonCode !== null) {
|
||||||
|
return TrustworthinessLevel::DiagnosticOnly->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uncoveredTypes !== [] || $evidenceGapCount > 0) {
|
||||||
|
return TrustworthinessLevel::LimitedConfidence->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrustworthinessLevel::Trustworthy->value;
|
||||||
|
}
|
||||||
|
|
||||||
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
$reason = $stats->reasonCode !== null
|
$reason = $stats->reasonCode !== null
|
||||||
|
|||||||
1005
apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php
Normal file
1005
apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor
|
|||||||
{
|
{
|
||||||
private const int STALE_AFTER_DAYS = 7;
|
private const int STALE_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
public static function staleAfterDays(): int
|
||||||
|
{
|
||||||
|
return self::STALE_AFTER_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$comparedAt = $value instanceof \DateTimeInterface
|
||||||
|
? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value))
|
||||||
|
: CarbonImmutable::parse($value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||||
|
}
|
||||||
|
|
||||||
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
||||||
{
|
{
|
||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return self::isStaleComparedAt($stats->lastComparedIso);
|
||||||
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Navigation;
|
namespace App\Support\Navigation;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final readonly class CanonicalNavigationContext
|
final readonly class CanonicalNavigationContext
|
||||||
@ -63,4 +66,31 @@ public function toQuery(): array
|
|||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public static function forBaselineCompareMatrix(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
array $filters = [],
|
||||||
|
?Tenant $tenant = null,
|
||||||
|
?string $subjectKey = null,
|
||||||
|
): self {
|
||||||
|
$parameters = array_filter([
|
||||||
|
'record' => $profile,
|
||||||
|
...$filters,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
sourceSurface: 'baseline_compare_matrix',
|
||||||
|
canonicalRouteName: BaselineCompareMatrix::getRouteName(),
|
||||||
|
tenantId: $tenant?->getKey(),
|
||||||
|
backLinkLabel: 'Back to compare matrix',
|
||||||
|
backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'),
|
||||||
|
filterPayload: array_filter([
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
|||||||
$request->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
|
$request->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) {
|
if ($state === null || ! $this->matchesRequestScope($tenant, $request, $state)) {
|
||||||
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
|
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -61,6 +61,26 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* sourceSurface: string,
|
||||||
|
* tenantRouteKey: string|null,
|
||||||
|
* workspaceId: int|null,
|
||||||
|
* concernFamily: string,
|
||||||
|
* concernState: string,
|
||||||
|
* concernReason: string|null,
|
||||||
|
* returnFilters: array<string, mixed>|null
|
||||||
|
* }|null $state
|
||||||
|
*/
|
||||||
|
public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext
|
||||||
|
{
|
||||||
|
if ($state === null || ! $this->matchesTenantScope($tenant, $state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildContext($tenant, $state);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* sourceSurface: string,
|
* sourceSurface: string,
|
||||||
@ -72,7 +92,30 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
|||||||
* returnFilters: array<string, mixed>|null
|
* returnFilters: array<string, mixed>|null
|
||||||
* } $state
|
* } $state
|
||||||
*/
|
*/
|
||||||
private function matchesScope(Tenant $tenant, Request $request, array $state): bool
|
private function matchesRequestScope(Tenant $tenant, Request $request, array $state): bool
|
||||||
|
{
|
||||||
|
if (! $this->matchesTenantScope($tenant, $state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $state['workspaceId'];
|
||||||
|
|
||||||
|
return $workspaceId === null
|
||||||
|
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* sourceSurface: string,
|
||||||
|
* tenantRouteKey: string|null,
|
||||||
|
* workspaceId: int|null,
|
||||||
|
* concernFamily: string,
|
||||||
|
* concernState: string,
|
||||||
|
* concernReason: string|null,
|
||||||
|
* returnFilters: array<string, mixed>|null
|
||||||
|
* } $state
|
||||||
|
*/
|
||||||
|
private function matchesTenantScope(Tenant $tenant, array $state): bool
|
||||||
{
|
{
|
||||||
$tenantRouteKey = $state['tenantRouteKey'];
|
$tenantRouteKey = $state['tenantRouteKey'];
|
||||||
|
|
||||||
@ -92,7 +135,7 @@ private function matchesScope(Tenant $tenant, Request $request, array $state): b
|
|||||||
$workspaceId = $state['workspaceId'];
|
$workspaceId = $state['workspaceId'];
|
||||||
|
|
||||||
return $workspaceId === null
|
return $workspaceId === null
|
||||||
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|
|| (int) $tenant->workspace_id === $workspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -55,6 +55,7 @@ final class PortfolioArrivalContextToken
|
|||||||
private const array RETURN_FILTER_ALLOWLIST = [
|
private const array RETURN_FILTER_ALLOWLIST = [
|
||||||
'backup_posture' => true,
|
'backup_posture' => true,
|
||||||
'recovery_evidence' => true,
|
'recovery_evidence' => true,
|
||||||
|
'review_state' => true,
|
||||||
'triage_sort' => true,
|
'triage_sort' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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 a record is set from forTableAction, try to resolve it
|
||||||
if ($this->record !== null) {
|
if ($this->record !== null) {
|
||||||
$resolved = $this->record instanceof Closure
|
$resolved = $this->record instanceof Closure
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use ReflectionObject;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement
|
|||||||
|
|
||||||
private Model|Closure|null $record = null;
|
private Model|Closure|null $record = null;
|
||||||
|
|
||||||
|
private bool $preserveExistingVisibility = false;
|
||||||
|
|
||||||
|
private bool $preserveExistingDisabled = false;
|
||||||
|
|
||||||
private function __construct(Action $action)
|
private function __construct(Action $action)
|
||||||
{
|
{
|
||||||
$this->action = $action;
|
$this->action = $action;
|
||||||
@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se
|
|||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forAction(Action $action, Model|Closure|null $record = null): self
|
||||||
|
{
|
||||||
|
$instance = new self($action);
|
||||||
|
$instance->record = $record;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
public function requireMembership(bool $require = true): self
|
public function requireMembership(bool $require = true): self
|
||||||
{
|
{
|
||||||
$this->requireMembership = $require;
|
$this->requireMembership = $require;
|
||||||
@ -95,6 +108,20 @@ public function tooltip(string $message): self
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function preserveVisibility(): self
|
||||||
|
{
|
||||||
|
$this->preserveExistingVisibility = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preserveDisabled(): self
|
||||||
|
{
|
||||||
|
$this->preserveExistingDisabled = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function apply(): Action
|
public function apply(): Action
|
||||||
{
|
{
|
||||||
$this->applyVisibility();
|
$this->applyVisibility();
|
||||||
@ -111,10 +138,22 @@ private function applyVisibility(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->action->visible(function (?Model $record = null): bool {
|
$existingVisibility = $this->preserveExistingVisibility
|
||||||
|
? $this->getExistingVisibilityCondition()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
return $context->isMember;
|
if (! $context->isMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingVisibility === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +165,15 @@ private function applyDisabledState(): void
|
|||||||
|
|
||||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$this->action->disabled(function (?Model $record = null): bool {
|
$existingDisabled = $this->preserveExistingDisabled
|
||||||
|
? $this->getExistingDisabledCondition()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
|
||||||
|
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
if (! $context->isMember) {
|
if (! $context->isMember) {
|
||||||
@ -173,6 +220,96 @@ private function applyServerSideGuard(): void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getExistingVisibilityCondition(): bool|Closure|null
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionObject($this->action);
|
||||||
|
|
||||||
|
if (! $ref->hasProperty('isVisible')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$property = $ref->getProperty('isVisible');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var bool|Closure $value */
|
||||||
|
$value = $property->getValue($this->action);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
||||||
|
{
|
||||||
|
if (is_bool($condition)) {
|
||||||
|
return $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionFunction($condition);
|
||||||
|
$parameters = $reflection->getParameters();
|
||||||
|
|
||||||
|
if ($parameters === []) {
|
||||||
|
return (bool) $condition();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $condition($record);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getExistingDisabledCondition(): bool|Closure|null
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionObject($this->action);
|
||||||
|
|
||||||
|
if (! $ref->hasProperty('isDisabled')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$property = $ref->getProperty('isDisabled');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var bool|Closure $value */
|
||||||
|
$value = $property->getValue($this->action);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
|
||||||
|
{
|
||||||
|
if (is_bool($condition)) {
|
||||||
|
return $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionFunction($condition);
|
||||||
|
$parameters = $reflection->getParameters();
|
||||||
|
|
||||||
|
if ($parameters === []) {
|
||||||
|
return (bool) $condition();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $condition($record);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
use ReflectionClass;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
final class ActionSurfaceDiscovery
|
final class ActionSurfaceDiscovery
|
||||||
{
|
{
|
||||||
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
|
|||||||
{
|
{
|
||||||
$scopes = [ActionSurfacePanelScope::Tenant];
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||||
|
|
||||||
if (in_array($className, $adminScopedClasses, true)) {
|
if (
|
||||||
|
in_array($className, $adminScopedClasses, true)
|
||||||
|
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
||||||
|
) {
|
||||||
$scopes[] = ActionSurfacePanelScope::Admin;
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool
|
|||||||
&& method_exists($className, 'actionSurfaceDeclaration');
|
&& method_exists($className, 'actionSurfaceDeclaration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
|
||||||
|
* from the resource instead of being panel-registered directly. When that happens,
|
||||||
|
* inherit admin scope from the owning resource so discovery stays truthful.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $adminScopedClasses
|
||||||
|
*/
|
||||||
|
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
|
||||||
|
{
|
||||||
|
if (! class_exists($className)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new ReflectionClass($className);
|
||||||
|
|
||||||
|
if (! $reflection->hasProperty('resource')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = $reflection->getDefaultProperties();
|
||||||
|
$resourceClass = $defaults['resource'] ?? null;
|
||||||
|
|
||||||
|
return is_string($resourceClass)
|
||||||
|
&& $resourceClass !== ''
|
||||||
|
&& in_array($resourceClass, $adminScopedClasses, true);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,31 @@
|
|||||||
|
|
||||||
namespace App\Support\Ui\ActionSurface;
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
|
use App\Filament\Pages\Monitoring\Alerts;
|
||||||
|
use App\Filament\Pages\Monitoring\AuditLog;
|
||||||
|
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
|
use App\Filament\Pages\Monitoring\Operations;
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
|
use App\Filament\Pages\TenantDiagnostics;
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
|
||||||
|
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||||
|
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||||
|
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||||
|
|
||||||
final class ActionSurfaceExemptions
|
final class ActionSurfaceExemptions
|
||||||
@ -24,7 +49,6 @@ public static function baseline(): self
|
|||||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
||||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
||||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
||||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts remains exempt because the active admin alerts surface resolves through the cluster entry at /admin/alerts, not this page-class route.',
|
|
||||||
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
|
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
|
||||||
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
||||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests in spec 172 (OnboardingVerificationTest, OnboardingVerificationClustersTest, OnboardingVerificationV1_5UxTest) and remains exempt from blanket discovery.',
|
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests in spec 172 (OnboardingVerificationTest, OnboardingVerificationClustersTest, OnboardingVerificationV1_5UxTest) and remains exempt from blanket discovery.',
|
||||||
@ -49,4 +73,472 @@ public function hasClass(string $className): bool
|
|||||||
{
|
{
|
||||||
return array_key_exists($className, $this->componentReasons);
|
return array_key_exists($className, $this->componentReasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* routeKind: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* maxVisiblePrimaryActions: int,
|
||||||
|
* allowsNoPrimaryAction: bool,
|
||||||
|
* requiresGroupedSecondaryActions: bool,
|
||||||
|
* requiresDangerSeparation: bool,
|
||||||
|
* allowsPrimaryNavigation: bool,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function spec192RecordPageInventory(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ViewBaselineProfile::class => [
|
||||||
|
'surfaceKey' => 'baseline_profile_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Baseline profile',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewEvidenceSnapshot::class => [
|
||||||
|
'surfaceKey' => 'evidence_snapshot_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Evidence snapshot',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewFindingException::class => [
|
||||||
|
'surfaceKey' => 'finding_exception_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Finding exception',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewTenantReview::class => [
|
||||||
|
'surfaceKey' => 'tenant_review_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Tenant review',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
EditTenant::class => [
|
||||||
|
'surfaceKey' => 'tenant_edit',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Tenant',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'edit',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewTenant::class => [
|
||||||
|
'surfaceKey' => 'tenant_view',
|
||||||
|
'classification' => 'workflow_heavy_special_type',
|
||||||
|
'canonicalNoun' => 'Tenant',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => 'Tenant detail remains a workflow-heavy hub for external links, verification/setup, and lifecycle operations. It may show one dominant next step, but it must never silently fall back to a flat multi-button strip.',
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewProviderConnection::class => [
|
||||||
|
'surfaceKey' => 'provider_connection_view',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Provider connection',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
ViewFinding::class => [
|
||||||
|
'surfaceKey' => 'finding_view',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Finding',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
ViewReviewPack::class => [
|
||||||
|
'surfaceKey' => 'review_pack_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Review pack',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewAlertDestination::class => [
|
||||||
|
'surfaceKey' => 'alert_destination_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Alert destination',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewPolicyVersion::class => [
|
||||||
|
'surfaceKey' => 'policy_version_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Policy version',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewWorkspace::class => [
|
||||||
|
'surfaceKey' => 'workspace_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Workspace',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewBaselineSnapshot::class => [
|
||||||
|
'surfaceKey' => 'baseline_snapshot_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Baseline snapshot',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewBackupSet::class => [
|
||||||
|
'surfaceKey' => 'backup_set_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Backup set',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* surfaceKind: string,
|
||||||
|
* primaryInspectModel: string,
|
||||||
|
* sharedPattern: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* requiresExplicitDeclaration: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function spec193MonitoringSurfaceInventory(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
FindingExceptionsQueue::class => [
|
||||||
|
'surfaceKey' => 'finding_exceptions_queue',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Finding exceptions',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||||
|
'surfaceKind' => 'queue_workbench',
|
||||||
|
'primaryInspectModel' => 'explicit_inspect_action',
|
||||||
|
'sharedPattern' => 'operate_hub_shell',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
TenantlessOperationRunViewer::class => [
|
||||||
|
'surfaceKey' => 'tenantless_operation_run_viewer',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Operation run',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'surfaceKind' => 'monitoring_detail',
|
||||||
|
'primaryInspectModel' => 'singleton_detail_surface',
|
||||||
|
'sharedPattern' => 'operate_hub_shell',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
Operations::class => [
|
||||||
|
'surfaceKey' => 'operations',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Operations',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'surfaceKind' => 'monitoring_landing',
|
||||||
|
'primaryInspectModel' => 'clickable_row',
|
||||||
|
'sharedPattern' => 'operate_hub_shell',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
Alerts::class => [
|
||||||
|
'surfaceKey' => 'alerts',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Alerts',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'surfaceKind' => 'monitoring_landing',
|
||||||
|
'primaryInspectModel' => 'page_level_overview',
|
||||||
|
'sharedPattern' => 'cluster_entry',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
AuditLog::class => [
|
||||||
|
'surfaceKey' => 'audit_log',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Audit log',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||||
|
'surfaceKind' => 'read_only_report',
|
||||||
|
'primaryInspectModel' => 'explicit_inspect_action',
|
||||||
|
'sharedPattern' => 'operate_hub_shell',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
ListAlertDeliveries::class => [
|
||||||
|
'surfaceKey' => 'alert_deliveries',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Alert deliveries',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'surfaceKind' => 'read_only_report',
|
||||||
|
'primaryInspectModel' => 'clickable_row',
|
||||||
|
'sharedPattern' => 'operate_hub_shell',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
EvidenceOverview::class => [
|
||||||
|
'surfaceKey' => 'evidence_overview',
|
||||||
|
'classification' => 'compliant_no_op',
|
||||||
|
'canonicalNoun' => 'Evidence overview',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||||
|
'surfaceKind' => 'read_only_report',
|
||||||
|
'primaryInspectModel' => 'clickable_row',
|
||||||
|
'sharedPattern' => 'none',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
BaselineCompareLanding::class => [
|
||||||
|
'surfaceKey' => 'baseline_compare_landing',
|
||||||
|
'classification' => 'compliant_no_op',
|
||||||
|
'canonicalNoun' => 'Baseline compare',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'surfaceKind' => 'monitoring_landing',
|
||||||
|
'primaryInspectModel' => 'page_level_overview',
|
||||||
|
'sharedPattern' => 'none',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
BaselineCompareMatrix::class => [
|
||||||
|
'surfaceKey' => 'baseline_compare_matrix',
|
||||||
|
'classification' => 'compliant_no_op',
|
||||||
|
'canonicalNoun' => 'Baseline compare matrix',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'surfaceKind' => 'read_only_report',
|
||||||
|
'primaryInspectModel' => 'matrix_itself',
|
||||||
|
'sharedPattern' => 'none',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ReviewRegister::class => [
|
||||||
|
'surfaceKey' => 'review_register',
|
||||||
|
'classification' => 'compliant_no_op',
|
||||||
|
'canonicalNoun' => 'Review register',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||||
|
'surfaceKind' => 'read_only_report',
|
||||||
|
'primaryInspectModel' => 'clickable_row',
|
||||||
|
'sharedPattern' => 'none',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
TenantDiagnostics::class => [
|
||||||
|
'surfaceKey' => 'tenant_diagnostics',
|
||||||
|
'classification' => 'special_type_acceptable',
|
||||||
|
'canonicalNoun' => 'Tenant diagnostics',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'surfaceKind' => 'diagnostic_exception',
|
||||||
|
'primaryInspectModel' => 'singleton_detail_surface',
|
||||||
|
'sharedPattern' => 'none',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'requiresExplicitDeclaration' => true,
|
||||||
|
'exceptionReason' => 'Tenant diagnostics is already the focused diagnostic surface for the active tenant and may expose repair actions only when a real defect exists.',
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* routeKind: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* maxVisiblePrimaryActions: int,
|
||||||
|
* allowsNoPrimaryAction: bool,
|
||||||
|
* requiresGroupedSecondaryActions: bool,
|
||||||
|
* requiresDangerSeparation: bool,
|
||||||
|
* allowsPrimaryNavigation: bool,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public static function spec192RecordPageSurface(string $className): ?array
|
||||||
|
{
|
||||||
|
return self::spec192RecordPageInventory()[$className] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* surfaceKind: string,
|
||||||
|
* primaryInspectModel: string,
|
||||||
|
* sharedPattern: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* requiresExplicitDeclaration: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public static function spec193MonitoringSurface(string $className): ?array
|
||||||
|
{
|
||||||
|
return self::spec193MonitoringSurfaceInventory()[$className] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,9 @@ public function validateComponents(array $components): ActionSurfaceValidationRe
|
|||||||
{
|
{
|
||||||
$issues = [];
|
$issues = [];
|
||||||
|
|
||||||
|
$this->validateSpec193MonitoringSurfaceInventory($issues);
|
||||||
|
$this->validateSpec192RecordPageInventory($issues);
|
||||||
|
|
||||||
foreach ($components as $component) {
|
foreach ($components as $component) {
|
||||||
if (! class_exists($component->className)) {
|
if (! class_exists($component->className)) {
|
||||||
$issues[] = new ActionSurfaceValidationIssue(
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
@ -106,6 +109,268 @@ className: $component->className,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateSpec193MonitoringSurfaceInventory(array &$issues): void
|
||||||
|
{
|
||||||
|
$allowedClassifications = [
|
||||||
|
'remediation_required',
|
||||||
|
'minor_alignment_only',
|
||||||
|
'compliant_no_op',
|
||||||
|
'special_type_acceptable',
|
||||||
|
];
|
||||||
|
$allowedPanelScopes = ['admin', 'tenant'];
|
||||||
|
$allowedOwnerScopes = ['workspace-owned', 'workspace-visible-tenant-owned', 'tenant-owned'];
|
||||||
|
$allowedSurfaceKinds = ['queue_workbench', 'monitoring_detail', 'monitoring_landing', 'read_only_report', 'diagnostic_exception'];
|
||||||
|
$allowedPrimaryInspectModels = ['explicit_inspect_action', 'clickable_row', 'page_level_overview', 'matrix_itself', 'singleton_detail_surface'];
|
||||||
|
$allowedSharedPatterns = ['operate_hub_shell', 'cluster_entry', 'none'];
|
||||||
|
$surfaceKeys = [];
|
||||||
|
|
||||||
|
foreach (ActionSurfaceExemptions::spec193MonitoringSurfaceInventory() as $className => $surface) {
|
||||||
|
if (! class_exists($className)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 inventory references a surface class that does not exist.',
|
||||||
|
hint: 'Keep ActionSurfaceExemptions::spec193MonitoringSurfaceInventory() aligned with the in-scope monitoring surface classes.',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||||
|
|
||||||
|
if ($surfaceKey === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 inventory entry is missing a non-empty surface key.',
|
||||||
|
hint: 'Provide the stable spec surface key for this monitoring surface.',
|
||||||
|
);
|
||||||
|
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: sprintf('Spec 193 surface key "%s" is declared more than once.', $surfaceKey),
|
||||||
|
hint: 'Each in-scope monitoring surface must have a unique surface key.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$surfaceKeys[$surfaceKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 classification is invalid or missing.',
|
||||||
|
hint: 'Use remediation_required, minor_alignment_only, compliant_no_op, or special_type_acceptable.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 panel scope is invalid or missing.',
|
||||||
|
hint: 'Use admin or tenant for each monitoring surface inventory entry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 owner scope is invalid or missing.',
|
||||||
|
hint: 'Use workspace-owned, workspace-visible-tenant-owned, or tenant-owned.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['surfaceKind'] ?? null, $allowedSurfaceKinds, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 surface kind is invalid or missing.',
|
||||||
|
hint: 'Use queue_workbench, monitoring_detail, monitoring_landing, read_only_report, or diagnostic_exception.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['primaryInspectModel'] ?? null, $allowedPrimaryInspectModels, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 primary inspect model is invalid or missing.',
|
||||||
|
hint: 'Use an allowed inspect model such as explicit_inspect_action, clickable_row, or page_level_overview.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['sharedPattern'] ?? null, $allowedSharedPatterns, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 shared pattern is invalid or missing.',
|
||||||
|
hint: 'Use operate_hub_shell, cluster_entry, or none.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 canonical noun must be non-empty.',
|
||||||
|
hint: 'Use the stable operator-facing noun for the monitoring surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$classification = (string) ($surface['classification'] ?? '');
|
||||||
|
$exceptionReason = $surface['exceptionReason'] ?? null;
|
||||||
|
|
||||||
|
if ($classification === 'special_type_acceptable') {
|
||||||
|
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Special-type acceptable Spec 193 surfaces require an explicit exception reason.',
|
||||||
|
hint: 'Document why this surface intentionally differs from the standard monitoring hierarchy.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Only special-type acceptable Spec 193 surfaces may carry an exception reason.',
|
||||||
|
hint: 'Clear the exception reason for remediation, minor-alignment, and compliant surfaces.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($classification === 'remediation_required' && ($surface['requiresHeaderRemediation'] ?? false) !== true) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Remediation-required Spec 193 surfaces must mark header remediation as required.',
|
||||||
|
hint: 'Set requiresHeaderRemediation to true for remediation_required surfaces.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($surface['requiresExplicitDeclaration'] ?? false) === true && ! method_exists($className, 'actionSurfaceDeclaration')) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 193 surface requires an explicit action-surface declaration, but the class does not define one.',
|
||||||
|
hint: 'Add actionSurfaceDeclaration() to the page class or clear requiresExplicitDeclaration if the surface is intentionally declaration-free.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateSpec192RecordPageInventory(array &$issues): void
|
||||||
|
{
|
||||||
|
$allowedClassifications = [
|
||||||
|
'remediation_required',
|
||||||
|
'minor_alignment_only',
|
||||||
|
'compliant_reference',
|
||||||
|
'workflow_heavy_special_type',
|
||||||
|
];
|
||||||
|
$allowedPanelScopes = ['admin', 'tenant'];
|
||||||
|
$allowedOwnerScopes = ['workspace-owned', 'tenant-owned'];
|
||||||
|
$allowedRouteKinds = ['view', 'edit'];
|
||||||
|
$surfaceKeys = [];
|
||||||
|
|
||||||
|
foreach (ActionSurfaceExemptions::spec192RecordPageInventory() as $className => $surface) {
|
||||||
|
if (! class_exists($className)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 inventory references a page class that does not exist.',
|
||||||
|
hint: 'Keep ActionSurfaceExemptions::spec192RecordPageInventory() aligned with the in-scope page classes.',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||||
|
|
||||||
|
if ($surfaceKey === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 inventory entry is missing a non-empty surface key.',
|
||||||
|
hint: 'Provide the stable spec surface key for this page.',
|
||||||
|
);
|
||||||
|
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: sprintf('Spec 192 surface key "%s" is declared more than once.', $surfaceKey),
|
||||||
|
hint: 'Each in-scope page must have a unique surface key.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$surfaceKeys[$surfaceKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 classification is invalid or missing.',
|
||||||
|
hint: 'Use remediation_required, minor_alignment_only, compliant_reference, or workflow_heavy_special_type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 panel scope is invalid or missing.',
|
||||||
|
hint: 'Use the concrete panel scope for the record page inventory entry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 owner scope is invalid or missing.',
|
||||||
|
hint: 'Use workspace-owned or tenant-owned.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['routeKind'] ?? null, $allowedRouteKinds, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 route kind is invalid or missing.',
|
||||||
|
hint: 'Use view or edit.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 canonical noun must be non-empty.',
|
||||||
|
hint: 'Use the stable operator-facing noun for the surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$classification = (string) ($surface['classification'] ?? '');
|
||||||
|
$exceptionReason = $surface['exceptionReason'] ?? null;
|
||||||
|
|
||||||
|
if ($classification === 'workflow_heavy_special_type') {
|
||||||
|
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Workflow-heavy Spec 192 pages require an explicit exception reason.',
|
||||||
|
hint: 'Document why this surface is intentionally exempt from the standard record-page rule.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Only workflow-heavy Spec 192 pages may carry an exception reason.',
|
||||||
|
hint: 'Clear the exception reason for standard, minor-alignment, and compliant-reference surfaces.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($surface['maxVisiblePrimaryActions'] ?? null) !== 1) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 maxVisiblePrimaryActions must stay pinned to 1.',
|
||||||
|
hint: 'The bounded header contract allows at most one visible primary header action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($classification === 'remediation_required' && ($surface['allowsPrimaryNavigation'] ?? false) === true) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Remediation-required Spec 192 surfaces must not allow primary navigation.',
|
||||||
|
hint: 'Move pure navigation into contextual placement outside the header.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantTriageReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
@ -31,6 +32,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||||
@ -47,6 +49,7 @@ public function __construct(
|
|||||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||||
|
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,6 +69,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||||
$navigationContext = $this->workspaceOverviewNavigationContext();
|
$navigationContext = $this->workspaceOverviewNavigationContext();
|
||||||
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
|
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
|
||||||
|
$triageReviewProgress = $this->triageReviewProgress($workspaceId, $tenantContexts);
|
||||||
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
|
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
|
||||||
|
|
||||||
$governanceAttentionTenantCount = count(array_filter(
|
$governanceAttentionTenantCount = count(array_filter(
|
||||||
@ -141,6 +145,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
totalAlertFailuresCount: $totalAlertFailuresCount,
|
totalAlertFailuresCount: $totalAlertFailuresCount,
|
||||||
canViewAlerts: $canViewAlerts,
|
canViewAlerts: $canViewAlerts,
|
||||||
tenantContexts: $tenantContexts,
|
tenantContexts: $tenantContexts,
|
||||||
|
triageReviewSummaries: $triageReviewProgress['summaries'],
|
||||||
user: $user,
|
user: $user,
|
||||||
navigationContext: $navigationContext,
|
navigationContext: $navigationContext,
|
||||||
);
|
);
|
||||||
@ -164,6 +169,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
'workspace_name' => (string) $workspace->name,
|
'workspace_name' => (string) $workspace->name,
|
||||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
'accessible_tenant_count' => $accessibleTenants->count(),
|
||||||
'summary_metrics' => $summaryMetrics,
|
'summary_metrics' => $summaryMetrics,
|
||||||
|
'triage_review_progress' => $triageReviewProgress['families'],
|
||||||
'attention_items' => $attentionItems,
|
'attention_items' => $attentionItems,
|
||||||
'attention_empty_state' => $attentionEmptyState,
|
'attention_empty_state' => $attentionEmptyState,
|
||||||
'recent_operations' => $recentOperations,
|
'recent_operations' => $recentOperations,
|
||||||
@ -828,6 +834,7 @@ private function summaryMetrics(
|
|||||||
int $totalAlertFailuresCount,
|
int $totalAlertFailuresCount,
|
||||||
bool $canViewAlerts,
|
bool $canViewAlerts,
|
||||||
array $tenantContexts,
|
array $tenantContexts,
|
||||||
|
array $triageReviewSummaries,
|
||||||
User $user,
|
User $user,
|
||||||
CanonicalNavigationContext $navigationContext,
|
CanonicalNavigationContext $navigationContext,
|
||||||
): array {
|
): array {
|
||||||
@ -861,7 +868,11 @@ private function summaryMetrics(
|
|||||||
label: 'Backup attention',
|
label: 'Backup attention',
|
||||||
value: $backupAttentionTenantCount,
|
value: $backupAttentionTenantCount,
|
||||||
category: 'backup_health',
|
category: 'backup_health',
|
||||||
description: 'Visible tenants with non-healthy backup posture.',
|
description: $this->reviewSummaryMetricDescription(
|
||||||
|
family: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||||
|
baseDescription: 'Visible tenants with non-healthy backup posture.',
|
||||||
|
triageReviewSummaries: $triageReviewSummaries,
|
||||||
|
),
|
||||||
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
|
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
|
||||||
destination: $this->attentionMetricDestination(
|
destination: $this->attentionMetricDestination(
|
||||||
tenantContexts: $tenantContexts,
|
tenantContexts: $tenantContexts,
|
||||||
@ -874,7 +885,11 @@ private function summaryMetrics(
|
|||||||
label: 'Recovery attention',
|
label: 'Recovery attention',
|
||||||
value: $recoveryAttentionTenantCount,
|
value: $recoveryAttentionTenantCount,
|
||||||
category: 'recovery_evidence',
|
category: 'recovery_evidence',
|
||||||
description: 'Visible tenants with weakened or unvalidated recovery evidence.',
|
description: $this->reviewSummaryMetricDescription(
|
||||||
|
family: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||||
|
baseDescription: 'Visible tenants with weakened or unvalidated recovery evidence.',
|
||||||
|
triageReviewSummaries: $triageReviewSummaries,
|
||||||
|
),
|
||||||
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
|
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
|
||||||
destination: $this->attentionMetricDestination(
|
destination: $this->attentionMetricDestination(
|
||||||
tenantContexts: $tenantContexts,
|
tenantContexts: $tenantContexts,
|
||||||
@ -912,6 +927,83 @@ private function summaryMetrics(
|
|||||||
return $metrics;
|
return $metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $tenantContexts
|
||||||
|
* @return array{
|
||||||
|
* summaries: array<string, array{
|
||||||
|
* concern_family: string,
|
||||||
|
* affected_total: int,
|
||||||
|
* reviewed_count: int,
|
||||||
|
* follow_up_needed_count: int,
|
||||||
|
* changed_since_review_count: int,
|
||||||
|
* not_reviewed_count: int
|
||||||
|
* }>,
|
||||||
|
* families: list<array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function triageReviewProgress(int $workspaceId, array $tenantContexts): array
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
$backupHealthByTenant = [];
|
||||||
|
$recoveryEvidenceByTenant = [];
|
||||||
|
|
||||||
|
foreach ($tenantContexts as $context) {
|
||||||
|
$tenant = $context['tenant'] ?? null;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$tenantIds[] = $tenantId;
|
||||||
|
$backupHealthByTenant[$tenantId] = $context['backup_health_assessment'] ?? null;
|
||||||
|
$recoveryEvidenceByTenant[$tenantId] = $context['recovery_evidence'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
tenantIds: $tenantIds,
|
||||||
|
backupHealthByTenant: $backupHealthByTenant,
|
||||||
|
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summaries' => $resolved['summaries'],
|
||||||
|
'families' => $this->triageReviewProgressFamilies($resolved['summaries']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{
|
||||||
|
* concern_family: string,
|
||||||
|
* affected_total: int,
|
||||||
|
* reviewed_count: int,
|
||||||
|
* follow_up_needed_count: int,
|
||||||
|
* changed_since_review_count: int,
|
||||||
|
* not_reviewed_count: int
|
||||||
|
* }> $triageReviewSummaries
|
||||||
|
*/
|
||||||
|
private function reviewSummaryMetricDescription(
|
||||||
|
string $family,
|
||||||
|
string $baseDescription,
|
||||||
|
array $triageReviewSummaries,
|
||||||
|
): string {
|
||||||
|
$summary = $triageReviewSummaries[$family] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
|
||||||
|
return $baseDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s Reviewed %d/%d. Follow-up needed %d. Changed since review %d.',
|
||||||
|
$baseDescription,
|
||||||
|
(int) ($summary['reviewed_count'] ?? 0),
|
||||||
|
(int) ($summary['affected_total'] ?? 0),
|
||||||
|
(int) ($summary['follow_up_needed_count'] ?? 0),
|
||||||
|
(int) ($summary['changed_since_review_count'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -938,6 +1030,77 @@ private function makeSummaryMetric(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{
|
||||||
|
* concern_family: string,
|
||||||
|
* affected_total: int,
|
||||||
|
* reviewed_count: int,
|
||||||
|
* follow_up_needed_count: int,
|
||||||
|
* changed_since_review_count: int,
|
||||||
|
* not_reviewed_count: int
|
||||||
|
* }> $triageReviewSummaries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function triageReviewProgressFamilies(array $triageReviewSummaries): array
|
||||||
|
{
|
||||||
|
$families = [];
|
||||||
|
|
||||||
|
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
|
||||||
|
$summary = $triageReviewSummaries[$family] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$families[] = [
|
||||||
|
'concern_family' => $family,
|
||||||
|
'label' => $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH ? 'Backup health' : 'Recovery evidence',
|
||||||
|
'affected_total' => (int) ($summary['affected_total'] ?? 0),
|
||||||
|
'reviewed_count' => (int) ($summary['reviewed_count'] ?? 0),
|
||||||
|
'follow_up_needed_count' => (int) ($summary['follow_up_needed_count'] ?? 0),
|
||||||
|
'changed_since_review_count' => (int) ($summary['changed_since_review_count'] ?? 0),
|
||||||
|
'not_reviewed_count' => (int) ($summary['not_reviewed_count'] ?? 0),
|
||||||
|
'reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_REVIEWED, 'Reviewed'),
|
||||||
|
'follow_up_needed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, 'Follow-up needed'),
|
||||||
|
'changed_since_review_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, 'Changed since review'),
|
||||||
|
'not_reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED, 'Not reviewed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $families;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function triageReviewBucketDestination(string $family, string $reviewState, string $label): array
|
||||||
|
{
|
||||||
|
$filters = match ($family) {
|
||||||
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => [
|
||||||
|
'backup_posture' => [
|
||||||
|
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||||
|
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||||
|
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||||
|
],
|
||||||
|
'review_state' => [$reviewState],
|
||||||
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
|
],
|
||||||
|
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => [
|
||||||
|
'recovery_evidence' => [
|
||||||
|
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||||
|
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||||
|
],
|
||||||
|
'review_state' => [$reviewState],
|
||||||
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'review_state' => [$reviewState],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->filteredTenantRegistryTarget($filters, $label);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $tenantContexts
|
* @param list<array<string, mixed>> $tenantContexts
|
||||||
*/
|
*/
|
||||||
|
|||||||
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,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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -10,6 +10,7 @@
|
|||||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
||||||
|
|
||||||
const recentKeys = new Map();
|
const recentKeys = new Map();
|
||||||
|
const recentTransportFailures = [];
|
||||||
|
|
||||||
const cleanupRecentKeys = (nowMs) => {
|
const cleanupRecentKeys = (nowMs) => {
|
||||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
for (const [key, timestampMs] of recentKeys.entries()) {
|
||||||
@ -19,6 +20,258 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cleanupRecentTransportFailures = (nowMs) => {
|
||||||
|
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
|
||||||
|
recentTransportFailures.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUrl = (value) => {
|
||||||
|
if (typeof value !== 'string' || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.href).href;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBodySnippet = (body) => {
|
||||||
|
if (typeof body !== 'string' || body === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.slice(0, 1_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
cleanupRecentTransportFailures(nowMs);
|
||||||
|
|
||||||
|
recentTransportFailures.push({
|
||||||
|
requestUrl: normalizeUrl(requestUrl),
|
||||||
|
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
|
||||||
|
status: Number.isFinite(status) ? status : null,
|
||||||
|
bodySnippet: toBodySnippet(body),
|
||||||
|
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
|
||||||
|
timestampMs: nowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentTransportFailures.length > 30) {
|
||||||
|
recentTransportFailures.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTransportMetadata = (reason) => {
|
||||||
|
if (reason === null || typeof reason !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directRequestUrl = typeof reason.requestUrl === 'string'
|
||||||
|
? normalizeUrl(reason.requestUrl)
|
||||||
|
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
|
||||||
|
|
||||||
|
if (directRequestUrl) {
|
||||||
|
return {
|
||||||
|
requestUrl: directRequestUrl,
|
||||||
|
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
|
||||||
|
transportType: 'reason',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransportEnvelope(reason)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const reasonBodySnippet = toBodySnippet(reason.body);
|
||||||
|
|
||||||
|
cleanupRecentTransportFailures(nowMs);
|
||||||
|
|
||||||
|
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = recentTransportFailures[index];
|
||||||
|
|
||||||
|
if (nowMs - candidate.timestampMs > 5_000) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.status !== reason.status) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestUrl: candidate.requestUrl,
|
||||||
|
method: candidate.method,
|
||||||
|
transportType: candidate.transportType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractRequestMetadata = (input, init) => {
|
||||||
|
if (input instanceof Request) {
|
||||||
|
return {
|
||||||
|
requestUrl: normalizeUrl(input.url),
|
||||||
|
method: typeof input.method === 'string' && input.method !== ''
|
||||||
|
? input.method.toUpperCase()
|
||||||
|
: 'GET',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
|
||||||
|
method: typeof init?.method === 'string' && init.method !== ''
|
||||||
|
? init.method.toUpperCase()
|
||||||
|
: 'GET',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const [input, init] = args;
|
||||||
|
const transport = extractRequestMetadata(input, init);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await originalFetch(...args);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
|
||||||
|
|
||||||
|
if (clonedResponse && typeof clonedResponse.text === 'function') {
|
||||||
|
clonedResponse.text()
|
||||||
|
.then((body) => {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body: null,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body: null,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: null,
|
||||||
|
body: error instanceof Error ? error.message : String(error ?? ''),
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
|
||||||
|
const originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
const originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||||
|
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
|
||||||
|
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
|
||||||
|
|
||||||
|
return originalOpen.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function (...args) {
|
||||||
|
if (!this.__tenantpilotTransportFailureListenerApplied) {
|
||||||
|
this.addEventListener('loadend', () => {
|
||||||
|
if (typeof this.status === 'number' && this.status >= 400) {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: this.__tenantpilotRequestUrl,
|
||||||
|
method: this.__tenantpilotRequestMethod,
|
||||||
|
status: this.status,
|
||||||
|
body: typeof this.responseText === 'string' ? this.responseText : null,
|
||||||
|
transportType: 'xhr',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.__tenantpilotTransportFailureListenerApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalSend.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTransportEnvelope = (value) => {
|
||||||
|
return value !== null
|
||||||
|
&& typeof value === 'object'
|
||||||
|
&& Object.prototype.hasOwnProperty.call(value, 'status')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(value, 'body')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(value, 'json')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(value, 'errors');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCancellationReason = (reason) => {
|
||||||
|
if (!isTransportEnvelope(reason)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason.status === null
|
||||||
|
&& reason.body === null
|
||||||
|
&& reason.json === null
|
||||||
|
&& reason.errors === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPageHiddenOrInactive = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof document.hasFocus === 'function'
|
||||||
|
? document.hasFocus() === false
|
||||||
|
: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpectedBackgroundTransportFailure = (reason) => {
|
||||||
|
if (isCancellationReason(reason)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransportEnvelope(reason) || !isPageHiddenOrInactive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (reason.status === 419 && typeof reason.body === 'string' && reason.body.includes('Page Expired'))
|
||||||
|
|| (reason.status === 404 && typeof reason.body === 'string' && reason.body.includes('Not Found'));
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeReason = (value, depth = 0) => {
|
const normalizeReason = (value, depth = 0) => {
|
||||||
if (depth > 3) {
|
if (depth > 3) {
|
||||||
return '[max-depth-reached]';
|
return '[max-depth-reached]';
|
||||||
@ -58,6 +311,9 @@
|
|||||||
'errors',
|
'errors',
|
||||||
'reason',
|
'reason',
|
||||||
'code',
|
'code',
|
||||||
|
'url',
|
||||||
|
'requestUrl',
|
||||||
|
'method',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
@ -95,23 +351,41 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const normalizedReason = normalizeReason(event.reason);
|
||||||
|
const transport = resolveTransportMetadata(normalizedReason);
|
||||||
const payload = {
|
const payload = {
|
||||||
source: 'window.unhandledrejection',
|
source: 'window.unhandledrejection',
|
||||||
href: window.location.href,
|
href: window.location.href,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
reason: normalizeReason(event.reason),
|
requestUrl: transport?.requestUrl ?? null,
|
||||||
|
requestMethod: transport?.method ?? null,
|
||||||
|
transportType: transport?.transportType ?? null,
|
||||||
|
reason: normalizedReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeKey = toStableJson({
|
||||||
|
source: payload.source,
|
||||||
|
href: payload.href,
|
||||||
|
requestUrl: payload.requestUrl,
|
||||||
|
reason: payload.reason,
|
||||||
|
});
|
||||||
|
|
||||||
const payloadJson = toStableJson(payload);
|
const payloadJson = toStableJson(payload);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
cleanupRecentKeys(nowMs);
|
cleanupRecentKeys(nowMs);
|
||||||
|
|
||||||
if (recentKeys.has(payloadJson)) {
|
if (recentKeys.has(dedupeKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recentKeys.set(payloadJson, nowMs);
|
recentKeys.set(dedupeKey, nowMs);
|
||||||
|
|
||||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
||||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||||
|
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
|
||||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||||
@endphp
|
@endphp
|
||||||
@ -72,6 +73,37 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($contextLinks !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
@foreach ($contextLinks as $link)
|
||||||
|
@php
|
||||||
|
$title = is_string($link['title'] ?? null) ? $link['title'] : null;
|
||||||
|
$label = is_string($link['label'] ?? null) ? $link['label'] : null;
|
||||||
|
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
|
||||||
|
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@continue($title === null || $label === null || $url === null)
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
|
{{ $label }}
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($description !== null && trim($description) !== '')
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $description }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
||||||
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
|
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
|
||||||
|
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
|
||||||
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
||||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
||||||
@ -26,6 +28,28 @@
|
|||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
@if ($arrivedFromCompareMatrix)
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-squares-2x2" size="sm">
|
||||||
|
Arrived from compare matrix
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($matrixBaselineProfileId)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Baseline profile #{{ (int) $matrixBaselineProfileId }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($matrixSubjectKey))
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Subject {{ $matrixSubjectKey }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($duplicateNamePoliciesCountValue > 0)
|
@if ($duplicateNamePoliciesCountValue > 0)
|
||||||
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
|
|||||||
@ -0,0 +1,868 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
@php
|
||||||
|
$reference = is_array($reference ?? null) ? $reference : [];
|
||||||
|
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
|
||||||
|
$denseRows = is_array($denseRows ?? null) ? $denseRows : [];
|
||||||
|
$compactResults = is_array($compactResults ?? null) ? $compactResults : [];
|
||||||
|
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
|
||||||
|
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
|
||||||
|
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
|
||||||
|
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
|
||||||
|
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
|
||||||
|
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
|
||||||
|
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
|
||||||
|
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
|
||||||
|
$draftFilters = is_array($draftFilters ?? null) ? $draftFilters : [];
|
||||||
|
$presentationState = is_array($presentationState ?? null) ? $presentationState : [];
|
||||||
|
$supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : [];
|
||||||
|
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
||||||
|
$activeFilterCount = $this->activeFilterCount();
|
||||||
|
$activeFilterSummary = $this->activeFilterSummary();
|
||||||
|
$stagedFilterSummary = $this->stagedFilterSummary();
|
||||||
|
$hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false);
|
||||||
|
$requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto');
|
||||||
|
$resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact');
|
||||||
|
$visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0);
|
||||||
|
$autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false);
|
||||||
|
$lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null;
|
||||||
|
$compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false);
|
||||||
|
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount);
|
||||||
|
|
||||||
|
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
|
||||||
|
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
|
||||||
|
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
|
||||||
|
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
|
||||||
|
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
|
||||||
|
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
|
||||||
|
$modeBadgeColor = match ($resolvedMode) {
|
||||||
|
'dense' => 'info',
|
||||||
|
'compact' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
$modeLabel = $this->presentationModeLabel($resolvedMode);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($autoRefreshActive)
|
||||||
|
<div aria-hidden="true" wire:poll.5s="pollMatrix"></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-filament::section heading="Reference overview">
|
||||||
|
<x-slot name="description">
|
||||||
|
Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
|
||||||
|
{{ $profileStatusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$referenceReady ? 'success' : 'warning'" :icon="$referenceReady ? 'heroicon-m-check-badge' : 'heroicon-m-exclamation-triangle'" size="sm">
|
||||||
|
{{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$modeBadgeColor" size="sm">
|
||||||
|
{{ $modeLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($reference['referenceSnapshotId'] ?? null))
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($hiddenAssignedTenantCount > 0)
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
|
||||||
|
{{ $hiddenAssignedTenantCount }} hidden by access scope
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
|
||||||
|
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
|
||||||
|
Visible tenants: {{ $visibleTenantCount }}.
|
||||||
|
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
|
||||||
|
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (filled($reference['referenceReasonCode'] ?? null))
|
||||||
|
<p class="text-sm text-warning-700 dark:text-warning-300">
|
||||||
|
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-2 xl:w-[28rem]">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
|
||||||
|
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $visibleTenantCount }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
|
||||||
|
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Active filters</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
@if ($activeFilterCount === 0)
|
||||||
|
All visible results
|
||||||
|
@else
|
||||||
|
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Resolved mode</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ $modeLabel }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(19rem,23rem)]">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="baseline-compare-matrix-mode-switcher">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Presentation mode</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::button tag="a" :href="$this->modeUrl('auto')" :color="$requestedMode === 'auto' ? 'primary' : 'gray'" size="sm">
|
||||||
|
Auto
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
<x-filament::button tag="a" :href="$this->modeUrl('dense')" :color="$requestedMode === 'dense' ? 'primary' : 'gray'" size="sm">
|
||||||
|
Dense
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($compactModeAvailable)
|
||||||
|
<x-filament::button tag="a" :href="$this->modeUrl('compact')" :color="$requestedMode === 'compact' ? 'primary' : 'gray'" size="sm">
|
||||||
|
Compact
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Compact unlocks at one visible tenant
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2" data-testid="baseline-compare-matrix-last-updated">
|
||||||
|
@if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt))
|
||||||
|
<x-filament::badge color="gray" icon="heroicon-m-clock" size="sm">
|
||||||
|
Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive)
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
||||||
|
Passive auto-refresh every 5 seconds
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div wire:loading.flex wire:target="refreshMatrix,applyFilters,resetFilters" class="items-center">
|
||||||
|
<x-filament::badge color="warning" icon="heroicon-m-arrow-path" size="sm">
|
||||||
|
Refreshing now
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<x-filament::button type="button" wire:click="refreshMatrix" wire:loading.attr="disabled" wire:target="refreshMatrix,applyFilters,resetFilters" color="gray" size="sm">
|
||||||
|
Refresh matrix
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($hiddenAssignedTenantCount > 0)
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Visible-set only. Hidden tenants never contribute to summaries or drilldowns.
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Filters">
|
||||||
|
<x-slot name="description">
|
||||||
|
Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
|
||||||
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div class="space-y-2 min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Applied matrix scope</div>
|
||||||
|
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
@if ($activeFilterCount === 0)
|
||||||
|
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
|
||||||
|
@else
|
||||||
|
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($activeFilterSummary !== [])
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($activeFilterSummary as $label => $value)
|
||||||
|
<x-filament::badge color="info" size="sm">
|
||||||
|
{{ $label }}: {{ $value }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||||
|
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
|
||||||
|
@if ($activeFilterCount === 0)
|
||||||
|
All visible results
|
||||||
|
@else
|
||||||
|
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
|
||||||
|
@endif
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($hasStagedFilterChanges)
|
||||||
|
<div class="mt-3 rounded-xl border border-primary-200 bg-primary-50/70 px-3 py-3 dark:border-primary-900/60 dark:bg-primary-950/20" data-testid="baseline-compare-matrix-staged-filters">
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
|
||||||
|
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
|
||||||
|
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($stagedFilterSummary !== [])
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($stagedFilterSummary as $label => $value)
|
||||||
|
<x-filament::badge color="primary" size="sm">
|
||||||
|
{{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit.prevent="applyFilters" class="space-y-4">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
|
||||||
|
|
||||||
|
@if (filled($currentFilters['subject_key'] ?? null))
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
|
||||||
|
{{ $currentFilters['subject_key'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
||||||
|
Clear subject focus
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
None set yet. Use Focus subject from a row when you want a subject-first drilldown.
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||||
|
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
||||||
|
Apply filters
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
<x-filament::button type="button" wire:click="resetFilters" color="gray" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
||||||
|
Reset filters
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Support context">
|
||||||
|
<x-slot name="description">
|
||||||
|
Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current scope</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}.
|
||||||
|
{{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($policyTypeOptions !== [])
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ count($policyTypeOptions) }} searchable policy types
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($hiddenAssignedTenantCount > 0)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Visible-set only
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Refresh honesty</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($autoRefreshActive)
|
||||||
|
<div class="mt-3">
|
||||||
|
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
||||||
|
Compare work is still queued or running
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm open:bg-white dark:border-gray-800 dark:bg-gray-900/50 dark:open:bg-gray-900/70">
|
||||||
|
<summary class="cursor-pointer list-none">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Grouped legend</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
State, freshness, and trust stay available on demand without pushing the matrix down the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ count($stateLegend) }} states</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ count($freshnessLegend) }} freshness cues</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ count($trustLegend) }} trust cues</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($stateLegend as $item)
|
||||||
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($freshnessLegend as $item)
|
||||||
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($trustLegend as $item)
|
||||||
|
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<div class="relative" data-testid="baseline-compare-matrix-results">
|
||||||
|
@if ($emptyState !== null)
|
||||||
|
<x-filament::section heading="Results">
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
|
||||||
|
<div class="space-y-3" data-testid="baseline-compare-matrix-empty-state">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
|
||||||
|
|
||||||
|
@if ($activeFilterCount > 0)
|
||||||
|
<div class="pt-1">
|
||||||
|
<x-filament::button type="button" wire:click="resetFilters" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
|
||||||
|
Reset filters
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@elseif ($resolvedMode === 'compact')
|
||||||
|
@php
|
||||||
|
$compactTenant = $tenantSummaries[0] ?? null;
|
||||||
|
$compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null);
|
||||||
|
$compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section heading="Compact compare results">
|
||||||
|
<x-slot name="description">
|
||||||
|
One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
@if ($compactTenant)
|
||||||
|
<div class="mb-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70" data-testid="baseline-compare-matrix-compact-shell">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $compactTenant['tenantName'] }}</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$compactTenantFreshnessSpec->color" :icon="$compactTenantFreshnessSpec->icon" size="sm">
|
||||||
|
{{ $compactTenantFreshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$compactTenantTrustSpec->color" :icon="$compactTenantTrustSpec->icon" size="sm">
|
||||||
|
{{ $compactTenantTrustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($compactResults as $result)
|
||||||
|
@php
|
||||||
|
$stateSpec = $stateBadge($result['state'] ?? null);
|
||||||
|
$freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null);
|
||||||
|
$trustSpec = $trustBadge($result['trustLevel'] ?? null);
|
||||||
|
$severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null;
|
||||||
|
$tenantId = (int) ($result['tenantId'] ?? 0);
|
||||||
|
$subjectKey = $result['subjectKey'] ?? null;
|
||||||
|
$primaryUrl = filled($result['findingId'] ?? null)
|
||||||
|
? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey)
|
||||||
|
: $this->tenantCompareUrl($tenantId, $subjectKey);
|
||||||
|
$runUrl = filled($result['compareRunId'] ?? null)
|
||||||
|
? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey)
|
||||||
|
: null;
|
||||||
|
$attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) {
|
||||||
|
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
||||||
|
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
||||||
|
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
||||||
|
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
$attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review')));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $result['policyType'] ?? 'Unknown policy type' }}
|
||||||
|
</div>
|
||||||
|
@if (filled($result['baselineExternalId'] ?? null))
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Reference ID: {{ $result['baselineExternalId'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon" size="sm">
|
||||||
|
{{ $stateSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||||
|
{{ $freshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||||
|
{{ $trustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($severitySpec)
|
||||||
|
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||||
|
{{ $severitySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
|
||||||
|
{{ $attentionLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null))
|
||||||
|
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
@if (filled($result['reasonSummary'] ?? null))
|
||||||
|
<div>{{ $result['reasonSummary'] }}</div>
|
||||||
|
@endif
|
||||||
|
@if (filled($result['lastComparedAt'] ?? null))
|
||||||
|
<div>Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 xl:items-end">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Missing {{ (int) ($result['missingBreadth'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
@if ($primaryUrl)
|
||||||
|
<x-filament::link :href="$primaryUrl" size="sm">
|
||||||
|
{{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($runUrl)
|
||||||
|
<x-filament::link :href="$runUrl" color="gray" size="sm">
|
||||||
|
Open run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($result['subjectKey'] ?? null))
|
||||||
|
<x-filament::link :href="$this->filterUrl(['subject_key' => $result['subjectKey']])" color="gray" size="sm">
|
||||||
|
Focus subject
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<x-filament::section heading="Dense multi-tenant scan">
|
||||||
|
<x-slot name="description">
|
||||||
|
The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="mb-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
@foreach ($tenantSummaries as $tenantSummary)
|
||||||
|
@php
|
||||||
|
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
||||||
|
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
|
||||||
|
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
|
||||||
|
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
|
||||||
|
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
|
||||||
|
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||||
|
{{ $freshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||||
|
{{ $trustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($tenantSeveritySpec)
|
||||||
|
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
|
||||||
|
{{ $tenantSeveritySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Aligned</div>
|
||||||
|
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Drift</div>
|
||||||
|
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing</div>
|
||||||
|
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Ambiguous</div>
|
||||||
|
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
@if ($tenantCompareUrl)
|
||||||
|
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
||||||
|
Open tenant compare
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($tenantRunUrl)
|
||||||
|
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
|
||||||
|
Open latest run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
|
||||||
|
<div class="min-w-[82rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800" data-testid="baseline-compare-matrix-dense-shell">
|
||||||
|
<table class="min-w-full border-separate border-spacing-0">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-950/70">
|
||||||
|
<tr>
|
||||||
|
<th class="sticky left-0 z-20 w-[22rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
|
||||||
|
Baseline subject
|
||||||
|
</th>
|
||||||
|
|
||||||
|
@foreach ($tenantSummaries as $tenantSummary)
|
||||||
|
@php
|
||||||
|
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<th class="min-w-[16rem] border-b border-gray-200 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||||
|
{{ $freshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
@foreach ($denseRows as $row)
|
||||||
|
@php
|
||||||
|
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
||||||
|
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
||||||
|
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
|
||||||
|
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
||||||
|
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
|
||||||
|
$rowSurfaceClasses = $loop->even
|
||||||
|
? 'bg-gray-50/70 dark:bg-gray-950/20'
|
||||||
|
: 'bg-white dark:bg-gray-900/60';
|
||||||
|
$subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) {
|
||||||
|
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
||||||
|
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
||||||
|
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
||||||
|
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}" data-testid="baseline-compare-matrix-row">
|
||||||
|
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $subject['policyType'] ?? 'Unknown policy type' }}
|
||||||
|
</div>
|
||||||
|
@if (filled($subject['baselineExternalId'] ?? null))
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Reference ID: {{ $subject['baselineExternalId'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
|
||||||
|
{{ $subjectTrustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($subjectSeveritySpec)
|
||||||
|
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
|
||||||
|
{{ $subjectSeveritySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $subjectAttentionClasses }}">
|
||||||
|
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($subject['subjectKey'] ?? null))
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
|
||||||
|
Focus subject
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
@foreach ($cells as $cell)
|
||||||
|
@php
|
||||||
|
$cellStateSpec = $stateBadge($cell['state'] ?? null);
|
||||||
|
$cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null);
|
||||||
|
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
||||||
|
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
||||||
|
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
||||||
|
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
||||||
|
$primaryUrl = filled($cell['findingId'] ?? null)
|
||||||
|
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
|
||||||
|
: $this->tenantCompareUrl($tenantId, $subjectKey);
|
||||||
|
$runUrl = filled($cell['compareRunId'] ?? null)
|
||||||
|
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
|
||||||
|
: null;
|
||||||
|
$attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
|
||||||
|
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
|
||||||
|
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
|
||||||
|
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
|
||||||
|
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
$cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
|
||||||
|
'needs_attention' => 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40',
|
||||||
|
'refresh_recommended' => 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40',
|
||||||
|
'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10',
|
||||||
|
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
|
||||||
|
<div class="flex h-full flex-col gap-3 rounded-xl border p-3 text-xs transition-colors group-hover:border-primary-200 dark:group-hover:border-primary-900 {{ $cellSurfaceClasses }}">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
|
||||||
|
{{ $cellStateSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($cellSeveritySpec)
|
||||||
|
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
|
||||||
|
{{ $cellSeveritySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
|
||||||
|
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge :color="$cellFreshnessSpec->color" :icon="$cellFreshnessSpec->icon" size="sm">
|
||||||
|
{{ $cellFreshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
||||||
|
{{ $cellTrustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null))
|
||||||
|
<div class="space-y-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||||
|
@if (filled($cell['reasonSummary'] ?? null))
|
||||||
|
<div>{{ $cell['reasonSummary'] }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($cell['lastComparedAt'] ?? null))
|
||||||
|
<div>Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-auto space-y-2">
|
||||||
|
@if ($primaryUrl)
|
||||||
|
<div class="text-sm">
|
||||||
|
<x-filament::link :href="$primaryUrl" size="sm">
|
||||||
|
{{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($runUrl || filled($subjectKey))
|
||||||
|
<details class="rounded-lg border border-gray-200 bg-white/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-950/50">
|
||||||
|
<summary class="cursor-pointer list-none text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
More follow-up
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-3 text-sm">
|
||||||
|
@if ($runUrl)
|
||||||
|
<x-filament::link :href="$runUrl" color="gray" size="sm">
|
||||||
|
Open run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($subjectKey))
|
||||||
|
<x-filament::link :href="$this->filterUrl(['subject_key' => $subjectKey])" color="gray" size="sm">
|
||||||
|
Focus subject
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::page>
|
||||||
@ -1,4 +1,20 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
@php($navigationContext = \App\Support\Navigation\CanonicalNavigationContext::fromRequest(request()))
|
||||||
|
|
||||||
|
@if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null)
|
||||||
|
<x-filament::section class="mb-6">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Return path stays quiet while this overview remains focused on alert health and downstream drilldowns.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button tag="a" color="gray" :href="$navigationContext->backLinkUrl">
|
||||||
|
{{ $navigationContext->backLinkLabel }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
|||||||
@ -14,10 +14,51 @@
|
|||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($this->showSelectedExceptionSummary && $selectedException)
|
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||||
<x-filament::section>
|
<x-filament::section heading="Focused review lane">
|
||||||
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
<x-slot name="description">
|
||||||
'selectedException' => $selectedException,
|
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||||
])
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,26rem)]">
|
||||||
|
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||||
|
'selectedException' => $selectedException,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 p-4 shadow-sm dark:border-primary-500/30 dark:bg-primary-500/10">
|
||||||
|
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">
|
||||||
|
Decision lane
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-primary-800 dark:text-primary-200">
|
||||||
|
@if ($selectedException->isPending())
|
||||||
|
Approve exception and Reject exception are the only promoted next steps while this request remains pending.
|
||||||
|
@else
|
||||||
|
This exception is no longer decision-ready. Use the selected context group to close details or drill into related records.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Related drilldown
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Open tenant detail and Open finding stay available for context, but they no longer share the same semantic lane as the review decision.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@else
|
||||||
|
<x-filament::section heading="Quiet monitoring mode">
|
||||||
|
<x-slot name="description">
|
||||||
|
Inspect an exception to enter the focused review lane. Scope, filters, and tenant drilldowns stay secondary until one request is actively under review.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
No exception is selected right now. Use Inspect exception from the queue to review one request in context.
|
||||||
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,45 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
@php($landingHierarchy = $this->landingHierarchySummary())
|
||||||
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||||
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||||
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
|
|
||||||
|
<x-filament::section heading="Monitoring landing" class="mb-6">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Tabs, filters, and row inspection define the active work lane. Scope context and return navigation stay secondary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['scope_label'] }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['scope_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($landingHierarchy['return_label'] !== null && $landingHierarchy['return_body'] !== null)
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Return path</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['return_label'] }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['return_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($landingHierarchy['scope_reset_label'] !== null && $landingHierarchy['scope_reset_body'] !== null)
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope reset</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['scope_reset_label'] }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['scope_reset_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Inspect flow</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open run detail</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['inspect_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
<x-filament::tabs label="Operations tabs">
|
<x-filament::tabs label="Operations tabs">
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'all'"
|
:active="$this->activeTab === 'all'"
|
||||||
@ -57,3 +94,4 @@
|
|||||||
|
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
$blockedBanner = $this->blockedExecutionBanner();
|
$blockedBanner = $this->blockedExecutionBanner();
|
||||||
$lifecycleBanner = $this->lifecycleBanner();
|
$lifecycleBanner = $this->lifecycleBanner();
|
||||||
$restoreContinuationBanner = $this->restoreContinuationBanner();
|
$restoreContinuationBanner = $this->restoreContinuationBanner();
|
||||||
|
$monitoringDetail = $this->monitoringDetailSummary();
|
||||||
$pollInterval = $this->pollInterval();
|
$pollInterval = $this->pollInterval();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@ -10,6 +11,44 @@
|
|||||||
<div
|
<div
|
||||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||||
>
|
>
|
||||||
|
<x-filament::section heading="Monitoring detail" class="mb-6">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($contextBanner !== null)
|
@if ($contextBanner !== null)
|
||||||
@php
|
@php
|
||||||
$bannerClasses = match ($contextBanner['tone']) {
|
$bannerClasses = match ($contextBanner['tone']) {
|
||||||
|
|||||||
@ -105,6 +105,7 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
|||||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
|
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
|
||||||
'items' => $overview['attention_items'] ?? [],
|
'items' => $overview['attention_items'] ?? [],
|
||||||
'emptyState' => $overview['attention_empty_state'] ?? [],
|
'emptyState' => $overview['attention_empty_state'] ?? [],
|
||||||
|
'triageReviewProgress' => $overview['triage_review_progress'] ?? [],
|
||||||
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))
|
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))
|
||||||
|
|
||||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [
|
@livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
|
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
|
||||||
|
/** @var array<string, mixed>|null $reviewState */
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -11,6 +12,8 @@
|
|||||||
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
|
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
};
|
};
|
||||||
|
$reviewStateColor = \App\Support\Badges\BadgeRenderer::color(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
|
||||||
|
$reviewStateLabel = \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
@ -49,6 +52,24 @@ class="h-5 w-5 text-warning-500"
|
|||||||
{{ $context->arrivalSummary }}
|
{{ $context->arrivalSummary }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$reviewStateColor" size="sm">
|
||||||
|
{{ $reviewStateLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $context->concernFamilyLabel() }} review state
|
||||||
|
@if (filled($reviewState['reviewed_by_user_name'] ?? null))
|
||||||
|
by {{ $reviewState['reviewed_by_user_name'] }}
|
||||||
|
@endif
|
||||||
|
@if (($reviewState['reviewed_at'] ?? null) instanceof \Illuminate\Support\Carbon)
|
||||||
|
· {{ $reviewState['reviewed_at']->diffForHumans() }}
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (filled($context->currentTruthDelta))
|
@if (filled($context->currentTruthDelta))
|
||||||
<div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
<div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||||
{{ $context->currentTruthDelta }}
|
{{ $context->currentTruthDelta }}
|
||||||
@ -89,9 +110,24 @@ class="h-5 w-5 text-warning-500"
|
|||||||
{{ $context->nextStep['helperText'] }}
|
{{ $context->nextStep['helperText'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white/80 p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||||
|
TenantPilot only. Review state tracks shared triage progress and never changes backup posture or recovery evidence.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-t border-gray-200 pt-4 dark:border-white/10">
|
||||||
|
{{ $this->markReviewedAction }}
|
||||||
|
{{ $this->markFollowUpNeededAction }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,42 @@
|
|||||||
<x-filament::section heading="Needs attention">
|
<x-filament::section heading="Needs attention">
|
||||||
|
@if ($triageReviewProgress !== [])
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
@foreach ($triageReviewProgress as $progress)
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $progress['label'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Reviewed {{ $progress['reviewed_count'] }}/{{ $progress['affected_total'] }} · Follow-up needed {{ $progress['follow_up_needed_count'] }} · Changed since review {{ $progress['changed_since_review_count'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
|
||||||
|
Current affected set
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
<x-filament::link :href="$progress['not_reviewed_destination']['url']" size="sm">
|
||||||
|
Not reviewed {{ $progress['not_reviewed_count'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
<x-filament::link :href="$progress['follow_up_needed_destination']['url']" size="sm">
|
||||||
|
Follow-up needed {{ $progress['follow_up_needed_count'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
<x-filament::link :href="$progress['changed_since_review_destination']['url']" size="sm">
|
||||||
|
Changed since review {{ $progress['changed_since_review_count'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
<x-filament::link :href="$progress['reviewed_destination']['url']" size="sm">
|
||||||
|
Reviewed {{ $progress['reviewed_count'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($items === [])
|
@if ($items === [])
|
||||||
<div class="flex h-full flex-col justify-between gap-4">
|
<div class="flex h-full flex-col justify-between gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => Finding::SEVERITY_CRITICAL],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($fixture['user'])->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||||
|
|
||||||
|
$page = visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('Dense multi-tenant scan')
|
||||||
|
->assertSee('Grouped legend')
|
||||||
|
->assertSee('Open finding')
|
||||||
|
->assertSee('More follow-up')
|
||||||
|
->click('Open finding')
|
||||||
|
->waitForText('Back to compare matrix')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Back to compare matrix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => Finding::SEVERITY_HIGH],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
||||||
|
->assertSee('Compact compare results')
|
||||||
|
->assertSee('Open finding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
attributes: [
|
||||||
|
'status' => \App\Support\OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
||||||
|
'completed_at' => null,
|
||||||
|
'started_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($fixture['user'])->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->waitForText('No rows match the current filters')
|
||||||
|
->assertSee('Passive auto-refresh every 5 seconds')
|
||||||
|
->click('Reset filters')
|
||||||
|
->waitForText('Dense multi-tenant scan')
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(20_000);
|
||||||
|
|
||||||
|
function spec192ApprovedFindingException(Tenant $tenant, User $requester)
|
||||||
|
{
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $service */
|
||||||
|
$service = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$requested = $service->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'request_reason' => 'Browser smoke test exception request.',
|
||||||
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $service->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Browser smoke approval.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('smokes remediated standard record pages with contextual navigation and one clear next step', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Browser Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Browser Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $onboardingTenant->workspace,
|
||||||
|
'tenant' => $onboardingTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $onboardingTenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Browser Baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||||
|
|
||||||
|
\App\Models\BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
|
'summary' => ['finding_count' => 2],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = spec192ApprovedFindingException($tenant, $user);
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Review compare matrix')
|
||||||
|
->assertSee('Compare now');
|
||||||
|
|
||||||
|
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Review pack')
|
||||||
|
->assertSee('Refresh evidence');
|
||||||
|
|
||||||
|
visit(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Open finding')
|
||||||
|
->assertSee('Renew exception');
|
||||||
|
|
||||||
|
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Artifact truth')
|
||||||
|
->assertSee('Evidence snapshot');
|
||||||
|
|
||||||
|
visit(TenantResource::getUrl('edit', ['record' => $onboardingTenant], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Resume onboarding')
|
||||||
|
->assertSee('Open tenant detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Workflow Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Edit tenant')
|
||||||
|
->assertSee('Open provider connections');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Reference Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Reference Baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Spec192 Browser Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'version_number' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => (int) $baselineSnapshot->getKey(),
|
||||||
|
'meta_jsonb' => [
|
||||||
|
'display_name' => 'Spec192 Browser Policy',
|
||||||
|
'version_reference' => [
|
||||||
|
'policy_version_id' => (int) $version->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'name' => 'Spec192 Browser Backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->forTenant($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'backup_set.add_policies',
|
||||||
|
'context' => [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reviewSnapshot = seedTenantReviewEvidence($tenant);
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $reviewSnapshot);
|
||||||
|
|
||||||
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_review_id' => (int) $review->getKey(),
|
||||||
|
'evidence_snapshot_id' => (int) $reviewSnapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Spec192 Browser Destination',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
|
->waitForText('Download')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Regenerate');
|
||||||
|
|
||||||
|
visit(AlertDestinationResource::getUrl('view', ['record' => $destination], panel: 'admin'))
|
||||||
|
->waitForText('Send test message')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Details');
|
||||||
|
|
||||||
|
visit(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('View snapshot');
|
||||||
|
|
||||||
|
visit(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
|
||||||
|
->waitForText('Memberships')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Edit');
|
||||||
|
|
||||||
|
visit(BaselineSnapshotResource::getUrl('view', ['record' => $baselineSnapshot], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||||
|
->assertSee('Spec192 Browser Policy');
|
||||||
|
|
||||||
|
visit(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||||
|
->assertSee('Operations');
|
||||||
|
});
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('smokes remediated, calm-reference, and explicit-exception monitoring surfaces', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
|
||||||
|
FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Browser hierarchy smoke',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$diagnosticsTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $diagnosticsTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'readonly',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $diagnosticsTenant->getKey())
|
||||||
|
->update(['role' => 'readonly']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
visit(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->assertSee('Quiet monitoring mode');
|
||||||
|
|
||||||
|
visit(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->assertSee('Monitoring detail')
|
||||||
|
->assertSee('Follow-up lane');
|
||||||
|
|
||||||
|
visit('/admin/alerts')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->assertSee('Alert deliveries');
|
||||||
|
|
||||||
|
visit('/admin/t/'.$diagnosticsTenant->external_id.'/diagnostics')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertNoConsoleLogs()
|
||||||
|
->assertSee('Missing owner');
|
||||||
|
});
|
||||||
@ -150,6 +150,55 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
|||||||
->assertCanNotSeeTableRecords([$deliveryB]);
|
->assertCanNotSeeTableRecords([$deliveryB]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps deep-linked delivery filters while surfacing origin context quietly', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sentDelivery = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'alert_rule_id' => (int) $rule->getKey(),
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_SENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$failedDelivery = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'alert_rule_id' => (int) $rule->getKey(),
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'status' => AlertDelivery::STATUS_FAILED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withQueryParams([
|
||||||
|
'nav' => [
|
||||||
|
'source_surface' => 'alerts.overview',
|
||||||
|
'canonical_route_name' => 'admin.alert-deliveries.index',
|
||||||
|
'back_label' => 'Back to alerts',
|
||||||
|
'back_url' => \App\Filament\Clusters\Monitoring\AlertsCluster::getUrl(panel: 'admin'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->actingAs($user)
|
||||||
|
->test(ListAlertDeliveries::class)
|
||||||
|
->assertActionVisible('operate_hub_back_to_origin_alert_deliveries')
|
||||||
|
->filterTable('status', AlertDelivery::STATUS_SENT)
|
||||||
|
->assertCanSeeTableRecords([$sentDelivery])
|
||||||
|
->assertCanNotSeeTableRecords([$failedDelivery]);
|
||||||
|
});
|
||||||
|
|
||||||
it('replaces the persisted tenant filter when canonical tenant context changes', function (): void {
|
it('replaces the persisted tenant filter when canonical tenant context changes', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|||||||
@ -0,0 +1,317 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
|
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$visibleRunTwo = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$hiddenRun = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['hiddenTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$visibleRunTwo,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['hiddenTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$hiddenRun,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
|
$wifiRow = collect($matrix['denseRows'])->first(
|
||||||
|
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
||||||
|
->and($matrix['reference']['visibleTenantCount'])->toBe(2)
|
||||||
|
->and(collect($matrix['tenantSummaries'])->pluck('tenantName')->all())->toEqualCanonicalizing([
|
||||||
|
(string) $fixture['visibleTenant']->name,
|
||||||
|
(string) $fixture['visibleTenantTwo']->name,
|
||||||
|
])
|
||||||
|
->and($wifiRow)->not->toBeNull()
|
||||||
|
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
|
||||||
|
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
|
||||||
|
->and(count($wifiRow['cells']))->toBe(2)
|
||||||
|
->and($matrix['denseRows'])->toHaveCount(2)
|
||||||
|
->and($matrix['compactResults'])->toBeEmpty()
|
||||||
|
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
|
||||||
|
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
|
||||||
|
->and($matrix['lastUpdatedAt'])->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$matchTenant = $fixture['visibleTenant'];
|
||||||
|
$differTenant = $fixture['visibleTenantTwo'];
|
||||||
|
|
||||||
|
$missingTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Contoso Missing',
|
||||||
|
]);
|
||||||
|
$ambiguousTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Contoso Ambiguous',
|
||||||
|
]);
|
||||||
|
$notComparedTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Contoso Uncovered',
|
||||||
|
]);
|
||||||
|
$staleTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Contoso Stale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fixture['user']->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $missingTenant->getKey() => ['role' => 'owner'],
|
||||||
|
(int) $ambiguousTenant->getKey() => ['role' => 'owner'],
|
||||||
|
(int) $notComparedTenant->getKey() => ['role' => 'owner'],
|
||||||
|
(int) $staleTenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assignTenantToBaselineProfile($fixture['profile'], $missingTenant);
|
||||||
|
$this->assignTenantToBaselineProfile($fixture['profile'], $ambiguousTenant);
|
||||||
|
$this->assignTenantToBaselineProfile($fixture['profile'], $notComparedTenant);
|
||||||
|
$this->assignTenantToBaselineProfile($fixture['profile'], $staleTenant);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun($matchTenant, $fixture['profile'], $fixture['snapshot']);
|
||||||
|
|
||||||
|
$differRun = $this->makeBaselineCompareMatrixRun($differTenant, $fixture['profile'], $fixture['snapshot']);
|
||||||
|
$this->makeBaselineCompareMatrixFinding($differTenant, $fixture['profile'], $differRun, 'wifi-corp-profile', [
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
'change_type' => 'different_version',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingRun = $this->makeBaselineCompareMatrixRun($missingTenant, $fixture['profile'], $fixture['snapshot']);
|
||||||
|
$this->makeBaselineCompareMatrixFinding($missingTenant, $fixture['profile'], $missingRun, 'wifi-corp-profile', [
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
'change_type' => 'missing_policy',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ambiguousRun = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$ambiguousTenant,
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
[
|
||||||
|
'baseline_compare' => [
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 1,
|
||||||
|
'by_reason' => ['ambiguous_match' => 1],
|
||||||
|
'subjects' => [
|
||||||
|
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
|
||||||
|
'reason_code' => 'ambiguous_match',
|
||||||
|
'resolution_outcome' => 'ambiguous_match',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->makeBaselineCompareMatrixFinding($ambiguousTenant, $fixture['profile'], $ambiguousRun, 'wifi-corp-profile', [
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
'change_type' => 'missing_policy',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$notComparedTenant,
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
[
|
||||||
|
'baseline_compare' => [
|
||||||
|
'coverage' => [
|
||||||
|
'proof' => true,
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => [],
|
||||||
|
'uncovered_types' => ['deviceConfiguration'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$staleTenant,
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'completed_at' => $fixture['snapshot']->captured_at->copy()->subDay(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $fixture['profile']->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey() - 1,
|
||||||
|
'baseline_compare' => [
|
||||||
|
'coverage' => [
|
||||||
|
'proof' => true,
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
],
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 0,
|
||||||
|
'by_reason' => [],
|
||||||
|
'subjects' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
|
$wifiRow = collect($matrix['denseRows'])->first(
|
||||||
|
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
$cellsByTenant = collect($wifiRow['cells'] ?? [])
|
||||||
|
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
|
||||||
|
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
|
||||||
|
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
|
||||||
|
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
||||||
|
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
|
||||||
|
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
|
||||||
|
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
||||||
|
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
|
||||||
|
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$visibleRun,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$deviceOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
||||||
|
'policyTypes' => ['deviceConfiguration'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$driftOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
||||||
|
'states' => ['differ'],
|
||||||
|
'severities' => ['critical'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subjectFocus = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
||||||
|
'focusedSubjectKey' => 'wifi-corp-profile',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(count($deviceOnly['denseRows']))->toBe(1)
|
||||||
|
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
||||||
|
->and(count($driftOnly['denseRows']))->toBe(1)
|
||||||
|
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
||||||
|
->and(count($subjectFocus['denseRows']))->toBe(1)
|
||||||
|
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
|
||||||
|
|
||||||
|
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
||||||
|
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
|
||||||
|
->and($matrix['compactResults'])->toHaveCount(2)
|
||||||
|
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
|
||||||
|
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
|
||||||
|
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
|
||||||
|
});
|
||||||
@ -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\Drift\DriftHasher;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
||||||
bindFailHardGraphClient();
|
bindFailHardGraphClient();
|
||||||
@ -100,3 +105,28 @@
|
|||||||
expect($code)->toBeString();
|
expect($code)->toBeString();
|
||||||
expect($code)->toContain('->chunk(');
|
expect($code)->toContain('->chunk(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps matrix aggregation query-bounded over the visible assigned set', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
foreach (range(1, 6) as $index) {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Matrix Tenant '.$index,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fixture['user']->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assignTenantToBaselineProfile($fixture['profile'], $tenant);
|
||||||
|
$this->makeBaselineCompareMatrixRun($tenant, $fixture['profile'], $fixture['snapshot']);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::enableQueryLog();
|
||||||
|
DB::flushQueryLog();
|
||||||
|
|
||||||
|
app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
|
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|||||||
@ -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\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantTriageReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
trait BuildsPortfolioTriageFixtures
|
trait BuildsPortfolioTriageFixtures
|
||||||
@ -154,12 +159,58 @@ protected function portfolioTriageRegistryList(User $user, Tenant $workspaceTena
|
|||||||
protected function portfolioReturnFilters(
|
protected function portfolioReturnFilters(
|
||||||
array $backupPosture = [],
|
array $backupPosture = [],
|
||||||
array $recoveryEvidence = [],
|
array $recoveryEvidence = [],
|
||||||
|
array $reviewState = [],
|
||||||
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
): array {
|
): array {
|
||||||
return [
|
return [
|
||||||
'backup_posture' => $backupPosture,
|
'backup_posture' => $backupPosture,
|
||||||
'recovery_evidence' => $recoveryEvidence,
|
'recovery_evidence' => $recoveryEvidence,
|
||||||
|
'review_state' => $reviewState,
|
||||||
'triage_sort' => $triageSort,
|
'triage_sort' => $triageSort,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function seedPortfolioTriageReview(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $concernFamily,
|
||||||
|
string $manualState = TenantTriageReview::STATE_REVIEWED,
|
||||||
|
?User $actor = null,
|
||||||
|
bool $changedFingerprint = false,
|
||||||
|
): TenantTriageReview {
|
||||||
|
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||||
|
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||||
|
|
||||||
|
$review = match ($manualState) {
|
||||||
|
TenantTriageReview::STATE_REVIEWED => app(TenantTriageReviewService::class)->markReviewed(
|
||||||
|
tenant: $tenant,
|
||||||
|
concernFamily: $concernFamily,
|
||||||
|
backupHealth: $backupHealth,
|
||||||
|
recoveryEvidence: $recoveryEvidence,
|
||||||
|
actor: $actor,
|
||||||
|
),
|
||||||
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => app(TenantTriageReviewService::class)->markFollowUpNeeded(
|
||||||
|
tenant: $tenant,
|
||||||
|
concernFamily: $concernFamily,
|
||||||
|
backupHealth: $backupHealth,
|
||||||
|
recoveryEvidence: $recoveryEvidence,
|
||||||
|
actor: $actor,
|
||||||
|
),
|
||||||
|
default => throw new InvalidArgumentException('Unsupported triage review state.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($changedFingerprint) {
|
||||||
|
$review->forceFill([
|
||||||
|
'review_fingerprint' => hash('sha256', sprintf(
|
||||||
|
'%s:%s:%d',
|
||||||
|
$concernFamily,
|
||||||
|
$manualState,
|
||||||
|
(int) $review->getKey(),
|
||||||
|
)),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
request()->attributes->remove('tenant_resource.triage_review_snapshot');
|
||||||
|
|
||||||
|
return $review->fresh(['reviewer']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,10 @@
|
|||||||
->assertSee('Artifact truth')
|
->assertSee('Artifact truth')
|
||||||
->assertSee($tenantA->name)
|
->assertSee($tenantA->name)
|
||||||
->assertSee($tenantB->name)
|
->assertSee($tenantB->name)
|
||||||
->assertDontSee($foreignWorkspaceTenant->name);
|
->assertDontSee($foreignWorkspaceTenant->name)
|
||||||
|
->assertDontSee('Monitoring landing')
|
||||||
|
->assertDontSee('Navigation lane')
|
||||||
|
->assertDontSee('Focused review lane');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 for users without workspace membership on the evidence overview', function (): void {
|
it('returns 404 for users without workspace membership on the evidence overview', function (): void {
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\StoredReport;
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
@ -53,6 +55,17 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
OperationRun::factory()->forTenant($tenant)->create();
|
OperationRun::factory()->forTenant($tenant)->create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenceSnapshotHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the evidence list page for an authorized user', function (): void {
|
it('renders the evidence list page for an authorized user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
@ -125,27 +138,47 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
it('renders the view page for an active snapshot', function (): void {
|
it('renders the view page for an active snapshot', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
'summary' => ['finding_count' => 2],
|
'summary' => ['finding_count' => 2],
|
||||||
'generated_at' => now(),
|
'generated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Review pack');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||||
->assertActionVisible('refresh_snapshot')
|
->assertActionVisible('refresh_snapshot')
|
||||||
->assertActionVisible('expire_snapshot');
|
->assertActionVisible('expire_snapshot');
|
||||||
|
|
||||||
|
expect(collect(evidenceSnapshotHeaderActions($component))
|
||||||
|
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all())
|
||||||
|
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
|
||||||
|
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
|
||||||
|
->toContain('operation_run', 'review_pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||||
|
|||||||
@ -136,6 +136,26 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
|||||||
expect(getAlertDeliveryHeaderAction($component, 'view_alert_rules'))->toBeNull();
|
expect(getAlertDeliveryHeaderAction($component, 'view_alert_rules'))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows quiet origin navigation on alert deliveries when deep-linked from alerts overview', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::withQueryParams([
|
||||||
|
'nav' => [
|
||||||
|
'source_surface' => 'alerts.overview',
|
||||||
|
'canonical_route_name' => 'admin.alert-deliveries.index',
|
||||||
|
'back_label' => 'Back to alerts',
|
||||||
|
'back_url' => \App\Filament\Clusters\Monitoring\AlertsCluster::getUrl(panel: 'admin'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->actingAs($user)
|
||||||
|
->test(ListAlertDeliveries::class)
|
||||||
|
->assertActionVisible('operate_hub_back_to_origin_alert_deliveries');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
|
it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
|
||||||
[$user] = createUserWithTenant(role: 'owner');
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,22 @@ function auditLogPageTestRecord(?Tenant $tenant, array $attributes = []): AuditL
|
|||||||
->assertSee('Review governance, operational, and workspace-admin events in reverse chronological order');
|
->assertSee('Review governance, operational, and workspace-admin events in reverse chronological order');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps preselected audit detail subordinate to the summary-first route', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$audit = auditLogPageTestRecord($tenant, [
|
||||||
|
'summary' => 'Preselected audit detail',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Summary-first audit history')
|
||||||
|
->assertSee('Preselected audit detail')
|
||||||
|
->assertDontSee('Focused review lane');
|
||||||
|
});
|
||||||
|
|
||||||
it('loads the audit page with populated filter options', function (): void {
|
it('loads the audit page with populated filter options', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,8 @@
|
|||||||
Livewire::test(BaselineCompareLanding::class)
|
Livewire::test(BaselineCompareLanding::class)
|
||||||
->assertActionVisible('compareNow')
|
->assertActionVisible('compareNow')
|
||||||
->assertActionDisabled('compareNow')
|
->assertActionDisabled('compareNow')
|
||||||
|
->assertDontSee('Monitoring landing')
|
||||||
|
->assertDontSee('Navigation lane')
|
||||||
->callAction('compareNow')
|
->callAction('compareNow')
|
||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
|
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Visible-set baseline')
|
||||||
|
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
|
||||||
|
->assertDontSee('Monitoring landing')
|
||||||
|
->assertDontSee('Focused review lane')
|
||||||
|
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
|
||||||
|
->assertDontSee('Passive auto-refresh every 5 seconds')
|
||||||
|
->assertSee('Grouped legend')
|
||||||
|
->assertSee('Apply filters')
|
||||||
|
->assertSee('Compact unlocks at one visible tenant')
|
||||||
|
->assertSee('Dense multi-tenant scan')
|
||||||
|
->assertSee('Open finding')
|
||||||
|
->assertSee('More follow-up')
|
||||||
|
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
|
||||||
|
->assertSee('sticky left-0', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$finding = $this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
|
$component = Livewire::withQueryParams([
|
||||||
|
'mode' => 'dense',
|
||||||
|
'policy_type' => ['deviceConfiguration'],
|
||||||
|
'state' => ['differ'],
|
||||||
|
'severity' => ['high'],
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
])
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||||
|
->assertSet('requestedMode', 'dense')
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('Focused subject')
|
||||||
|
->assertSee('wifi-corp-profile');
|
||||||
|
|
||||||
|
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
||||||
|
->set('draftSelectedStates', ['match'])
|
||||||
|
->set('draftSelectedSeverities', [])
|
||||||
|
->set('draftTenantSort', 'freshness_urgency')
|
||||||
|
->set('draftSubjectSort', 'display_name')
|
||||||
|
->assertSee('Draft filters are staged');
|
||||||
|
|
||||||
|
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
||||||
|
|
||||||
|
$component->call('applyFilters')->assertRedirect(
|
||||||
|
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
|
||||||
|
);
|
||||||
|
|
||||||
|
$applied = Livewire::withQueryParams([
|
||||||
|
'mode' => 'dense',
|
||||||
|
'policy_type' => ['compliancePolicy'],
|
||||||
|
'state' => ['match'],
|
||||||
|
'tenant_sort' => 'freshness_urgency',
|
||||||
|
'subject_sort' => 'display_name',
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
])
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
|
||||||
|
|
||||||
|
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
||||||
|
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
||||||
|
|
||||||
|
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
|
||||||
|
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
|
||||||
|
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
|
||||||
|
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
||||||
|
|
||||||
|
$applied->call('resetFilters')->assertRedirect(
|
||||||
|
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
|
||||||
|
->assertSee('Compact compare results')
|
||||||
|
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
|
||||||
|
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$fixture['snapshot']->markIncomplete();
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No usable reference snapshot')
|
||||||
|
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$fixture['profile']->tenantAssignments()->delete();
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No assigned tenants');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No visible assigned tenants');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
attributes: [
|
||||||
|
'status' => \App\Support\OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
||||||
|
'completed_at' => null,
|
||||||
|
'started_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Passive auto-refresh every 5 seconds')
|
||||||
|
->assertSee('wire:poll.5s="pollMatrix"', false)
|
||||||
|
->assertSee('Refresh matrix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('No rows match the current filters')
|
||||||
|
->assertSee('Reset filters');
|
||||||
|
});
|
||||||
@ -9,12 +9,26 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function baselineProfileCaptureHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -78,7 +92,7 @@
|
|||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
->assertActionVisible('capture')
|
->assertActionVisible('capture')
|
||||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||||
@ -86,6 +100,16 @@
|
|||||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
|
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($topLevelActionNames)->toBe(['capture']);
|
||||||
|
|
||||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
@ -8,9 +9,23 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function baselineProfileHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||||
@ -132,3 +147,85 @@
|
|||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
|
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
||||||
|
&& $action->isConfirmationRequired()
|
||||||
|
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
||||||
|
|
||||||
|
$topLevelActionNames = collect(baselineProfileHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$moreGroup = collect(baselineProfileHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible());
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($topLevelActionNames)->toBe(['compareNow'])
|
||||||
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||||
|
->and($moreActionNames)->toEqualCanonicalizing(['compareAssignedTenants', 'edit'])
|
||||||
|
->and(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||||
|
->toContain('compare_matrix', 'baseline_snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
|
->assertActionVisible('compareAssignedTenants')
|
||||||
|
->assertActionDisabled('compareAssignedTenants');
|
||||||
|
|
||||||
|
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||||
|
->toContain('compare_matrix');
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
it('keeps database notifications enabled without background polling on every panel', function (): void {
|
||||||
|
foreach (['admin', 'tenant', 'system'] as $panelId) {
|
||||||
|
$panel = Filament::getPanel($panelId);
|
||||||
|
|
||||||
|
expect($panel->hasDatabaseNotifications())->toBeTrue();
|
||||||
|
expect($panel->getDatabaseNotificationsPollingInterval())->toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the admin notifications modal without a polling attribute', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin');
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$html = $response->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
|
||||||
|
|
||||||
|
preg_match('/<[^>]+wire:name="Filament\\\\Livewire\\\\DatabaseNotifications"[^>]*>/', $html, $matches);
|
||||||
|
|
||||||
|
expect($matches)->not->toBeEmpty('Expected the admin page to render the database notifications Livewire root element.');
|
||||||
|
expect($matches[0])->not->toContain('wire:poll');
|
||||||
|
expect($matches[0])->not->toContain('wire:poll.30s');
|
||||||
|
});
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function editTenantHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTenantHeaderGroupLabels(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(editTenantHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTenantHeaderPrimaryNames(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(editTenantHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps related links in contextual placement and reserves the header for lifecycle actions', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Open tenant detail')
|
||||||
|
->assertSee('Resume onboarding');
|
||||||
|
|
||||||
|
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||||
|
->and(editTenantHeaderGroupLabels($component))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant lifecycle mutations available under the lifecycle header group with confirmation intact', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionEnabled('archive')
|
||||||
|
->assertActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired());
|
||||||
|
|
||||||
|
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||||
|
->and(editTenantHeaderGroupLabels($component))->toBe(['Lifecycle']);
|
||||||
|
});
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function findingExceptionHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingExceptionHeaderNames(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(findingExceptionHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps finding navigation out of the header while preserving renewal and revocation actions', function (): void {
|
||||||
|
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $service */
|
||||||
|
$service = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$requested = $service->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'request_reason' => 'Existing compensating controls remain in place.',
|
||||||
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = $service->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Accepted while remediation is scheduled.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($requester);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
|
||||||
|
->assertActionVisible('renew_exception')
|
||||||
|
->assertActionVisible('revoke_exception')
|
||||||
|
->assertActionExists('revoke_exception', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Approval queue')
|
||||||
|
->assertSee('Open finding');
|
||||||
|
|
||||||
|
expect(findingExceptionHeaderNames($component))
|
||||||
|
->toEqualCanonicalizing(['renew_exception', 'revoke_exception'])
|
||||||
|
->not->toContain('open_finding', 'open_approval_queue');
|
||||||
|
});
|
||||||
@ -6,13 +6,20 @@
|
|||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\TenantTriageReview;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
use function Pest\Laravel\mock;
|
||||||
@ -26,6 +33,18 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
|
|||||||
], panel: 'tenant', tenant: $tenant);
|
], panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Tenant $tenant, array $state): mixed
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
request()->attributes->remove('portfolio_triage.arrival_context');
|
||||||
|
|
||||||
|
return Livewire::withQueryParams([
|
||||||
|
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
|
||||||
|
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
|
||||||
|
}
|
||||||
|
|
||||||
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
|
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -164,3 +183,71 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
|
|||||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||||
], panel: 'tenant', tenant: $tenant), false);
|
], panel: 'tenant', tenant: $tenant), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows review-state context and requires preview confirmation before marking the current concern reviewed', function (): void {
|
||||||
|
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Review Tenant');
|
||||||
|
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||||
|
|
||||||
|
$component = tenantDashboardArrivalWidget($user, $tenant, [
|
||||||
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
||||||
|
'tenantRouteKey' => (string) $tenant->external_id,
|
||||||
|
'workspaceId' => (int) $tenant->workspace_id,
|
||||||
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||||
|
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
||||||
|
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||||
|
'returnFilters' => $this->portfolioReturnFilters(
|
||||||
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->assertSee('Not reviewed')
|
||||||
|
->assertActionVisible('markReviewed')
|
||||||
|
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
||||||
|
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
||||||
|
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
||||||
|
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
||||||
|
->mountAction('markReviewed');
|
||||||
|
|
||||||
|
expect(TenantTriageReview::query()->count())->toBe(0);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSee('Reviewed');
|
||||||
|
|
||||||
|
expect(TenantTriageReview::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
||||||
|
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
||||||
|
->whereNull('resolved_at')
|
||||||
|
->exists())->toBeTrue()
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders changed-since-review when the current concern fingerprint no longer matches the stored review', function (): void {
|
||||||
|
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Changed Tenant');
|
||||||
|
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||||
|
$this->seedPortfolioTriageReview(
|
||||||
|
$tenant,
|
||||||
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||||
|
TenantTriageReview::STATE_REVIEWED,
|
||||||
|
$user,
|
||||||
|
changedFingerprint: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
tenantDashboardArrivalWidget($user, $tenant, [
|
||||||
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
||||||
|
'tenantRouteKey' => (string) $tenant->external_id,
|
||||||
|
'workspaceId' => (int) $tenant->workspace_id,
|
||||||
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||||
|
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
||||||
|
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||||
|
'returnFilters' => $this->portfolioReturnFilters(
|
||||||
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->assertSee('Changed since review')
|
||||||
|
->assertSee($user->name);
|
||||||
|
});
|
||||||
|
|||||||
@ -16,6 +16,19 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
describe('Tenant diagnostics repairs', function () {
|
describe('Tenant diagnostics repairs', function () {
|
||||||
|
it('hides repair actions when no defect is present', function () {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantDiagnostics::class)
|
||||||
|
->assertSee('All good')
|
||||||
|
->assertActionHidden('bootstrapOwner')
|
||||||
|
->assertActionHidden('mergeDuplicateMemberships');
|
||||||
|
});
|
||||||
|
|
||||||
it('allows an authorized member to bootstrap an owner when a tenant has no owners', function () {
|
it('allows an authorized member to bootstrap an owner when a tenant has no owners', function () {
|
||||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user