Compare commits
9 Commits
160-operat
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 807d574d31 | |||
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d |
20
.github/agents/copilot-instructions.md
vendored
20
.github/agents/copilot-instructions.md
vendored
@ -102,6 +102,20 @@ ## Active Technologies
|
|||||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||||
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||||
|
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||||
|
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||||
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||||
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -121,8 +135,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
|
- 168-tenant-governance-aggregate-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167
|
||||||
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,19 +1,34 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.11.0 → 1.12.0
|
- Version change: 1.14.0 -> 2.0.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- None
|
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract
|
||||||
|
- Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-001)
|
||||||
|
- Operator-facing UI Naming Standards (UI-NAMING-001) -> Operator-Facing UI/UX Constitution v1 / Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||||
|
- Operator Surface Principles (OPSURF-001) -> Operator-Facing UI/UX Constitution v1 / Operator Surface Principles (OPSURF-001)
|
||||||
|
- Spec Scope Fields (SCOPE-002) -> Operator-Facing UI/UX Constitution v1 / Spec Scope Fields (SCOPE-002)
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Operator Surface Principles (OPSURF-001)
|
- Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||||
|
- Surface Taxonomy (UI-SURF-001)
|
||||||
|
- Hard Rules (UI-HARD-001)
|
||||||
|
- Exception Model (UI-EX-001)
|
||||||
|
- Enforcement Model (UI-REVIEW-001)
|
||||||
|
- Immediate Retrofit Priorities
|
||||||
|
- Appendix A - One-page Condensed Constitution
|
||||||
|
- Appendix B - Feature Review Checklist
|
||||||
|
- Appendix C - Red Flags for Future PRs
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- ✅ docs/product/principles.md
|
- ✅ docs/product/principles.md
|
||||||
- ✅ docs/product/standards/README.md
|
- ✅ docs/product/standards/README.md
|
||||||
- ✅ docs/HANDOVER.md
|
- ✅ docs/HANDOVER.md
|
||||||
|
- Commands checked:
|
||||||
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
- None.
|
- None.
|
||||||
-->
|
-->
|
||||||
@ -42,6 +57,73 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Proportionality First (PROP-001)
|
||||||
|
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||||
|
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||||
|
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||||
|
|
||||||
|
### No Premature Abstraction (ABSTR-001)
|
||||||
|
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||||
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
|
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||||
|
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||||
|
|
||||||
|
### No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||||
|
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||||
|
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||||
|
|
||||||
|
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||||
|
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||||
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
- The burden of proof is always on the broader abstraction.
|
||||||
|
|
||||||
|
### One Truth, Few Layers (LAYER-001)
|
||||||
|
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||||
|
- Prefer one canonical truth with thin adapters.
|
||||||
|
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||||
|
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||||
|
|
||||||
|
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||||
|
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||||
|
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||||
|
|
||||||
|
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||||
|
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||||
|
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||||
|
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||||
|
|
||||||
|
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||||
|
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||||
|
|
||||||
|
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||||
|
- That review MUST answer:
|
||||||
|
1. What current operator problem does this solve?
|
||||||
|
2. Why is existing structure insufficient?
|
||||||
|
3. Why is this the narrowest correct implementation?
|
||||||
|
4. What ownership cost does this create?
|
||||||
|
5. What alternative was intentionally rejected?
|
||||||
|
6. Is this current-release truth or future-release preparation?
|
||||||
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### Workspace Isolation is Non-negotiable
|
### Workspace Isolation is Non-negotiable
|
||||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
@ -235,98 +317,266 @@ ### Scheduled/system runs (OPS-UX-SYS-001)
|
|||||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||||
|
|
||||||
### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||||
|
|
||||||
For every new or modified Filament Resource / RelationManager / Page:
|
Purpose and scope
|
||||||
|
- 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 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.
|
||||||
|
|
||||||
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
|
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||||
|
|
||||||
|
##### CRUD / List-first Resource
|
||||||
|
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||||
|
- Primary behavior: Browse -> Open -> Decide / Mutate.
|
||||||
|
- Primary model: one-click inspect/open. Full-row click is the default; identifier click is allowed only when full-row click conflicts with another dominant row mechanism.
|
||||||
|
- Secondary actions: at most one inline non-destructive shortcut; everything else belongs in overflow.
|
||||||
|
- Destructive actions: never inline beside inspect; only in overflow or the detail header; confirmation is mandatory.
|
||||||
|
- Explicit View/Inspect: forbidden when row click or identifier click already opens the same destination.
|
||||||
|
|
||||||
|
##### Queue / Review Surface
|
||||||
|
- Purpose: triage items, inspect them in context, decide, and continue working through the queue.
|
||||||
|
- Primary behavior: Inspect in context -> Decide -> Continue.
|
||||||
|
- Primary model: explicit Inspect using a slide-over, inline detail pane, or same-page inspect.
|
||||||
|
- Secondary actions: only queue-relevant actions belong in the row.
|
||||||
|
- Destructive actions: inline is allowed only when the destructive decision is part of the real queue work; irreversibility or high risk still requires confirmation.
|
||||||
|
- Row click: forbidden by default.
|
||||||
|
- Explicit View/Inspect: required unless the detail is already visible inline.
|
||||||
|
|
||||||
|
##### History / Audit Surface
|
||||||
|
- Purpose: inspect immutable history, events, and evidence without losing chronology.
|
||||||
|
- Primary behavior: Inspect event -> Follow trace -> Return to history context.
|
||||||
|
- Primary model: explicit Inspect, preferably in a slide-over or same-page detail.
|
||||||
|
- Secondary actions: related navigation only.
|
||||||
|
- Destructive actions: normally none.
|
||||||
|
- Row click: forbidden.
|
||||||
|
- Explicit View/Inspect: required.
|
||||||
|
|
||||||
|
##### Config-lite Resource
|
||||||
|
- Purpose: manage small, low-cardinality configuration where edit is effectively the detail surface.
|
||||||
|
- Primary behavior: Open config -> Adjust.
|
||||||
|
- Primary model: edit-as-inspect.
|
||||||
|
- Secondary actions: minimal and usually limited to Edit or overflow.
|
||||||
|
- Destructive actions: overflow or detail header only.
|
||||||
|
- Row click: allowed when it opens Edit directly and no separate View surface exists.
|
||||||
|
- Explicit View/Inspect: forbidden.
|
||||||
|
|
||||||
|
##### Read-only Registry / Report Surface
|
||||||
|
- Purpose: inspect, compare, reference, and export immutable or mostly read-only artifacts.
|
||||||
|
- Primary behavior: Scan -> Open detail -> Reference / Export.
|
||||||
|
- Primary model: row click or identifier click to detail.
|
||||||
|
- Secondary actions: optional single inline non-destructive shortcut when it serves the operator flow.
|
||||||
|
- Destructive actions: normally none; if they exist they belong in detail only.
|
||||||
|
- Explicit View/Inspect: forbidden when a functional one-click open already exists.
|
||||||
|
|
||||||
|
##### Detail-first Operational Surface
|
||||||
|
- Purpose: fully understand one operational record, including state, truth, context, and next steps.
|
||||||
|
- Primary behavior: Read -> Understand -> Act / Navigate.
|
||||||
|
- Primary model: dedicated detail page or dedicated operational page.
|
||||||
|
- Secondary actions: header actions and related-link groups.
|
||||||
|
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||||
|
- Row click and explicit View/Inspect: not applicable.
|
||||||
|
|
||||||
|
#### Hard Rules (UI-HARD-001)
|
||||||
|
|
||||||
|
##### Primary inspect model
|
||||||
|
- Every list surface MUST expose exactly one primary inspect/open model.
|
||||||
|
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
||||||
|
- CRUD / List-first and Read-only Registry / Report surfaces MUST provide an obvious one-click open path.
|
||||||
|
- Queue / Review and History / Audit surfaces MUST use explicit Inspect rather than row-click navigation.
|
||||||
|
|
||||||
|
##### Row-click semantics
|
||||||
|
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
|
||||||
|
- Identifier-only click is allowed only when full-row click would conflict with another dominant row behavior such as selection-heavy interaction, expand/collapse, drag/sort, or another primary row mechanism.
|
||||||
|
- When row click is enabled, the row MUST feel consistent. Silent split behavior inside the same row is forbidden.
|
||||||
|
- Edit-as-inspect is allowed only for Config-lite resources.
|
||||||
|
|
||||||
|
##### View and Inspect actions
|
||||||
|
- Explicit View MUST NOT exist when the same destination is already opened through row click or identifier click.
|
||||||
|
- Explicit Inspect is the default only for Queue / Review, History / Audit, and explicitly catalogued exceptions.
|
||||||
|
- View and Inspect MUST NOT be treated as interchangeable labels. If the interaction preserves context and behaves unlike ordinary navigation, it is Inspect, not View.
|
||||||
|
|
||||||
|
##### Action hierarchy
|
||||||
|
- Every surface MUST distinguish between the primary inspect/open action, secondary safe actions, destructive actions, and long-running workflow launches.
|
||||||
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed the primary open interaction plus one inline safe shortcut.
|
||||||
|
- All other secondary actions MUST move to overflow.
|
||||||
|
- Long-running workflow launches such as sync, compare, verify, generate, consent, setup, or retry SHOULD live in list headers or detail headers rather than in every row.
|
||||||
|
|
||||||
|
##### Destructive actions
|
||||||
|
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces.
|
||||||
|
- Destructive actions MUST live in overflow or the detail header.
|
||||||
|
- Destructive actions MUST use confirmation.
|
||||||
|
- High-risk or high-volume destructive bulk actions SHOULD use typed confirmation.
|
||||||
|
- The Queue Decision exception applies only when the destructive decision is part of the actual queue work.
|
||||||
|
|
||||||
|
##### Overflow and More
|
||||||
|
- Overflow actions MUST follow one product-wide pattern per surface class.
|
||||||
|
- Mixed labeled-overflow versus icon-only overflow patterns inside the same surface class are forbidden unless an approved exception documents why.
|
||||||
|
- Empty `ActionGroup` and empty `BulkActionGroup` are forbidden.
|
||||||
|
- Placeholder UI added only to satisfy a contract or slot is forbidden.
|
||||||
|
|
||||||
|
##### Bulk actions
|
||||||
|
- Bulk actions are allowed only when they are safe enough, materially faster than row-by-row execution, and genuinely fit the surface.
|
||||||
|
- A surface with no real bulk need MUST NOT render bulk UI.
|
||||||
|
- Bulk destructive actions follow the same protection rules as row destructive actions, with stricter confirmation and review expectations.
|
||||||
|
|
||||||
|
##### Row label length and action budget
|
||||||
|
- Inline row action labels MUST stay short and SHOULD be one or two words.
|
||||||
|
- Long workflow labels belong in overflow, headers, or detail surfaces.
|
||||||
|
- Standard list rows MUST NOT become control centers for onboarding recovery, provider management, consent flows, RBAC setup, diagnostics, and destructive lifecycle actions all at once.
|
||||||
|
|
||||||
|
##### Scope and context semantics
|
||||||
|
- Scope chips, tenant pills, and similar context signals MUST correspond to real scoping behavior.
|
||||||
|
- A scope signal MUST NOT be shown when it neither scopes the displayed data nor materially changes the action targets.
|
||||||
|
- Remembered context is allowed only when labeled clearly as reference context rather than active scope.
|
||||||
|
- Cross-panel navigation MUST NOT imply that the operator remains inside the same logical scope when that is not true.
|
||||||
|
|
||||||
|
##### Canonical navigation and terminology
|
||||||
|
- Every domain object MUST have one canonical collection noun and one canonical singular noun.
|
||||||
|
- The same domain object MUST NOT use competing primary nouns across shells.
|
||||||
|
- The Operations domain MUST use one canonical collection noun. Parallel primary nouns such as Runs beside Operations are forbidden.
|
||||||
|
- Cross-panel navigation is allowed only when it lands on a canonical surface, uses stable nouns, and keeps back navigation clear.
|
||||||
|
|
||||||
|
##### Visibility of critical operational truth
|
||||||
|
- Critical operational truth MUST be visible by default.
|
||||||
|
- It MUST NOT be hidden only in default-off columns, tooltips, helper text, overflow menus, or detail pages when list decisions depend on it.
|
||||||
|
- Lifecycle truth, operability truth, health truth, execution outcome, trust/confidence, and next action MUST remain separate semantic dimensions.
|
||||||
|
- One badge, column, or label MUST NOT collapse multiple truth dimensions into a generic status.
|
||||||
|
|
||||||
|
##### Row density and scanability
|
||||||
|
- Standard CRUD lists MUST remain scanable.
|
||||||
|
- Outside Queue / Review and History / Audit exceptions, each row MAY contain at most one multi-line explanatory column and at most one prose-heavy explanatory context.
|
||||||
|
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose.
|
||||||
|
- Next-step prose belongs in detail, inspect, or queue surfaces, not in ordinary CRUD rows.
|
||||||
|
|
||||||
|
##### Custom abstractions
|
||||||
|
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior.
|
||||||
|
- Contract systems MUST NOT force placeholder UI.
|
||||||
|
- Behavior matters more than declaration. If declared conformance and rendered behavior differ, the surface is non-conformant.
|
||||||
|
- A feature MUST NOT ship when its implemented interaction semantics contradict its declared surface type.
|
||||||
|
|
||||||
|
#### Exception Model (UI-EX-001)
|
||||||
|
|
||||||
|
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
||||||
|
|
||||||
|
##### Queue Decision Exception
|
||||||
|
- Allowed when per-item decision-making is the real queue work.
|
||||||
|
- Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
|
||||||
|
|
||||||
|
##### History In-place Inspect Exception
|
||||||
|
- Allowed when leaving the page would break chronology or traceability.
|
||||||
|
- Guardrails: explicit Inspect is mandatory; row click is forbidden; generic mutation rails are forbidden.
|
||||||
|
|
||||||
|
##### Config-lite Edit-as-Inspect Exception
|
||||||
|
- Allowed when a separate View surface would add no value.
|
||||||
|
- Guardrails: no parallel View surface; no high-risk destructive flow as the default entry point.
|
||||||
|
|
||||||
|
##### Read-only Shortcut Exception
|
||||||
|
- Allowed for exactly one dominant non-destructive shortcut.
|
||||||
|
- Guardrails: inspect/open remains dominant; only one shortcut exists; the shortcut does not compete with the primary open path.
|
||||||
|
|
||||||
|
##### Cross-panel Canonical Route Exception
|
||||||
|
- Allowed when only one canonical surface makes sense.
|
||||||
|
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful.
|
||||||
|
|
||||||
|
#### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
For every new or modified Filament Resource, RelationManager, or Page:
|
||||||
|
|
||||||
Required surfaces
|
Required surfaces
|
||||||
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
- List/Table MUST define Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||||
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
|
- Every table MUST provide a record inspection affordance that matches its surface type.
|
||||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
||||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
- CRUD / List-first, Config-lite, and Read-only Registry surfaces MUST NOT render a redundant View action when the same destination is already available through row click or identifier click.
|
||||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
- Queue / Review and History / Audit surfaces MAY use a lone explicit Inspect action because context-preserving inspect is the primary interaction.
|
||||||
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
- View/Detail MUST define header actions and MUST keep destructive actions grouped and confirmed.
|
||||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
- View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
|
||||||
|
- Create/Edit MUST provide consistent Save and Cancel UX.
|
||||||
|
|
||||||
Grouping & safety
|
Grouping and safety
|
||||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
||||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
- Queue / Review rows MAY expose inline decision actions only when allowed by UI-EX-001.
|
||||||
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
- Everything else MUST move to `ActionGroup::make()` or the detail header.
|
||||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
- Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
|
||||||
|
- Empty `ActionGroup` and `BulkActionGroup` are forbidden.
|
||||||
|
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
|
||||||
- Relevant mutations MUST write an audit log entry.
|
- Relevant mutations MUST write an audit log entry.
|
||||||
|
|
||||||
RBAC enforcement
|
RBAC enforcement
|
||||||
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
||||||
- Member without capability: UI visible but disabled with tooltip; server-side MUST abort(403).
|
- Members without capability MAY see disabled actions with helper text, but server-side execution MUST still abort(403).
|
||||||
- Central enforcement helpers (tenant/workspace UI enforcement) MUST be used for gating.
|
- Central tenant and workspace UI enforcement helpers MUST be used for gating.
|
||||||
|
|
||||||
Spec / DoD gates
|
Behavior over declaration
|
||||||
- Every spec MUST include a “UI Action Matrix”.
|
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||||
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
- 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.
|
||||||
|
|
||||||
### Filament UI — Layout & Information Architecture Standards (UX-001)
|
#### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||||
|
|
||||||
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
|
Goal: operator-facing Filament screens MUST feel enterprise-grade, legible, and decisive.
|
||||||
|
|
||||||
Page layout
|
Page layout
|
||||||
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
||||||
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
|
- All fields MUST live inside Sections or Cards. Naked root-level inputs are forbidden.
|
||||||
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
|
- Main content carries domain definition and working content. Aside carries status and meta such as scope, owner, timestamps, or version labels.
|
||||||
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
|
- Related data MUST render as separate sections, tabs, or subordinate surfaces rather than as one long unstructured form or detail page.
|
||||||
|
|
||||||
View pages
|
View pages
|
||||||
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
|
- View/Detail MUST be a read-only surface built with Infolists or an equivalent read-first structure, not disabled edit forms.
|
||||||
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
|
- Status-like values MUST render via BADGE-001 semantics.
|
||||||
- Long text MUST render as readable prose (not textarea styling).
|
- Long text MUST read like prose, not like disabled textarea output.
|
||||||
|
|
||||||
Empty states
|
Empty states
|
||||||
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
|
- Empty lists and tables MUST show a specific title, a one-sentence explanation, and exactly one primary CTA.
|
||||||
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
|
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||||
|
|
||||||
Actions & flows
|
Actions and flows
|
||||||
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
|
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups.
|
||||||
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
|
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||||
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
|
- Destructive actions remain non-primary and confirmed.
|
||||||
|
|
||||||
Table work-surface defaults
|
Table defaults
|
||||||
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
|
- Tables SHOULD provide search when the dataset can grow, a meaningful default sort, and filters for core dimensions.
|
||||||
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
|
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
||||||
|
- Critical operational truth that informs list decisions MUST be default-visible.
|
||||||
|
|
||||||
Enforcement
|
Enforcement
|
||||||
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
- 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 explicit exemption exists with a documented rationale in the spec/PR.
|
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||||
|
|
||||||
### Operator-facing UI Naming Standards (UI-NAMING-001)
|
#### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||||
|
|
||||||
Goal: operator-facing actions, run labels, notifications, audit prose, and related UI copy MUST use consistent,
|
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
|
||||||
enterprise-grade product language.
|
|
||||||
|
|
||||||
Naming model
|
Naming model
|
||||||
- Operator-facing copy MUST distinguish four layers: Scope, Source/Domain, Operation, and Target Object.
|
- Operator-facing copy MUST distinguish Scope, Source/Domain, Operation, and Target Object.
|
||||||
- Scope terms (`Workspace`, `Tenant`) describe execution context and MUST NOT be used as the primary action label unless they are the actual target object.
|
- Scope terms such as Workspace and Tenant describe execution context and MUST NOT become the primary action label unless they are the actual target object.
|
||||||
- Source/Domain terms (`Intune`, `Entra`, `Teams`, future providers) are secondary and MUST NOT lead the primary label unless the current screen presents competing sources that need explicit disambiguation.
|
- Source/domain terms such as Intune or Entra are secondary and lead only when same-screen disambiguation genuinely requires them.
|
||||||
|
|
||||||
Primary action labels
|
Primary labels
|
||||||
- Primary buttons, header actions, and menu actions MUST use `Verb + Object`.
|
- Primary buttons, header actions, and menu actions MUST use Verb + Object.
|
||||||
- Preferred examples: `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, `Export review pack`.
|
- Preferred examples are `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, and `Export review pack`.
|
||||||
- Forbidden examples: `Sync from tenant`, `Backup tenant`, `Compare tenant`, `Sync from Intune`, `Run tenant sync now`, `Start inventory refresh from provider`.
|
- Implementation-first labels such as `Sync from tenant`, `Sync from Intune`, `Run tenant sync now`, or `Start inventory refresh from provider` are forbidden.
|
||||||
|
|
||||||
Domain vocabulary
|
Canonical nouns and routes
|
||||||
- Operator-facing copy MUST prefer product-domain objects such as `policies`, `groups`, `baseline`, `findings`, `review pack`, `alerts`, and `operations`.
|
- Every domain object MUST keep one canonical collection noun and one canonical singular noun.
|
||||||
- Primary operator-facing copy MUST NOT use implementation-first terms such as `provider`, `gateway`, `resolver`, `collector`, `contract registry`, or `job dispatch`.
|
- Cross-shell or cross-panel navigation MUST preserve the same noun.
|
||||||
- Source/domain details MAY appear in modal descriptions, helper text, run metadata, audit metadata, and notifications when needed for precision.
|
- Operations is the canonical collection noun for run records. Runs MUST NOT appear as a competing primary collection noun.
|
||||||
|
|
||||||
Run, notification, and audit semantics
|
Run, notification, and audit semantics
|
||||||
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD be concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD remain concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||||
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short, e.g. `Policy sync queued`, `Policy sync completed`, `Policy sync failed`, `Baseline compare detected drift`.
|
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short.
|
||||||
- Audit prose MUST use the same operator-facing language, e.g. `{actor} queued policy sync`, `{actor} captured baseline`, `{actor} reopened finding`.
|
- Audit prose MUST use the same operator-facing language as the initiating action.
|
||||||
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, and audit prose.
|
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, audit prose, and related navigation.
|
||||||
|
|
||||||
Verb standard
|
Verb standard
|
||||||
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||||
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided for operator-facing copy unless there is a deliberate domain reason.
|
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided unless the domain specifically requires them.
|
||||||
- `Run` MAY be used only when the object is itself run-like, such as `Run review` or `Run compare`; it MUST NOT be the generic fallback verb for all operations.
|
- `Run` MAY be used only when the object is itself run-like, such as `Run review`; it MUST NOT become the fallback verb for everything.
|
||||||
|
|
||||||
Current binding decision
|
Current binding decision
|
||||||
- The Policies screen primary action MUST be `Sync policies`.
|
- The Policies screen primary action MUST be `Sync policies`.
|
||||||
@ -335,75 +585,125 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
|||||||
- The visible run label for that action MUST be `Policy sync`.
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||||
|
|
||||||
### Operator Surface Principles (OPSURF-001)
|
#### Operator Surface Principles (OPSURF-001)
|
||||||
|
|
||||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
Goal: operator-facing surfaces MUST optimize for the operator's working question instead of raw implementation visibility.
|
||||||
|
|
||||||
Operator-first default surfaces
|
Operator-first default surfaces
|
||||||
- `/admin` is operator-first.
|
- `/admin` is operator-first.
|
||||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
- Default-visible content MUST use operator language, clear scope, and actionable status communication.
|
||||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||||
|
|
||||||
Progressive disclosure for diagnostics
|
Progressive disclosure for diagnostics
|
||||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
|
|
||||||
Distinct status dimensions
|
Distinct truth dimensions
|
||||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
- execution outcome
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
- data completeness
|
|
||||||
- governance result
|
|
||||||
- lifecycle or readiness state
|
|
||||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
|
||||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
|
||||||
|
|
||||||
Explicit mutation scope
|
Explicit mutation scope
|
||||||
- Every action that changes state MUST communicate before execution whether it affects:
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
- TenantPilot only
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
- the Microsoft tenant
|
|
||||||
- simulation only
|
|
||||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
|
||||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
|
||||||
|
|
||||||
Safe execution for dangerous actions
|
Safe execution
|
||||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
- Dangerous actions MUST follow a consistent safety flow: configuration, safety checks or simulation, preview, hard confirmation where required, then execution.
|
||||||
- configuration
|
- One-click high-blast-radius actions are forbidden unless an approved exception documents replacement safeguards.
|
||||||
- safety checks or simulation
|
|
||||||
- preview
|
|
||||||
- hard confirmation where required
|
|
||||||
- execute
|
|
||||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
|
||||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
|
||||||
|
|
||||||
Explicit workspace and tenant context
|
Explicit workspace and tenant context
|
||||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
- Workspace and tenant context MUST remain explicit in navigation, action copy, and page semantics.
|
||||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
- Tenant surfaces MUST NOT silently expose workspace-wide actions.
|
||||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
- Canonical workspace views that operate on tenant-owned records MUST make both workspace and tenant context legible before the operator acts.
|
||||||
|
|
||||||
|
Critical truth visibility and scanability
|
||||||
|
- Critical operational truth MUST be default-visible wherever the list or summary surface is used to prepare decisions.
|
||||||
|
- Standard CRUD surfaces MUST preserve scanability and MUST avoid collapsing multiple truth dimensions into one generic badge or one prose-heavy row.
|
||||||
|
|
||||||
Page contract requirement
|
Page contract requirement
|
||||||
- Every new or materially refactored operator-facing page MUST define:
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
- primary persona
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
- surface type
|
|
||||||
- primary operator question
|
|
||||||
- default-visible information
|
|
||||||
- diagnostics-only information
|
|
||||||
- status dimensions used
|
|
||||||
- mutation scope
|
|
||||||
- primary actions
|
|
||||||
- dangerous actions
|
|
||||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
|
||||||
|
|
||||||
Spec Scope Fields (SCOPE-002)
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
- Every feature spec MUST declare:
|
- Every feature spec MUST declare Scope, Primary Routes, Data Ownership, and RBAC requirements.
|
||||||
- Scope: workspace | tenant | canonical-view
|
- Canonical-view specs MUST define the default filter behavior when tenant context is active and the entitlement checks that prevent cross-tenant leakage.
|
||||||
- Primary Routes
|
|
||||||
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
|
#### Enforcement Model (UI-REVIEW-001)
|
||||||
- RBAC: membership requirements + capability requirements
|
|
||||||
- For canonical-view specs, the spec MUST define:
|
Spec review requirements
|
||||||
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
|
- 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.
|
||||||
- Explicit entitlement checks that prevent cross-tenant leakage
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
|
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-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||||
|
- Wave 1 is done only when each surface has exactly one primary inspect model, destructive actions are protected, and placeholder groups are gone.
|
||||||
|
|
||||||
|
Wave 2 - Scope, nouns, and truth
|
||||||
|
- Then fix scope and context leaks, stabilize canonical nouns, make cross-panel transitions explicit, move critical operational truth to default-visible regions, and reduce prose-heavy dense rows.
|
||||||
|
|
||||||
|
Wave 3 - Enforcement
|
||||||
|
- Then move the constitution into repo enforcement, require the PR checklist, anchor guard tests, and trim old declaration-only action-surface checks until behavior is the governing truth.
|
||||||
|
|
||||||
|
#### Appendix A - One-page Condensed Constitution
|
||||||
|
|
||||||
|
- Every admin surface has one surface type.
|
||||||
|
- Every list has exactly one primary inspect/open model.
|
||||||
|
- CRUD and Registry surfaces use one-click open.
|
||||||
|
- Queue and Audit surfaces use explicit Inspect.
|
||||||
|
- Edit-as-inspect exists only for Config-lite resources.
|
||||||
|
- Standard lists expose at most one inline safe shortcut.
|
||||||
|
- Destructive actions never sit openly beside inspect on standard lists.
|
||||||
|
- Overflow is standardized per surface class and is never empty.
|
||||||
|
- Bulk exists only when it is genuinely useful.
|
||||||
|
- Scope chips must be truthful.
|
||||||
|
- Domain nouns are canonical and stable.
|
||||||
|
- Critical operational truth is default-visible.
|
||||||
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
|
- Standard lists stay scanable.
|
||||||
|
- Exceptions are catalogued, justified, and tested.
|
||||||
|
- Features with ambiguous interaction semantics do not ship.
|
||||||
|
|
||||||
|
#### Appendix B - Feature Review Checklist
|
||||||
|
|
||||||
|
- Surface type is declared.
|
||||||
|
- Primary inspect/open model is defined.
|
||||||
|
- Row-click rule is decided.
|
||||||
|
- View/Inspect is correctly present or correctly forbidden.
|
||||||
|
- Edit-as-inspect is used only when allowed.
|
||||||
|
- Secondary actions are grouped correctly.
|
||||||
|
- Destructive actions are placed correctly.
|
||||||
|
- Overflow is not empty.
|
||||||
|
- Bulk is justified.
|
||||||
|
- Inline labels are short.
|
||||||
|
- Scope signals are truthful.
|
||||||
|
- Canonical nouns stay consistent.
|
||||||
|
- Critical truth is visible.
|
||||||
|
- Scanability is preserved.
|
||||||
|
- Exceptions are documented and tested.
|
||||||
|
|
||||||
|
#### Appendix C - Red Flags for Future PRs
|
||||||
|
|
||||||
|
- Row click and View open the same destination.
|
||||||
|
- A row becomes a control center.
|
||||||
|
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||||
|
- More menus or bulk menus are empty.
|
||||||
|
- Scope chips have no real scope effect.
|
||||||
|
- Runs and Operations are used as competing primary collection nouns.
|
||||||
|
- Long workflow labels live in dense tables.
|
||||||
|
- Edit is used as default inspect even though a true View surface exists.
|
||||||
|
- Queue surfaces throw the operator out of context through row click.
|
||||||
|
- Critical health or operability truth is hidden by default.
|
||||||
|
- A contract claims conformance while the rendered UI behaves differently.
|
||||||
|
|
||||||
### Data Minimization & Safe Logging
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
@ -416,6 +716,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
@ -437,9 +770,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Amendment Procedure
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
@ -451,4 +787,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**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28
|
||||||
|
|||||||
@ -48,7 +48,20 @@ ## Constitution Check
|
|||||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- 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
|
||||||
|
- 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 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 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 placeholder ban (UI-HARD-001): empty `ActionGroup` / `BulkActionGroup` placeholders and declaration-only UI conformance are forbidden
|
||||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
@ -56,7 +69,7 @@ ## Constitution Check
|
|||||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
- 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 record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), 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; 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; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@ -121,9 +134,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -17,6 +17,15 @@ ## 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]
|
||||||
|
|
||||||
|
## 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,
|
||||||
|
fill out one row per affected surface.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
## 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.
|
||||||
@ -25,6 +34,27 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 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 | 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)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -102,6 +132,16 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||||
@ -127,6 +167,12 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
- the target object,
|
- the target object,
|
||||||
@ -135,6 +181,19 @@ ## 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:
|
||||||
|
- the chosen surface type and why it is the correct classification,
|
||||||
|
- the one and only primary inspect/open model,
|
||||||
|
- whether row click is required, allowed, or forbidden,
|
||||||
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where secondary actions live,
|
||||||
|
- where destructive actions live,
|
||||||
|
- the canonical collection route and canonical detail route,
|
||||||
|
- 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,
|
||||||
|
- which critical operational truth is visible by default,
|
||||||
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
**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,
|
||||||
@ -144,9 +203,19 @@ ## Requirements *(mandatory)*
|
|||||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
- and the page contract for each new or materially refactored operator-facing page.
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||||
|
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
@ -175,7 +244,7 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
|||||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
|||||||
@ -39,20 +39,32 @@ # 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:
|
||||||
|
- 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,
|
||||||
- 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,
|
||||||
- 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 scope signals truthful and ensuring critical operational truth is visible by default,
|
||||||
|
- 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,
|
||||||
- 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 a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
- 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,
|
||||||
|
- moving additional secondary actions into More or the detail header,
|
||||||
|
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
|
- 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,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- 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,
|
||||||
- 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.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
@ -64,6 +76,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- 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),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -210,6 +229,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
|
|||||||
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PurgeLegacyBaselineGapRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||||
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||||
|
{--workspace=* : Limit cleanup to workspace ids}
|
||||||
|
{--limit=500 : Maximum candidate runs to inspect}
|
||||||
|
{--force : Actually delete matched legacy runs}';
|
||||||
|
|
||||||
|
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing'])) {
|
||||||
|
$this->error('This cleanup command is limited to local and testing environments.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = $this->normalizedTypes();
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$dryRun = ! (bool) $this->option('force');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($workspaceIds !== []) {
|
||||||
|
$query->whereIn('workspace_id', $workspaceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIds !== []) {
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get();
|
||||||
|
$matched = $candidates
|
||||||
|
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($matched->isEmpty()) {
|
||||||
|
$this->info('No legacy baseline gap runs matched the current filters.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||||
|
$matched
|
||||||
|
->map(fn (OperationRun $run): array => [
|
||||||
|
'Run' => (string) $run->getKey(),
|
||||||
|
'Type' => (string) $run->type,
|
||||||
|
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
||||||
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||||
|
'Legacy signal' => $this->legacySignal($run),
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
||||||
|
$matched->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationRun::query()
|
||||||
|
->whereKey($matched->modelKeys())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizedTypes(): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_unique(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||||
|
(array) $this->option('type'),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
return ['baseline_compare', 'baseline_capture'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$types,
|
||||||
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacySignal(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
||||||
|
$byReason = is_array($byReason) ? $byReason : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return 'legacy_reason_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'legacy_subject_shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,10 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
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;
|
||||||
@ -59,6 +62,8 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?int $duplicateNamePoliciesCount = null;
|
public ?int $duplicateNamePoliciesCount = null;
|
||||||
|
|
||||||
|
public ?int $duplicateNameSubjectsCount = null;
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $findingsCount = null;
|
public ?int $findingsCount = null;
|
||||||
@ -86,9 +91,24 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = null;
|
public ?array $evidenceGapsTopReasons = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $evidenceGapSummary = null;
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>>|null */
|
||||||
|
public ?array $evidenceGapBuckets = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $baselineCompareDiagnostics = null;
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $rbacRoleDefinitionSummary = null;
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $operatorExplanation = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -115,7 +135,11 @@ public function mount(): void
|
|||||||
|
|
||||||
public function refreshStats(): void
|
public function refreshStats(): void
|
||||||
{
|
{
|
||||||
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$aggregate = $tenant instanceof Tenant
|
||||||
|
? $this->governanceAggregate($tenant, $stats)
|
||||||
|
: null;
|
||||||
|
|
||||||
$this->state = $stats->state;
|
$this->state = $stats->state;
|
||||||
$this->message = $stats->message;
|
$this->message = $stats->message;
|
||||||
@ -123,6 +147,7 @@ public function refreshStats(): void
|
|||||||
$this->profileId = $stats->profileId;
|
$this->profileId = $stats->profileId;
|
||||||
$this->snapshotId = $stats->snapshotId;
|
$this->snapshotId = $stats->snapshotId;
|
||||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||||
|
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||||
$this->operationRunId = $stats->operationRunId;
|
$this->operationRunId = $stats->operationRunId;
|
||||||
$this->findingsCount = $stats->findingsCount;
|
$this->findingsCount = $stats->findingsCount;
|
||||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
@ -139,7 +164,18 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||||
|
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||||
|
? $stats->evidenceGapDetails['summary']
|
||||||
|
: null;
|
||||||
|
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||||
|
? $stats->evidenceGapDetails['buckets']
|
||||||
|
: null;
|
||||||
|
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||||
|
? $stats->baselineCompareDiagnostics
|
||||||
|
: null;
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
|
$this->summaryAssessment = $aggregate?->summaryAssessment->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,26 +188,32 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
|
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['count']
|
||||||
|
: (int) ($this->evidenceGapsCount ?? 0);
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||||
|
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||||
|
? (string) $evidenceGapSummary['detail_state']
|
||||||
|
: 'no_gaps';
|
||||||
|
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||||
|
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||||
|
|
||||||
$evidenceGapsSummary = null;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
if ($hasEvidenceGaps) {
|
||||||
$parts = [];
|
$parts = array_map(
|
||||||
|
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
BaselineCompareEvidenceGapDetails::topReasons(
|
||||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||||
continue;
|
5,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
$parts[] = $reason.' ('.((int) $count).')';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -207,12 +249,16 @@ protected function getViewData(): array
|
|||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
|
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||||
|
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||||
|
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,4 +425,15 @@ public function getRunUrl(): ?string
|
|||||||
|
|
||||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||||
|
|
||||||
|
/** @var TenantGovernanceAggregate $aggregate */
|
||||||
|
$aggregate = $resolver->fromStats($tenant, $stats);
|
||||||
|
|
||||||
|
return $aggregate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,10 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -52,6 +56,20 @@ class InventoryCoverage extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
protected string $view = 'filament.pages.inventory-coverage';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
|||||||
@ -43,6 +43,8 @@ class AuditLog extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public ?int $selectedAuditLogId = null;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -89,6 +91,7 @@ public function mount(): void
|
|||||||
|
|
||||||
if ($requestedEventId !== null) {
|
if ($requestedEventId !== null) {
|
||||||
$this->resolveAuditLog($requestedEventId);
|
$this->resolveAuditLog($requestedEventId);
|
||||||
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -174,6 +177,9 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
|
->before(function (AuditLogModel $record): void {
|
||||||
|
$this->selectedAuditLogId = (int) $record->getKey();
|
||||||
|
})
|
||||||
->slideOver()
|
->slideOver()
|
||||||
->stickyModalHeader()
|
->stickyModalHeader()
|
||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
@ -285,6 +291,33 @@ private function resolveAuditLog(int $auditLogId): AuditLogModel
|
|||||||
return $record;
|
return $record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function selectedAuditRecord(): ?AuditLogModel
|
||||||
|
{
|
||||||
|
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function selectedAuditTargetLink(): ?array
|
||||||
|
{
|
||||||
|
$record = $this->selectedAuditRecord();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{label: string, url: string}|null
|
* @return array{label: string, url: string}|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -90,7 +91,7 @@ public function mount(): void
|
|||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -133,4 +134,13 @@ protected function getHeaderActions(): array
|
|||||||
->url(route('admin.evidence.overview')),
|
->url(route('admin.evidence.overview')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forEvidenceSnapshot($snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -281,6 +282,11 @@ public function table(Table $table): Table
|
|||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
TextColumn::make('governance_warning')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (FindingException $record): ?string => $this->governanceWarning($record))
|
||||||
|
->color(fn (FindingException $record): string => $this->governanceWarningColor($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('requester.name')
|
TextColumn::make('requester.name')
|
||||||
->label('Requested by')
|
->label('Requested by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -500,4 +506,34 @@ private function hasActiveQueueFilters(): bool
|
|||||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceWarning(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceWarningColor(FindingException $record): string
|
||||||
|
{
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -50,6 +55,20 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
// Must be non-static
|
// Must be non-static
|
||||||
protected string $view = 'filament.pages.monitoring.operations';
|
protected string $view = 'filament.pages.monitoring.operations';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve scope context and return navigation for the monitoring operations list.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs remain immutable on the monitoring list and intentionally omit bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operation runs exist for the active workspace scope.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -27,6 +30,16 @@ class NoAccess extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.no-access';
|
protected string $view = 'filament.pages.no-access';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* @return array<Action>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -24,6 +25,11 @@
|
|||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -45,6 +51,20 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Canonical tenantless run viewing is a detail-only page and does not render list header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The tenantless run viewer is itself the canonical detail destination for a selected operation run.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Detail viewing does not expose bulk actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a selected run detail instead of a list empty state.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Header keeps scope context, back navigation, refresh, related links, and resumable capture actions when applicable.');
|
||||||
|
}
|
||||||
|
|
||||||
public OperationRun $run;
|
public OperationRun $run;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,8 +72,6 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
*/
|
*/
|
||||||
public ?array $navigationContextPayload = null;
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
@ -107,7 +125,7 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
$related = $this->relatedLinks();
|
||||||
|
|
||||||
$relatedActions = [];
|
$relatedActions = [];
|
||||||
|
|
||||||
@ -170,16 +188,21 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
? array_values(array_filter([
|
||||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
$operatorExplanation->headline,
|
||||||
];
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
|
]))
|
||||||
|
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||||
|
$this->surfaceFailureDetail() ?? 'The queued run was refused before side effects could begin.',
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => implode(' ', $lines),
|
'body' => implode(' ', array_values(array_unique($lines))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,26 +215,24 @@ public function lifecycleBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
$attention = $this->lifecycleAttentionSummary();
|
||||||
|
|
||||||
if ($attention === null) {
|
if ($attention === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
$detail = $this->surfaceFailureDetail() ?? 'Lifecycle truth needs operator review.';
|
||||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
|
||||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
|
||||||
|
|
||||||
return match ($this->run->freshnessState()->value) {
|
return match ($this->run->freshnessState()->value) {
|
||||||
'likely_stale' => [
|
'likely_stale' => [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Likely stale run',
|
'title' => 'Likely stale run',
|
||||||
'body' => $body,
|
'body' => $detail,
|
||||||
],
|
],
|
||||||
'reconciled_failed' => [
|
'reconciled_failed' => [
|
||||||
'tone' => 'rose',
|
'tone' => 'rose',
|
||||||
'title' => 'Automatically reconciled',
|
'title' => 'Automatically reconciled',
|
||||||
'body' => $body,
|
'body' => $detail,
|
||||||
],
|
],
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
@ -281,10 +302,6 @@ public function pollInterval(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->opsUxIsTabHidden === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($this->mountedActions ?? null)) {
|
if (filled($this->mountedActions ?? null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -451,4 +468,51 @@ private function relatedLinksTenant(): ?Tenant
|
|||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
)->allowed ? $tenant : null;
|
)->allowed ? $tenant : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationUxPresenter::governanceOperatorExplanation($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function relatedLinks(bool $fresh = false): array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||||
|
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::lifecycleAttentionSummaryFresh($this->run)
|
||||||
|
: OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function surfaceFailureDetail(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::surfaceFailureDetailFresh($this->run)
|
||||||
|
: OperationUxPresenter::surfaceFailureDetail($this->run);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -118,11 +119,11 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('artifact_truth')
|
TextColumn::make('artifact_truth')
|
||||||
->label('Artifact truth')
|
->label('Artifact truth')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryLabel)
|
||||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
->color(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryBadgeSpec()->color)
|
||||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
->icon(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => $this->reviewTruth($record)->operatorExplanation?->headline ?? $this->reviewTruth($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
@ -138,23 +139,23 @@ public function table(Table $table): Table
|
|||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->label)
|
)->label)
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->color)
|
)->color)
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->icon)
|
)->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->iconColor),
|
)->iconColor),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->operatorExplanation?->nextActionText ?? $this->reviewTruth($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -325,4 +326,13 @@ private function workspace(): ?Workspace
|
|||||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
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\Actions\Action;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
@ -25,6 +28,16 @@ class TenantDiagnostics extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
|
||||||
|
}
|
||||||
|
|
||||||
public bool $missingOwner = false;
|
public bool $missingOwner = false;
|
||||||
|
|
||||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
@ -27,6 +30,16 @@ class TenantRequiredPermissions extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
|
||||||
|
}
|
||||||
|
|
||||||
public string $status = 'missing';
|
public string $status = 'missing';
|
||||||
|
|
||||||
public string $type = 'all';
|
public string $type = 'all';
|
||||||
|
|||||||
@ -22,7 +22,6 @@
|
|||||||
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\ViewAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -322,9 +321,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
ViewAction::make()->label('View'),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No alert deliveries')
|
->emptyStateHeading('No alert deliveries')
|
||||||
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
||||||
|
|||||||
@ -18,8 +18,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TagsInput;
|
use Filament\Forms\Components\TagsInput;
|
||||||
@ -191,9 +189,6 @@ public static function table(Table $table): Table
|
|||||||
->since(),
|
->since(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make()
|
|
||||||
->label('Edit')
|
|
||||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('toggle_enabled')
|
Action::make('toggle_enabled')
|
||||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
@ -253,9 +248,6 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
\Filament\Actions\CreateAction::make()
|
\Filament\Actions\CreateAction::make()
|
||||||
->label('Create target')
|
->label('Create target')
|
||||||
|
|||||||
@ -20,8 +20,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -248,9 +246,6 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make()
|
|
||||||
->label('Edit')
|
|
||||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('toggle_enabled')
|
Action::make('toggle_enabled')
|
||||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
@ -311,9 +306,6 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No alert rules')
|
->emptyStateHeading('No alert rules')
|
||||||
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||||
->emptyStateIcon('heroicon-o-bell');
|
->emptyStateIcon('heroicon-o-bell');
|
||||||
|
|||||||
@ -39,7 +39,6 @@
|
|||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -571,16 +570,12 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
EditAction::make()
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('archive')
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
@ -666,6 +661,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -25,25 +24,12 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static ?string $title = 'Executions';
|
protected static ?string $title = 'Executions';
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $arguments
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
|
||||||
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::mountAction($name, $arguments, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||||
}
|
}
|
||||||
@ -54,6 +40,12 @@ public function table(Table $table): Table
|
|||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
|
->recordUrl(function (OperationRun $record): string {
|
||||||
|
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||||
|
$tenant = Tenant::currentOrFail();
|
||||||
|
|
||||||
|
return OperationRunLinks::view($record, $tenant);
|
||||||
|
})
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Enqueued')
|
->label('Enqueued')
|
||||||
@ -96,18 +88,7 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([])
|
||||||
Actions\Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->url(function (OperationRun $record): string {
|
|
||||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
|
||||||
$tenant = Tenant::currentOrFail();
|
|
||||||
|
|
||||||
return OperationRunLinks::view($record, $tenant);
|
|
||||||
})
|
|
||||||
->openUrlInNewTab(true),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No schedule runs yet')
|
->emptyStateHeading('No schedule runs yet')
|
||||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -21,6 +22,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@ -64,6 +69,16 @@ public function mountAction(string $name, array $arguments = [], array $context
|
|||||||
return parent::mountAction($name, $arguments, $context);
|
return parent::mountAction($name, $arguments, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.');
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$refreshTable = Actions\Action::make('refreshTable')
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
@ -257,6 +272,7 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Item')
|
->label('Item')
|
||||||
@ -358,23 +374,6 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
|
||||||
->url(function (BackupItem $record): ?string {
|
|
||||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
|
||||||
|
|
||||||
if ($record->policy_version_id) {
|
|
||||||
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $record->policy_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
|
||||||
->openUrlInNewTab(true),
|
|
||||||
$removeItem,
|
$removeItem,
|
||||||
])
|
])
|
||||||
->label('More')
|
->label('More')
|
||||||
@ -449,7 +448,39 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
|
|||||||
return $query->whereIn('policy_type', $types);
|
return $query->whereIn('policy_type', $types);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
private function backupItemInspectUrl(BackupItem $record): ?string
|
||||||
|
{
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
$resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record);
|
||||||
|
|
||||||
|
$resolvedRecord = $backupSet->items()
|
||||||
|
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||||
|
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||||
|
->whereKey($resolvedId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof BackupItem) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedRecord->policy_version_id) {
|
||||||
|
return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolvedRecord->policy_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
|
||||||
{
|
{
|
||||||
$recordId = $this->normalizeBackupItemKey($record);
|
$recordId = $this->normalizeBackupItemKey($record);
|
||||||
|
|
||||||
@ -472,7 +503,7 @@ private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet
|
|||||||
/**
|
/**
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array
|
||||||
{
|
{
|
||||||
$requestedIds = collect($recordKeys)
|
$requestedIds = collect($recordKeys)
|
||||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||||
|
|||||||
@ -36,7 +36,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -131,8 +130,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while edit and archive remain grouped under "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 provides capture + edit actions.');
|
||||||
@ -340,6 +339,7 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->recordUrl(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -412,10 +412,6 @@ public static function table(Table $table): Table
|
|||||||
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->icon('heroicon-o-eye'),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('edit')
|
Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
@ -425,9 +421,7 @@ public static function table(Table $table): Table
|
|||||||
self::archiveTableAction($workspace),
|
self::archiveTableAction($workspace),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([])
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No baseline profiles')
|
->emptyStateHeading('No baseline profiles')
|
||||||
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
|
|||||||
@ -179,7 +179,7 @@ public static function table(Table $table): Table
|
|||||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('lifecycle_state')
|
TextColumn::make('lifecycle_state')
|
||||||
->label('Lifecycle')
|
->label('Lifecycle')
|
||||||
@ -203,7 +203,7 @@ public static function table(Table $table): Table
|
|||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil
|
|||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
{
|
{
|
||||||
return match ($query->getConnection()->getDriverName()) {
|
return match ($query->getConnection()->getDriverName()) {
|
||||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
|
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
||||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
|
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
||||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
|
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,9 +385,13 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forBaselineSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forBaselineSnapshot($snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||||
|
|||||||
@ -112,7 +112,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
->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 and Expire snapshot actions.');
|
||||||
}
|
}
|
||||||
@ -257,32 +257,32 @@ public static function table(Table $table): Table
|
|||||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_snapshot')
|
Actions\ActionGroup::make([
|
||||||
->label('View snapshot')
|
UiEnforcement::forTableAction(
|
||||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
Actions\Action::make('expire')
|
||||||
UiEnforcement::forTableAction(
|
->label('Expire snapshot')
|
||||||
Actions\Action::make('expire')
|
->color('danger')
|
||||||
->label('Expire snapshot')
|
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||||
->color('danger')
|
->requiresConfirmation()
|
||||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
->action(function (EvidenceSnapshot $record): void {
|
||||||
->requiresConfirmation()
|
$user = auth()->user();
|
||||||
->action(function (EvidenceSnapshot $record): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
Notification::make()->success()->title('Snapshot expired')->send();
|
||||||
}),
|
}),
|
||||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No evidence snapshots yet')
|
->emptyStateHeading('No evidence snapshots yet')
|
||||||
@ -612,9 +612,13 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|||||||
return $label === 'Unknown' ? null : $label;
|
return $label === 'Unknown' ? null : $label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($record)
|
||||||
|
: $presenter->forEvidenceSnapshot($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
@ -646,6 +650,7 @@ public static function executeGeneration(array $data): void
|
|||||||
user: $user,
|
user: $user,
|
||||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||||
);
|
);
|
||||||
|
static::truthEnvelope($snapshot->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $snapshot->wasRecentlyCreated) {
|
if (! $snapshot->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -122,6 +124,36 @@ public static function getEloquentQuery(): Builder
|
|||||||
->with(static::relationshipsForView());
|
->with(static::relationshipsForView());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
|
||||||
|
*/
|
||||||
|
public static function exceptionStatsForCurrentTenant(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = FindingException::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->selectRaw('count(*) as total')
|
||||||
|
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
||||||
|
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => (int) ($counts?->active ?? 0),
|
||||||
|
'expiring' => (int) ($counts?->expiring ?? 0),
|
||||||
|
'expired' => (int) ($counts?->expired ?? 0),
|
||||||
|
'pending' => (int) ($counts?->pending ?? 0),
|
||||||
|
'total' => (int) ($counts?->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
{
|
{
|
||||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||||||
@ -238,10 +270,21 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('finding.severity')
|
||||||
|
->label('Severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('finding_summary')
|
Tables\Columns\TextColumn::make('finding_summary')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->wrap()
|
||||||
|
->limit(60),
|
||||||
Tables\Columns\TextColumn::make('governance_warning')
|
Tables\Columns\TextColumn::make('governance_warning')
|
||||||
->label('Governance warning')
|
->label('Governance warning')
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||||
@ -257,7 +300,14 @@ public static function table(Table $table): Table
|
|||||||
->label('Review due')
|
->label('Review due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
->sortable(),
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
|
||||||
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
|
->label('Expires')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
|
||||||
Tables\Columns\TextColumn::make('requested_at')
|
Tables\Columns\TextColumn::make('requested_at')
|
||||||
->label('Requested')
|
->label('Requested')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
@ -269,6 +319,12 @@ public static function table(Table $table): Table
|
|||||||
SelectFilter::make('current_validity_state')
|
SelectFilter::make('current_validity_state')
|
||||||
->label('Validity')
|
->label('Validity')
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||||
|
SelectFilter::make('finding_severity')
|
||||||
|
->label('Finding severity')
|
||||||
|
->options(FilterOptionCatalog::findingSeverities())
|
||||||
|
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
|
||||||
|
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
|
||||||
|
: $query),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
@ -441,15 +497,62 @@ private static function tenantMemberOptions(): array
|
|||||||
|
|
||||||
private static function findingSummary(FindingException $record): string
|
private static function findingSummary(FindingException $record): string
|
||||||
{
|
{
|
||||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
$finding = $record->finding;
|
||||||
|
|
||||||
if (is_string($summary) && trim($summary) !== '') {
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
return trim($summary);
|
return 'Finding #'.$record->finding_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $finding->resolvedSubjectDisplayName();
|
||||||
|
$findingType = $finding->finding_type;
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (is_string($displayName) && trim($displayName) !== '') {
|
||||||
|
$parts[] = trim($displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($findingType) && trim($findingType) !== '') {
|
||||||
|
$label = str_replace('_', ' ', trim($findingType));
|
||||||
|
$parts[] = '('.ucfirst($label).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts !== []) {
|
||||||
|
return implode(' ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Finding #'.$record->finding_id;
|
return 'Finding #'.$record->finding_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function relativeTimeDescription(mixed $date): ?string
|
||||||
|
{
|
||||||
|
if (! $date instanceof \DateTimeInterface) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$carbon = \Illuminate\Support\Carbon::instance($date);
|
||||||
|
|
||||||
|
if ($carbon->isToday()) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isPast()) {
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isTomorrow()) {
|
||||||
|
return 'Tomorrow';
|
||||||
|
}
|
||||||
|
|
||||||
|
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
|
||||||
|
|
||||||
|
if ($daysUntil <= 14) {
|
||||||
|
return 'In '.$daysUntil.' days';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
private static function canManageRecord(FindingException $record): bool
|
private static function canManageRecord(FindingException $record): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -479,10 +582,49 @@ private static function governanceWarningColor(FindingException $record): string
|
|||||||
? $record->finding
|
? $record->finding
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.finding-exceptions.open-queue', [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,13 +6,57 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
|
use App\Models\FindingException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ListFindingExceptions extends ListRecords
|
class ListFindingExceptions extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
protected static string $resource = FindingExceptionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
FindingExceptionStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
$needsAction = $stats['pending'] + $stats['expiring'] + $stats['expired'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_PENDING,
|
||||||
|
FindingException::STATUS_EXPIRING,
|
||||||
|
FindingException::STATUS_EXPIRED,
|
||||||
|
]))
|
||||||
|
->badge($needsAction > 0 ? $needsAction : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'active' => Tab::make('Active')
|
||||||
|
->icon('heroicon-m-check-badge')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->where('status', FindingException::STATUS_ACTIVE)),
|
||||||
|
'historical' => Tab::make('Historical')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -21,6 +65,12 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(FindingResource::getUrl('index')),
|
->url(FindingResource::getUrl('index')),
|
||||||
|
Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => FindingExceptionResource::canAccessApprovalQueueForTenant())
|
||||||
|
->url(fn (): ?string => FindingExceptionResource::approvalQueueUrl()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,23 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
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')
|
||||||
|
|||||||
@ -131,7 +131,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
||||||
@ -146,6 +146,65 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Status and next action')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||||
|
TextEntry::make('severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
|
TextEntry::make('finding_due_attention')
|
||||||
|
->label('Due state')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): ?string => static::dueAttentionLabel($record))
|
||||||
|
->color(fn (Finding $record): string => static::dueAttentionColor($record))
|
||||||
|
->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null),
|
||||||
|
TextEntry::make('finding_governance_validity_leading')
|
||||||
|
->label('Governance')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
|
||||||
|
TextEntry::make('owner_user_id_leading')
|
||||||
|
->label('Owner')
|
||||||
|
->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'),
|
||||||
|
TextEntry::make('assignee_user_id_leading')
|
||||||
|
->label('Assignee')
|
||||||
|
->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'),
|
||||||
|
TextEntry::make('finding_primary_narrative')
|
||||||
|
->label('Current reading')
|
||||||
|
->state(fn (Finding $record): string => static::primaryNarrative($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('finding_governance_warning_leading')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
||||||
|
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
||||||
|
TextEntry::make('finding_historical_context')
|
||||||
|
->label('Historical context')
|
||||||
|
->state(fn (Finding $record): ?string => static::historicalContext($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::historicalContext($record) !== null),
|
||||||
|
TextEntry::make('finding_primary_next_action')
|
||||||
|
->label('Next action')
|
||||||
|
->state(fn (Finding $record): ?string => static::primaryNextAction($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::primaryNextAction($record) !== null),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Finding')
|
Section::make('Finding')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||||
@ -628,19 +687,45 @@ public static function table(Table $table): Table
|
|||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
Tables\Columns\TextColumn::make('finding_type')
|
||||||
|
->badge()
|
||||||
|
->label('Type')
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingType))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingType))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingType)),
|
||||||
|
Tables\Columns\TextColumn::make('subject_display_name')
|
||||||
|
->label('Subject')
|
||||||
|
->placeholder('—')
|
||||||
|
->searchable()
|
||||||
|
->limit(40)
|
||||||
|
->wrap()
|
||||||
|
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||||
|
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||||
|
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||||
|
Tables\Columns\TextColumn::make('severity')
|
||||||
|
->badge()
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||||
Tables\Columns\TextColumn::make('severity')
|
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||||
|
Tables\Columns\TextColumn::make('governance_validity')
|
||||||
|
->label('Governance')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => static::governanceWarning($record)),
|
||||||
Tables\Columns\TextColumn::make('evidence_fidelity')
|
Tables\Columns\TextColumn::make('evidence_fidelity')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->badge()
|
->badge()
|
||||||
@ -652,12 +737,6 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('subject_display_name')
|
|
||||||
->label('Subject')
|
|
||||||
->placeholder('—')
|
|
||||||
->searchable()
|
|
||||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
|
||||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
|
||||||
Tables\Columns\TextColumn::make('subject_type')
|
Tables\Columns\TextColumn::make('subject_type')
|
||||||
->label('Subject type')
|
->label('Subject type')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -667,18 +746,19 @@ public static function table(Table $table): Table
|
|||||||
->label('Due')
|
->label('Due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
|
||||||
Tables\Columns\TextColumn::make('assigneeUser.name')
|
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||||
->label('Assignee')
|
->label('Assignee')
|
||||||
->placeholder('—'),
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\Filter::make('open')
|
Tables\Filters\Filter::make('open')
|
||||||
->label('Open')
|
->label('Active workflow')
|
||||||
->default()
|
|
||||||
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
||||||
Tables\Filters\Filter::make('overdue')
|
Tables\Filters\Filter::make('overdue')
|
||||||
->label('Overdue')
|
->label('Overdue')
|
||||||
@ -706,6 +786,45 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(FilterOptionCatalog::findingStatuses())
|
->options(FilterOptionCatalog::findingStatuses())
|
||||||
->label('Status'),
|
->label('Status'),
|
||||||
|
Tables\Filters\SelectFilter::make('workflow_family')
|
||||||
|
->label('Workflow family')
|
||||||
|
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'active' => $query->whereIn('status', Finding::openStatusesForQuery()),
|
||||||
|
'accepted_risk' => $query->where('status', Finding::STATUS_RISK_ACCEPTED),
|
||||||
|
'historical' => $query->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED]),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
Tables\Filters\SelectFilter::make('governance_validity')
|
||||||
|
->label('Governance')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionValidityStates())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === FindingException::VALIDITY_MISSING_SUPPORT) {
|
||||||
|
return $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereDoesntHave('findingException');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereHas('findingException', function (Builder $exceptionQuery) use ($value): void {
|
||||||
|
$exceptionQuery->where('current_validity_state', $value);
|
||||||
|
});
|
||||||
|
}),
|
||||||
Tables\Filters\SelectFilter::make('finding_type')
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
->options([
|
->options([
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
@ -760,9 +879,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
->recordUrl(static fn (Finding $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->actions([
|
->actions([
|
||||||
static::primaryRelatedAction(),
|
static::primaryRelatedAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -1078,7 +1195,7 @@ public static function table(Table $table): Table
|
|||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No findings match this view')
|
->emptyStateHeading('No findings match this view')
|
||||||
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.')
|
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface findings and governance follow-up.')
|
||||||
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1129,10 +1246,13 @@ private static function primaryRelatedAction(): Actions\Action
|
|||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return app(RelatedNavigationResolver::class)
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)
|
||||||
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||||
@ -1186,7 +1306,7 @@ public static function triageAction(): Actions\Action
|
|||||||
->label('Triage')
|
->label('Triage')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
@ -1212,7 +1332,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
->label('Start progress')
|
->label('Start progress')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
@ -1237,7 +1357,7 @@ public static function assignAction(): Actions\Action
|
|||||||
->label('Assign')
|
->label('Assign')
|
||||||
->icon('heroicon-o-user-plus')
|
->icon('heroicon-o-user-plus')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'assignee_user_id' => $record->assignee_user_id,
|
'assignee_user_id' => $record->assignee_user_id,
|
||||||
'owner_user_id' => $record->owner_user_id,
|
'owner_user_id' => $record->owner_user_id,
|
||||||
@ -1281,7 +1401,7 @@ public static function resolveAction(): Actions\Action
|
|||||||
->label('Resolve')
|
->label('Resolve')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
@ -1316,7 +1436,7 @@ public static function closeAction(): Actions\Action
|
|||||||
->label('Close')
|
->label('Close')
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
@ -1351,7 +1471,7 @@ public static function requestExceptionAction(): Actions\Action
|
|||||||
->label('Request exception')
|
->label('Request exception')
|
||||||
->icon('heroicon-o-shield-exclamation')
|
->icon('heroicon-o-shield-exclamation')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Select::make('owner_user_id')
|
Select::make('owner_user_id')
|
||||||
@ -1412,9 +1532,9 @@ public static function renewExceptionAction(): Actions\Action
|
|||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
|
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
||||||
])
|
])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
@ -1476,7 +1596,7 @@ public static function revokeExceptionAction(): Actions\Action
|
|||||||
->label('Revoke exception')
|
->label('Revoke exception')
|
||||||
->icon('heroicon-o-no-symbol')
|
->icon('heroicon-o-no-symbol')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('revocation_reason')
|
Textarea::make('revocation_reason')
|
||||||
@ -1503,7 +1623,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
@ -1523,6 +1643,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
*/
|
*/
|
||||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||||
{
|
{
|
||||||
|
$pageRecord = $record;
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -1551,6 +1672,8 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$callback($record, $tenant, $user);
|
$callback($record, $tenant, $user);
|
||||||
|
|
||||||
|
$pageRecord->refresh();
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Workflow action failed')
|
->title('Workflow action failed')
|
||||||
@ -1701,6 +1824,21 @@ private static function currentFindingException(Finding $record): ?FindingExcept
|
|||||||
return static::resolvedFindingException($finding);
|
return static::resolvedFindingException($finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function loadedFindingException(Finding $finding): ?FindingException
|
||||||
|
{
|
||||||
|
$exception = $finding->relationLoaded('findingException')
|
||||||
|
? $finding->findingException
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $exception instanceof FindingException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exception->loadMissing('currentDecision');
|
||||||
|
|
||||||
|
return $exception;
|
||||||
|
}
|
||||||
|
|
||||||
private static function resolvedFindingException(Finding $finding): ?FindingException
|
private static function resolvedFindingException(Finding $finding): ?FindingException
|
||||||
{
|
{
|
||||||
$exception = $finding->relationLoaded('findingException')
|
$exception = $finding->relationLoaded('findingException')
|
||||||
@ -1748,6 +1886,10 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
{
|
{
|
||||||
$exception = static::resolvedFindingException($finding);
|
$exception = static::resolvedFindingException($finding);
|
||||||
|
|
||||||
|
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
@ -1755,6 +1897,56 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function governanceValidityState(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryNarrative(Finding $finding): string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolvePrimaryNarrative($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function historicalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolveHistoricalContext($finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryNextAction(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dueAttentionLabel(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at->isPast()) {
|
||||||
|
return 'Overdue';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at->lessThanOrEqualTo(now()->addDays(3))) {
|
||||||
|
return 'Due soon';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dueAttentionColor(Finding $finding): string
|
||||||
|
{
|
||||||
|
return match (static::dueAttentionLabel($finding)) {
|
||||||
|
'Overdue' => 'danger',
|
||||||
|
'Due soon' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -1774,4 +1966,35 @@ private static function tenantMemberOptions(): array
|
|||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{open: int, overdue: int, high_severity: int, risk_accepted: int, total: int}
|
||||||
|
*/
|
||||||
|
public static function findingStatsForCurrentTenant(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now()->toDateTimeString();
|
||||||
|
|
||||||
|
$counts = Finding::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->selectRaw('count(*) as total')
|
||||||
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as open")
|
||||||
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') and due_at is not null and due_at < ? then 1 else 0 end) as overdue", [$now])
|
||||||
|
->selectRaw("sum(case when severity in ('high', 'critical') and status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as high_severity")
|
||||||
|
->selectRaw("sum(case when status = 'risk_accepted' then 1 else 0 end) as risk_accepted")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open' => (int) ($counts?->open ?? 0),
|
||||||
|
'overdue' => (int) ($counts?->overdue ?? 0),
|
||||||
|
'high_severity' => (int) ($counts?->high_severity ?? 0),
|
||||||
|
'risk_accepted' => (int) ($counts?->risk_accepted ?? 0),
|
||||||
|
'total' => (int) ($counts?->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
use App\Jobs\BackfillFindingLifecycleJob;
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -22,6 +23,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -61,6 +63,42 @@ protected function getHeaderWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BaselineCompareCoverageBanner::class,
|
BaselineCompareCoverageBanner::class,
|
||||||
|
FindingStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery()))
|
||||||
|
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'overdue' => Tab::make('Overdue')
|
||||||
|
->icon('heroicon-m-clock')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now()))
|
||||||
|
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||||
|
->badgeColor('danger'),
|
||||||
|
'risk_accepted' => Tab::make('Risk accepted')
|
||||||
|
->icon('heroicon-m-shield-check')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||||
|
'resolved' => Tab::make('Resolved')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
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;
|
||||||
@ -30,6 +32,23 @@ protected function getHeaderActions(): array
|
|||||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
|
Actions\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 Finding
|
||||||
|
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||||
|
})
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof Finding
|
||||||
|
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
@ -32,10 +33,11 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -74,7 +76,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
ActionSurfaceSlot::ListHeader,
|
ActionSurfaceSlot::ListHeader,
|
||||||
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
||||||
)
|
)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(
|
->exempt(
|
||||||
ActionSurfaceSlot::ListBulkMoreGroup,
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
||||||
@ -125,6 +127,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -132,7 +135,7 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)),
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -159,7 +162,7 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
@ -239,11 +242,7 @@ public static function table(Table $table): Table
|
|||||||
'until' => now()->toDateString(),
|
'until' => now()->toDateString(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label('View run')
|
|
||||||
->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No operation runs found')
|
->emptyStateHeading('No operation runs found')
|
||||||
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
||||||
@ -256,22 +255,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
$targetScope = static::targetScopeDisplay($record);
|
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
$artifactTruth = $record->isGovernanceArtifactOperation()
|
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||||
: null;
|
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||||
$artifactTruthBadge = $artifactTruth !== null
|
$supportingGroups = static::supportingGroups(
|
||||||
? $factory->statusBadge(
|
record: $record,
|
||||||
$artifactTruth->primaryBadgeSpec()->label,
|
factory: $factory,
|
||||||
$artifactTruth->primaryBadgeSpec()->color,
|
referencedTenantLifecycle: $referencedTenantLifecycle,
|
||||||
$artifactTruth->primaryBadgeSpec()->icon,
|
operatorExplanation: $operatorExplanation,
|
||||||
$artifactTruth->primaryBadgeSpec()->iconColor,
|
primaryNextStep: $primaryNextStep,
|
||||||
)
|
);
|
||||||
: null;
|
|
||||||
|
|
||||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||||
@ -282,162 +280,120 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
],
|
],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
$factory->keyFact('Target', $targetScope),
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
|
||||||
],
|
],
|
||||||
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->decisionZone($factory->decisionZone(
|
||||||
$factory->factsSection(
|
facts: array_values(array_filter([
|
||||||
id: 'run_summary',
|
$factory->keyFact(
|
||||||
kind: 'core_details',
|
'Execution state',
|
||||||
title: 'Run summary',
|
$statusSpec->label,
|
||||||
items: [
|
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||||
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
),
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
$factory->keyFact(
|
||||||
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
'Outcome',
|
||||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
$outcomeSpec->label,
|
||||||
],
|
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
|
),
|
||||||
|
static::artifactTruthFact($factory, $artifactTruth),
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result meaning',
|
||||||
|
$operatorExplanation->evaluationResultLabel(),
|
||||||
|
$operatorExplanation->headline,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result trust',
|
||||||
|
$operatorExplanation->trustworthinessLabel(),
|
||||||
|
static::detailHintUnlessDuplicate(
|
||||||
|
$operatorExplanation->reliabilityStatement,
|
||||||
|
$artifactTruth?->primaryExplanation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
])),
|
||||||
|
primaryNextStep: $factory->primaryNextStep(
|
||||||
|
$primaryNextStep['text'],
|
||||||
|
$primaryNextStep['source'],
|
||||||
|
$primaryNextStep['secondaryGuidance'],
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||||
id: 'artifact_truth',
|
compactCounts: $summaryLine !== null
|
||||||
kind: 'current_status',
|
? $factory->countPresentation(summaryLine: $summaryLine)
|
||||||
title: 'Artifact truth',
|
: null,
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
attentionNote: static::decisionAttentionNote($record),
|
||||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
));
|
||||||
visible: $record->isGovernanceArtifactOperation(),
|
|
||||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
if ($supportingGroups !== []) {
|
||||||
),
|
$builder->addSupportingGroup(...$supportingGroups);
|
||||||
$factory->viewSection(
|
}
|
||||||
id: 'related_context',
|
|
||||||
kind: 'related_context',
|
$builder->addSection(
|
||||||
title: 'Related context',
|
$factory->viewSection(
|
||||||
view: 'filament.infolists.entries.related-context',
|
id: 'related_context',
|
||||||
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
kind: 'related_context',
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
title: 'Related context',
|
||||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
view: 'filament.infolists.entries.related-context',
|
||||||
),
|
viewData: ['entries' => static::relatedContextEntries($record)],
|
||||||
)
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
->addSupportingCard(
|
),
|
||||||
$factory->supportingFactsCard(
|
$factory->viewSection(
|
||||||
kind: 'status',
|
id: 'artifact_truth',
|
||||||
title: 'Current state',
|
kind: 'supporting_detail',
|
||||||
items: array_values(array_filter([
|
title: 'Artifact truth details',
|
||||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
viewData: [
|
||||||
$artifactTruth !== null
|
'artifactTruthState' => $artifactTruth?->toArray(),
|
||||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
'surface' => 'expanded',
|
||||||
: null,
|
],
|
||||||
$referencedTenantLifecycle !== null
|
visible: $artifactTruth !== null,
|
||||||
? $factory->keyFact(
|
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
|
||||||
'Tenant lifecycle',
|
collapsible: true,
|
||||||
$referencedTenantLifecycle->presentation->label,
|
collapsed: true,
|
||||||
badge: $factory->statusBadge(
|
),
|
||||||
$referencedTenantLifecycle->presentation->label,
|
);
|
||||||
$referencedTenantLifecycle->presentation->badgeColor,
|
|
||||||
$referencedTenantLifecycle->presentation->badgeIcon,
|
|
||||||
$referencedTenantLifecycle->presentation->badgeIconColor,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
|
||||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
|
||||||
: null,
|
|
||||||
$referencedTenantLifecycle?->contextNote !== null
|
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
|
||||||
: null,
|
|
||||||
static::freshnessLabel($record) !== null
|
|
||||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
|
||||||
: null,
|
|
||||||
static::reconciliationHeadline($record) !== null
|
|
||||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
|
||||||
: null,
|
|
||||||
static::reconciledAtLabel($record) !== null
|
|
||||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
|
||||||
: null,
|
|
||||||
static::reconciliationSourceLabel($record) !== null
|
|
||||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
|
||||||
: null,
|
|
||||||
$artifactTruth !== null
|
|
||||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
|
||||||
: null,
|
|
||||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
|
||||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
|
||||||
: null,
|
|
||||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
|
||||||
static::blockedExecutionReasonCode($record) !== null
|
|
||||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionDetail($record) !== null
|
|
||||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionSource($record) !== null
|
|
||||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
|
||||||
: null,
|
|
||||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
|
||||||
])),
|
|
||||||
),
|
|
||||||
$factory->supportingFactsCard(
|
|
||||||
kind: 'timestamps',
|
|
||||||
title: 'Timing',
|
|
||||||
items: [
|
|
||||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
|
||||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
|
||||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Context',
|
|
||||||
entries: [
|
|
||||||
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
|
||||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
|
||||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
|
||||||
],
|
|
||||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::contextPayload($record)],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$counts = static::summaryCountFacts($record, $factory);
|
$counts = static::summaryCountFacts($record, $factory);
|
||||||
|
|
||||||
if ($counts !== []) {
|
if ($counts !== []) {
|
||||||
$builder->addSection(
|
$builder->addTechnicalSection(
|
||||||
$factory->factsSection(
|
$factory->technicalDetail(
|
||||||
id: 'counts',
|
title: 'Count diagnostics',
|
||||||
kind: 'current_status',
|
entries: $counts,
|
||||||
title: 'Counts',
|
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
|
||||||
items: $counts,
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
variant: 'diagnostic',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($record->failure_summary)) {
|
if (! empty($record->failure_summary)) {
|
||||||
$builder->addSection(
|
$builder->addTechnicalSection(
|
||||||
$factory->viewSection(
|
$factory->technicalDetail(
|
||||||
id: 'failures',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||||
|
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $record->failure_summary ?? []],
|
viewData: ['payload' => $record->failure_summary ?? []],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (static::reconciliationPayload($record) !== []) {
|
if (static::reconciliationPayload($record) !== []) {
|
||||||
$builder->addSection(
|
$builder->addTechnicalSection(
|
||||||
$factory->viewSection(
|
$factory->technicalDetail(
|
||||||
id: 'reconciliation',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: 'Lifecycle reconciliation',
|
title: 'Lifecycle reconciliation',
|
||||||
|
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -445,14 +401,39 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
if ((string) $record->type === 'baseline_compare') {
|
if ((string) $record->type === 'baseline_compare') {
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
|
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||||
|
|
||||||
if ($baselineCompareFacts !== []) {
|
if ($baselineCompareFacts !== []) {
|
||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->factsSection(
|
$factory->factsSection(
|
||||||
id: 'baseline_compare',
|
id: 'baseline_compare',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline compare',
|
title: 'Baseline compare',
|
||||||
items: $baselineCompareFacts,
|
items: $baselineCompareFacts,
|
||||||
|
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_compare_gap_details',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Evidence gap details',
|
||||||
|
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
||||||
|
view: 'filament.infolists.entries.evidence-gap-subjects',
|
||||||
|
viewData: [
|
||||||
|
'summary' => $gapSummary,
|
||||||
|
'buckets' => $gapBuckets,
|
||||||
|
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
||||||
|
],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -461,10 +442,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'baseline_compare_evidence',
|
id: 'baseline_compare_evidence',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline compare evidence',
|
title: 'Baseline compare evidence',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $baselineCompareEvidence],
|
viewData: ['payload' => $baselineCompareEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -477,10 +460,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'baseline_capture_evidence',
|
id: 'baseline_capture_evidence',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline capture evidence',
|
title: 'Baseline capture evidence',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $baselineCaptureEvidence],
|
viewData: ['payload' => $baselineCaptureEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -490,7 +475,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'verification_report',
|
id: 'verification_report',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Verification report',
|
title: 'Verification report',
|
||||||
view: 'filament.components.verification-report-viewer',
|
view: 'filament.components.verification-report-viewer',
|
||||||
viewData: static::verificationReportViewData($record),
|
viewData: static::verificationReportViewData($record),
|
||||||
@ -498,9 +483,321 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Context',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||||
|
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||||
|
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||||
|
],
|
||||||
|
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::contextPayload($record)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return $builder->build();
|
return $builder->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
|
||||||
|
*/
|
||||||
|
private static function supportingGroups(
|
||||||
|
OperationRun $record,
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
array $primaryNextStep,
|
||||||
|
): array {
|
||||||
|
$groups = [];
|
||||||
|
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
|
||||||
|
|
||||||
|
$guidanceItems = array_values(array_filter([
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
||||||
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
|
: null,
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
|
||||||
|
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
|
||||||
|
: null,
|
||||||
|
...array_map(
|
||||||
|
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
|
||||||
|
array_values(array_filter(
|
||||||
|
$primaryNextStep['secondaryGuidance'] ?? [],
|
||||||
|
static fn (mixed $guidance): bool => is_array($guidance),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
static::blockedExecutionReasonCode($record) !== null
|
||||||
|
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||||
|
: null,
|
||||||
|
static::blockedExecutionDetail($record) !== null
|
||||||
|
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||||
|
: null,
|
||||||
|
static::blockedExecutionSource($record) !== null
|
||||||
|
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||||
|
: null,
|
||||||
|
RunDurationInsights::stuckGuidance($record) !== null
|
||||||
|
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($guidanceItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'guidance',
|
||||||
|
title: 'Guidance',
|
||||||
|
items: $guidanceItems,
|
||||||
|
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lifecycleItems = array_values(array_filter([
|
||||||
|
$referencedTenantLifecycle !== null
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Tenant lifecycle',
|
||||||
|
$referencedTenantLifecycle->presentation->label,
|
||||||
|
badge: $factory->statusBadge(
|
||||||
|
$referencedTenantLifecycle->presentation->label,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeColor,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeIcon,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeIconColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||||
|
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||||
|
: null,
|
||||||
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
|
: null,
|
||||||
|
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
|
||||||
|
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||||
|
: null,
|
||||||
|
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
|
||||||
|
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||||
|
: null,
|
||||||
|
static::reconciledAtLabel($record) !== null
|
||||||
|
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
||||||
|
: null,
|
||||||
|
static::reconciliationSourceLabel($record) !== null
|
||||||
|
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($lifecycleItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'lifecycle',
|
||||||
|
title: 'Lifecycle',
|
||||||
|
items: $lifecycleItems,
|
||||||
|
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timingItems = [
|
||||||
|
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||||
|
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||||
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'timing',
|
||||||
|
title: 'Timing',
|
||||||
|
items: $timingItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
$metadataItems = array_values(array_filter([
|
||||||
|
$factory->keyFact('Initiator', $record->initiator_name),
|
||||||
|
RunDurationInsights::expectedHuman($record) !== null
|
||||||
|
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($metadataItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'metadata',
|
||||||
|
title: 'Metadata',
|
||||||
|
items: $metadataItems,
|
||||||
|
description: 'Secondary metadata remains visible without crowding the top decision surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function resolvePrimaryNextStep(
|
||||||
|
OperationRun $record,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
): array {
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
|
||||||
|
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
|
||||||
|
|
||||||
|
$opsUxSource = match (true) {
|
||||||
|
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
||||||
|
static::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
||||||
|
default => 'ops_ux',
|
||||||
|
};
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, static::surfaceGuidance($record), $opsUxSource);
|
||||||
|
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [
|
||||||
|
'text' => 'No action needed.',
|
||||||
|
'source' => 'none_required',
|
||||||
|
'secondaryGuidance' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary = $candidates[0];
|
||||||
|
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
|
||||||
|
? 'none_required'
|
||||||
|
: $primary['source'];
|
||||||
|
|
||||||
|
$secondaryGuidance = array_map(
|
||||||
|
static fn (array $candidate): array => [
|
||||||
|
'label' => static::guidanceLabel($candidate['source']),
|
||||||
|
'text' => $candidate['text'],
|
||||||
|
'source' => $candidate['source'],
|
||||||
|
],
|
||||||
|
array_slice($candidates, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'text' => $primary['text'],
|
||||||
|
'source' => $primarySource,
|
||||||
|
'secondaryGuidance' => $secondaryGuidance,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
|
||||||
|
*/
|
||||||
|
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
|
||||||
|
{
|
||||||
|
$formattedText = static::formatGuidanceText($text);
|
||||||
|
|
||||||
|
if ($formattedText === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = static::normalizeGuidance($formattedText);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (($candidate['normalized'] ?? null) === $normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates[] = [
|
||||||
|
'text' => $formattedText,
|
||||||
|
'source' => $source,
|
||||||
|
'normalized' => $normalized,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatGuidanceText(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/[.!?]$/', $text) === 1) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text.'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeGuidance(string $text): string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower(trim($text));
|
||||||
|
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
|
||||||
|
|
||||||
|
return trim($normalized, " \t\n\r\0\x0B.!?");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function guidanceLabel(string $source): string
|
||||||
|
{
|
||||||
|
return match ($source) {
|
||||||
|
'operator_explanation' => 'Operator guidance',
|
||||||
|
'artifact_truth' => 'Artifact guidance',
|
||||||
|
'blocked_reason' => 'Blocked prerequisite',
|
||||||
|
'lifecycle_attention' => 'Lifecycle guidance',
|
||||||
|
default => 'General guidance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function artifactTruthFact(
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
): ?array {
|
||||||
|
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$badge = $artifactTruth->primaryBadgeSpec();
|
||||||
|
|
||||||
|
return $factory->keyFact(
|
||||||
|
'Artifact truth',
|
||||||
|
$artifactTruth->primaryLabel,
|
||||||
|
$artifactTruth->primaryExplanation,
|
||||||
|
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||||
|
{
|
||||||
|
$normalizedHint = static::normalizeDetailText($hint);
|
||||||
|
|
||||||
|
if ($normalizedHint === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($hint ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeDetailText(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||||
|
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_strtolower($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@ -511,12 +808,29 @@ private static function summaryCountFacts(
|
|||||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
|
static fn (string $key, int $value): array => $factory->keyFact(
|
||||||
|
SummaryCountsNormalizer::label($key),
|
||||||
|
$value,
|
||||||
|
tone: self::countTone($key, $value),
|
||||||
|
),
|
||||||
array_keys($counts),
|
array_keys($counts),
|
||||||
array_values($counts),
|
array_values($counts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function countTone(string $key, int $value): ?string
|
||||||
|
{
|
||||||
|
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
|
||||||
|
return $value > 0 ? 'danger' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'succeeded' && $value > 0) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||||
@ -581,6 +895,8 @@ private static function baselineCompareFacts(
|
|||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
): array {
|
): array {
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
$facts = [];
|
$facts = [];
|
||||||
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||||
@ -612,6 +928,30 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Evidence gap detail',
|
||||||
|
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
||||||
|
'structured_details_recorded' => 'Structured subject details available',
|
||||||
|
'details_not_recorded' => 'Detailed rows were not recorded',
|
||||||
|
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
|
||||||
|
default => 'No evidence gaps recorded',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
|
||||||
|
}
|
||||||
|
|
||||||
if ($uncoveredTypes !== []) {
|
if ($uncoveredTypes !== []) {
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||||
@ -842,6 +1182,57 @@ public static function getPages(): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function artifactTruthEnvelope(OperationRun $record, bool $fresh = false): ?ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
if (! $record->supportsOperatorExplanation()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forOperationRunFresh($record)
|
||||||
|
: $presenter->forOperationRun($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::lifecycleAttentionSummaryFresh($record)
|
||||||
|
: OperationUxPresenter::lifecycleAttentionSummary($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::surfaceGuidanceFresh($record)
|
||||||
|
: OperationUxPresenter::surfaceGuidance($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)
|
||||||
|
: $resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record);
|
||||||
|
}
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|||||||
@ -99,7 +99,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
||||||
@ -365,6 +365,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label('Policy')
|
||||||
@ -490,7 +491,6 @@ public static function table(Table $table): Table
|
|||||||
->all()),
|
->all()),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('ignore')
|
Actions\Action::make('ignore')
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
@ -49,8 +50,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
||||||
}
|
}
|
||||||
@ -181,13 +182,11 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->defaultSort('version_number', 'desc')
|
->defaultSort('version_number', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
|
->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
$restoreToIntune,
|
$restoreToIntune,
|
||||||
Actions\ViewAction::make()
|
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
|
||||||
->openUrlInNewTab(false),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No versions captured')
|
->emptyStateHeading('No versions captured')
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -26,12 +27,9 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('primary_related')
|
Action::make('primary_related')
|
||||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->actionLabel ?? 'Open related record')
|
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->targetUrl)
|
|
||||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->isAvailable() ?? false))
|
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -42,4 +40,13 @@ public function getFooter(): ?View
|
|||||||
'record' => $this->getRecord(),
|
'record' => $this->getRecord(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())
|
||||||
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,7 +82,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\RestoreRunIdempotency;
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -103,6 +107,17 @@ public static function canCreate(): bool
|
|||||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -862,8 +877,8 @@ public static function table(Table $table): Table
|
|||||||
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
||||||
FilterPresets::archived(),
|
FilterPresets::archived(),
|
||||||
])
|
])
|
||||||
|
->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
static::rerunActionWithGate(),
|
static::rerunActionWithGate(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
@ -975,7 +990,9 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
@ -1232,7 +1249,7 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No restore runs')
|
->emptyStateHeading('No restore runs')
|
||||||
->emptyStateDescription('Start a restoration from a backup set.')
|
->emptyStateDescription('Start a restoration from a backup set.')
|
||||||
|
|||||||
@ -99,9 +99,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download and Expire remain direct row shortcuts.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
}
|
}
|
||||||
@ -337,6 +337,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -397,9 +398,13 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forReviewPackFresh($record)
|
||||||
|
: $presenter->forReviewPack($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -447,6 +452,8 @@ public static function executeGeneration(array $data): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($reviewPack->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $reviewPack->wasRecentlyCreated) {
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
|
|||||||
@ -146,10 +146,10 @@ public static function canDeleteAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->withListRowPrimaryActionLimit(2)
|
->withListRowPrimaryActionLimit(1)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; lifecycle-adjacent and destructive actions move under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||||
@ -245,6 +245,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (Tenant $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -317,49 +318,11 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
|
||||||
Actions\Action::make('related_onboarding')
|
Actions\Action::make('related_onboarding')
|
||||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('restore')
|
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
|
||||||
->color('success')
|
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::restoreTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
|
||||||
->color('danger')
|
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::archiveTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('related_onboarding_overflow')
|
Actions\Action::make('related_onboarding_overflow')
|
||||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||||
@ -367,6 +330,40 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::archiveTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
|
|||||||
@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
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\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -15,11 +20,48 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class TenantMembershipsRelationManager extends RelationManager
|
class TenantMembershipsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
protected static string $relationship = 'memberships';
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||||
|
{
|
||||||
|
if (! $ownerRecord instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageClass !== ManageTenantMemberships::class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($ownerRecord)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $ownerRecord, Capabilities::TENANT_MEMBERSHIP_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -120,7 +120,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->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, 'Primary row actions stay limited to View review and Export executive pack.')
|
->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 Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,7 +257,7 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
@ -295,7 +295,7 @@ public static function table(Table $table): Table
|
|||||||
->boolean(),
|
->boolean(),
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
@ -311,9 +311,6 @@ public static function table(Table $table): Table
|
|||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
@ -419,6 +416,8 @@ public static function executeCreateReview(array $data): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $review->wasRecentlyCreated) {
|
if (! $review->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -488,6 +487,9 @@ public static function executeExport(TenantReview $review): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
app(ArtifactTruthPresenter::class)->forReviewPackFresh($pack->refresh());
|
||||||
|
|
||||||
if (! $pack->wasRecentlyCreated) {
|
if (! $pack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -563,6 +565,7 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
||||||
'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'] : [],
|
||||||
@ -605,8 +608,12 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\WorkspaceRole;
|
use App\Support\Auth\WorkspaceRole;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
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\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
@ -21,6 +25,16 @@ class WorkspaceMembershipsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'memberships';
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace membership rows are managed inline and have no separate inspect destination.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Change role stays inline while destructive removal is grouped under More.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -177,46 +191,47 @@ public function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
->tooltip('You do not have permission to manage workspace memberships.')
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
->apply(),
|
->apply(),
|
||||||
|
ActionGroup::make([
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
|
->label(__('Remove'))
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
||||||
|
$workspace = $this->getOwnerRecord();
|
||||||
|
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
if (! $workspace instanceof Workspace) {
|
||||||
Action::make('remove')
|
abort(404);
|
||||||
->label(__('Remove'))
|
}
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
|
||||||
$workspace = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
$actor = auth()->user();
|
||||||
abort(404);
|
if (! $actor instanceof User) {
|
||||||
}
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$actor = auth()->user();
|
try {
|
||||||
if (! $actor instanceof User) {
|
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
||||||
abort(403);
|
} catch (\Throwable $throwable) {
|
||||||
}
|
Notification::make()
|
||||||
|
->title(__('Failed to remove member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
try {
|
return;
|
||||||
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
}
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to remove member'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
}
|
$this->resetTable();
|
||||||
|
}),
|
||||||
Notification::make()->title(__('Member removed'))->success()->send();
|
fn () => $this->getOwnerRecord(),
|
||||||
$this->resetTable();
|
)
|
||||||
}),
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
fn () => $this->getOwnerRecord(),
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
)
|
->destructive()
|
||||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
->apply(),
|
||||||
->tooltip('You do not have permission to manage workspace memberships.')
|
])->label('More'),
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('No workspace members'))
|
->emptyStateHeading(__('No workspace members'))
|
||||||
|
|||||||
@ -96,8 +96,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only primary View/Edit row actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only row-click inspection plus a primary Edit action.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
||||||
@ -151,6 +151,7 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->recordUrl(fn (Workspace $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -160,7 +161,6 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
fn (): ?Workspace => null,
|
fn (): ?Workspace => null,
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -31,6 +35,16 @@ class Tenants extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.directory.tenants';
|
protected string $view = 'filament.system.pages.directory.tenants';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System tenant directory does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that tenants appear here after onboarding and inventory sync.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -14,6 +14,10 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -35,6 +39,16 @@ class Workspaces extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.directory.workspaces';
|
protected string $view = 'filament.system.pages.directory.workspaces';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System workspace directory does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that workspaces appear here once the platform inventory is seeded.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -15,6 +15,10 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -39,6 +43,16 @@ class Failures extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.failures';
|
protected string $view = 'filament.system.pages.ops.failures';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
$count = OperationRun::query()
|
$count = OperationRun::query()
|
||||||
|
|||||||
@ -13,6 +13,10 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -37,6 +41,16 @@ class Runs extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.runs';
|
protected string $view = 'filament.system.pages.ops.runs';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -15,6 +15,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
use App\Support\SystemConsole\StuckRunClassifier;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -39,6 +43,16 @@ class Stuck extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.stuck';
|
protected string $view = 'filament.system.pages.ops.stuck';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
$count = app(StuckRunClassifier::class)
|
$count = app(StuckRunClassifier::class)
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -28,6 +31,16 @@ class AccessLogs extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.security.access-logs';
|
protected string $view = 'filament.system.pages.security.access-logs';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no platform auth or break-glass events match the current log scope.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The access log page does not open per-record detail headers; review stays inline in the table.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -4,8 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -22,38 +26,57 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'state' => 'no_assignment',
|
|
||||||
'message' => null,
|
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
|
||||||
'highCount' => 0,
|
|
||||||
'mediumCount' => 0,
|
|
||||||
'lowCount' => 0,
|
|
||||||
'lastComparedAt' => null,
|
'lastComparedAt' => null,
|
||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'findingsUrl' => null,
|
||||||
|
'nextActionUrl' => null,
|
||||||
|
'summaryAssessment' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forWidget($tenant);
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
if ($aggregate->compareState === 'no_assignment') {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
|
$runUrl = $aggregate->stats->operationRunId !== null
|
||||||
|
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||||
|
: null;
|
||||||
|
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||||
|
'run' => $runUrl,
|
||||||
|
'findings' => $findingsUrl,
|
||||||
|
'landing' => $tenantLandingUrl,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'state' => $stats->state,
|
'profileName' => $aggregate->profileName,
|
||||||
'message' => $stats->message,
|
'lastComparedAt' => $aggregate->lastComparedLabel,
|
||||||
'profileName' => $stats->profileName,
|
'landingUrl' => $tenantLandingUrl,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
'runUrl' => $runUrl,
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
'findingsUrl' => $findingsUrl,
|
||||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||||
|
|
||||||
|
/** @var TenantGovernanceAggregate $aggregate */
|
||||||
|
$aggregate = $resolver->forTenant($tenant);
|
||||||
|
|
||||||
|
return $aggregate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -34,85 +31,71 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
|
$compareAssessment = $aggregate->summaryAssessment;
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
$highSeverityCount = (int) Finding::query()
|
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
|
||||||
->where('tenant_id', $tenantId)
|
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||||
->where('status', Finding::STATUS_NEW)
|
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
if ($lapsedGovernanceCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Lapsed accepted-risk governance',
|
||||||
|
'body' => "{$lapsedGovernanceCount} finding(s) need governance follow-up before accepted risk is safe to rely on.",
|
||||||
|
'badge' => 'Governance',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Overdue findings',
|
||||||
|
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
||||||
|
'badge' => 'Findings',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiringGovernanceCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Expiring accepted-risk governance',
|
||||||
|
'body' => "{$expiringGovernanceCount} finding(s) will need governance review soon.",
|
||||||
|
'badge' => 'Governance',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
if ($highSeverityCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity active findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} active finding(s) need review.",
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestBaselineCompareSuccess = OperationRun::query()
|
if ($compareAssessment->stateFamily !== 'positive') {
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestBaselineCompareSuccess) {
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'No baseline compare yet',
|
'title' => 'Baseline compare posture',
|
||||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Drift',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => $compareAssessment->tone,
|
||||||
];
|
'nextStep' => $aggregate->nextActionLabel,
|
||||||
} else {
|
|
||||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare stale',
|
|
||||||
'body' => 'Last baseline compare is older than 7 days.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = ActiveRuns::existForTenant($tenant)
|
||||||
->where('tenant_id', $tenantId)
|
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
||||||
->active()
|
: 0;
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
if ($activeRuns > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
@ -125,24 +108,24 @@ protected function getViewData(): array
|
|||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Drift findings look healthy',
|
'title' => 'Baseline compare looks trustworthy',
|
||||||
'body' => 'No high severity drift findings are open.',
|
'body' => $aggregate->headline,
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compares are up to date',
|
'title' => 'No overdue findings',
|
||||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
'body' => 'No open findings are currently overdue for this tenant.',
|
||||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
],
|
||||||
: 'Baseline compare history is available in Baseline Compare.',
|
[
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'title' => 'Accepted-risk governance is healthy',
|
||||||
'linkLabel' => 'Open Baseline Compare',
|
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'No high severity active findings',
|
||||||
|
'body' => 'No high severity findings are currently open for this tenant.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -153,4 +136,15 @@ protected function getViewData(): array
|
|||||||
'healthyChecks' => $healthyChecks,
|
'healthyChecks' => $healthyChecks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||||
|
|
||||||
|
/** @var TenantGovernanceAggregate $aggregate */
|
||||||
|
$aggregate = $resolver->forTenant($tenant);
|
||||||
|
|
||||||
|
return $aggregate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -29,29 +31,39 @@ protected function getViewData(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
|
$runUrl = $aggregate->stats->operationRunId !== null
|
||||||
|
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||||
|
: null;
|
||||||
|
|
||||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||||
|
'run' => $runUrl,
|
||||||
$coverageStatus = $stats->coverageStatus;
|
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
'landing' => $landingUrl,
|
||||||
|
default => null,
|
||||||
$runUrl = null;
|
};
|
||||||
|
$shouldShow = in_array($aggregate->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
if ($stats->operationRunId !== null) {
|
|| $aggregate->stateFamily === 'action_required';
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
'shouldShow' => $shouldShow,
|
||||||
|
'landingUrl' => $landingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'state' => $stats->state,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
'message' => $stats->message,
|
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
||||||
'coverageStatus' => $coverageStatus,
|
'state' => $aggregate->compareState,
|
||||||
'fidelity' => $stats->fidelity,
|
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
|
||||||
'uncoveredTypes' => $uncoveredTypes,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||||
|
|
||||||
|
/** @var TenantGovernanceAggregate $aggregate */
|
||||||
|
$aggregate = $resolver->forTenant($tenant);
|
||||||
|
|
||||||
|
return $aggregate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class FindingExceptionStatsOverview extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$counts = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Active', $counts['active'])
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('success'),
|
||||||
|
Stat::make('Expiring', $counts['expiring'])
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color('warning'),
|
||||||
|
Stat::make('Expired', $counts['expired'])
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger'),
|
||||||
|
Stat::make('Pending approval', $counts['pending'])
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class FindingStatsOverview extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$counts = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Open', $counts['open'])
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color($counts['open'] > 0 ? 'warning' : 'success'),
|
||||||
|
Stat::make('Overdue', $counts['overdue'])
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color($counts['overdue'] > 0 ? 'danger' : 'gray'),
|
||||||
|
Stat::make('High severity', $counts['high_severity'])
|
||||||
|
->icon('heroicon-o-fire')
|
||||||
|
->color($counts['high_severity'] > 0 ? 'danger' : 'gray'),
|
||||||
|
Stat::make('Risk accepted', $counts['risk_accepted'])
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class OpenFindingExceptionsQueueController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Tenant $tenant): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
if (! $workspaceContext->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||||
|
|
||||||
|
if (! $workspaceContext->rememberTenantContext($tenant, $request)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(FindingExceptionsQueue::getUrl([
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
], panel: 'admin'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -108,6 +108,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
|
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
@ -127,6 +128,7 @@ public function handle(
|
|||||||
scope: $effectiveScope,
|
scope: $effectiveScope,
|
||||||
identity: $identity,
|
identity: $identity,
|
||||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
policyTypes: $truthfulTypes,
|
||||||
);
|
);
|
||||||
|
|
||||||
$subjects = $inventoryResult['subjects'];
|
$subjects = $inventoryResult['subjects'];
|
||||||
@ -262,6 +264,9 @@ public function handle(
|
|||||||
'gaps' => [
|
'gaps' => [
|
||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
|
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||||
|
? array_values($phaseResult['gap_subjects'])
|
||||||
|
: null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
],
|
],
|
||||||
@ -296,7 +301,7 @@ public function handle(
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* subjects_total: int,
|
* subjects_total: int,
|
||||||
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
|
||||||
* inventory_by_key: array<string, array{
|
* inventory_by_key: array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
* workspace_subject_external_id: string,
|
* workspace_subject_external_id: string,
|
||||||
@ -317,6 +322,7 @@ private function collectInventorySubjects(
|
|||||||
BaselineScope $scope,
|
BaselineScope $scope,
|
||||||
BaselineSnapshotIdentity $identity,
|
BaselineSnapshotIdentity $identity,
|
||||||
?int $latestInventorySyncRunId = null,
|
?int $latestInventorySyncRunId = null,
|
||||||
|
?array $policyTypes = null,
|
||||||
): array {
|
): array {
|
||||||
$query = InventoryItem::query()
|
$query = InventoryItem::query()
|
||||||
->where('tenant_id', $sourceTenant->getKey());
|
->where('tenant_id', $sourceTenant->getKey());
|
||||||
@ -325,7 +331,7 @@ private function collectInventorySubjects(
|
|||||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn('policy_type', $scope->allTypes());
|
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
|
||||||
|
|
||||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||||
$inventoryByKey = [];
|
$inventoryByKey = [];
|
||||||
@ -413,6 +419,7 @@ private function collectInventorySubjects(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$inventoryByKey,
|
$inventoryByKey,
|
||||||
));
|
));
|
||||||
@ -425,6 +432,27 @@ private function collectInventorySubjects(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||||
|
{
|
||||||
|
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||||
|
|
||||||
|
if (is_array($truthfulTypes)) {
|
||||||
|
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||||
|
|
||||||
|
if ($truthfulTypes !== []) {
|
||||||
|
sort($truthfulTypes, SORT_STRING);
|
||||||
|
|
||||||
|
return $truthfulTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $effectiveScope->allTypes();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{
|
* @param array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
|
|||||||
@ -43,10 +43,12 @@
|
|||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -143,7 +145,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$effectiveTypes = $effectiveScope->allTypes();
|
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
@ -319,6 +321,7 @@ public function handle(
|
|||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $context]);
|
$this->operationRun->update(['context' => $context]);
|
||||||
$this->operationRun->refresh();
|
$this->operationRun->refresh();
|
||||||
@ -361,6 +364,7 @@ public function handle(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['subject_external_id'],
|
'subject_external_id' => (string) $item['subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$currentItems,
|
$currentItems,
|
||||||
));
|
));
|
||||||
@ -386,6 +390,7 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$phaseResult = [];
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
|
$phaseGapSubjects = [];
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
@ -414,6 +419,7 @@ public function handle(
|
|||||||
|
|
||||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||||
|
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
||||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,6 +499,12 @@ public function handle(
|
|||||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||||
$gapsCount = array_sum($gapsByReason);
|
$gapsCount = array_sum($gapsByReason);
|
||||||
|
|
||||||
|
$gapSubjects = $this->collectGapSubjects(
|
||||||
|
ambiguousKeys: $ambiguousKeys,
|
||||||
|
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||||
|
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
@ -570,6 +582,7 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
...$gapsByReason,
|
...$gapsByReason,
|
||||||
|
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'coverage' => [
|
'coverage' => [
|
||||||
@ -597,6 +610,10 @@ public function handle(
|
|||||||
'findings_resolved' => $resolvedCount,
|
'findings_resolved' => $resolvedCount,
|
||||||
'severity_breakdown' => $severityBreakdown,
|
'severity_breakdown' => $severityBreakdown,
|
||||||
];
|
];
|
||||||
|
$updatedContext = $this->withCompareReasonTranslation(
|
||||||
|
$updatedContext,
|
||||||
|
$reasonCode?->value,
|
||||||
|
);
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
$this->auditCompleted(
|
$this->auditCompleted(
|
||||||
@ -842,6 +859,7 @@ private function completeWithCoverageWarning(
|
|||||||
'findings_resolved' => 0,
|
'findings_resolved' => 0,
|
||||||
'severity_breakdown' => [],
|
'severity_breakdown' => [],
|
||||||
];
|
];
|
||||||
|
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -948,6 +966,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
unset($context['reason_translation'], $context['next_steps']);
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||||
|
|
||||||
|
if ($translation === null) {
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context['reason_translation'] = $translation->toArray();
|
||||||
|
$context['reason_code'] = $reasonCode;
|
||||||
|
|
||||||
|
if ($translation->toLegacyNextSteps() !== []) {
|
||||||
|
$context['next_steps'] = $translation->toLegacyNextSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load current inventory items keyed by "policy_type|subject_key".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1067,6 +1113,27 @@ private function snapshotBlockedMessage(string $reasonCode): string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||||
|
{
|
||||||
|
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||||
|
|
||||||
|
if (is_array($truthfulTypes)) {
|
||||||
|
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||||
|
|
||||||
|
if ($truthfulTypes !== []) {
|
||||||
|
sort($truthfulTypes, SORT_STRING);
|
||||||
|
|
||||||
|
return $truthfulTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $effectiveScope->allTypes();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare baseline items vs current inventory and produce drift results.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
@ -1099,6 +1166,7 @@ private function computeDrift(
|
|||||||
): array {
|
): array {
|
||||||
$drift = [];
|
$drift = [];
|
||||||
$evidenceGaps = [];
|
$evidenceGaps = [];
|
||||||
|
$evidenceGapSubjects = [];
|
||||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||||
|
|
||||||
@ -1140,6 +1208,7 @@ private function computeDrift(
|
|||||||
if (! is_array($currentItem)) {
|
if (! is_array($currentItem)) {
|
||||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1204,6 +1273,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1220,12 +1290,14 @@ private function computeDrift(
|
|||||||
if ($isRbacRoleDefinition) {
|
if ($isRbacRoleDefinition) {
|
||||||
if ($baselinePolicyVersionId === null) {
|
if ($baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentPolicyVersionId === null) {
|
if ($currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1239,6 +1311,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($roleDefinitionDiff === null) {
|
if ($roleDefinitionDiff === null) {
|
||||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1319,6 +1392,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1334,6 +1408,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1393,6 +1468,7 @@ private function computeDrift(
|
|||||||
return [
|
return [
|
||||||
'drift' => $drift,
|
'drift' => $drift,
|
||||||
'evidence_gaps' => $evidenceGaps,
|
'evidence_gaps' => $evidenceGaps,
|
||||||
|
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1904,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const GAP_SUBJECTS_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $ambiguousKeys
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
||||||
|
{
|
||||||
|
$subjects = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
if ($ambiguousKeys !== []) {
|
||||||
|
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
||||||
|
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeStructuredGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $record) {
|
||||||
|
if (! is_array($record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeLegacyGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $reasonCode => $keys) {
|
||||||
|
if (! is_string($reasonCode) || ! is_array($keys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$outcome = match ($reasonCode) {
|
||||||
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||||
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||||
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||||
|
};
|
||||||
|
|
||||||
|
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
$record['reason_code'] = $reasonCode;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?string, 1: ?string}
|
||||||
|
*/
|
||||||
|
private function splitGapSubjectKey(string $value): array
|
||||||
|
{
|
||||||
|
$parts = explode('|', $value, 2);
|
||||||
|
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $parts;
|
||||||
|
$policyType = trim($policyType);
|
||||||
|
$subjectKey = trim($subjectKey);
|
||||||
|
|
||||||
|
if ($policyType === '' || $subjectKey === '') {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$policyType, $subjectKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectResolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||||
|
|||||||
254
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
254
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Filament\Tables\TableComponent;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class BaselineCompareEvidenceGapTable extends TableComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $gapRows = [];
|
||||||
|
|
||||||
|
public string $context = 'default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $buckets
|
||||||
|
*/
|
||||||
|
public function mount(array $buckets = [], string $context = 'default'): void
|
||||||
|
{
|
||||||
|
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
|
||||||
|
$this->context = $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
|
||||||
|
->defaultSort('reason_label')
|
||||||
|
->defaultPaginationPageOption(10)
|
||||||
|
->paginated(TablePaginationProfiles::picker())
|
||||||
|
->searchable()
|
||||||
|
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
|
||||||
|
->records(function (
|
||||||
|
?string $sortColumn,
|
||||||
|
?string $sortDirection,
|
||||||
|
?string $search,
|
||||||
|
array $filters,
|
||||||
|
int $page,
|
||||||
|
int $recordsPerPage
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$rows = $this->filterRows(
|
||||||
|
rows: collect($this->gapRows),
|
||||||
|
search: $search,
|
||||||
|
filters: $filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->sortRows(
|
||||||
|
rows: $rows,
|
||||||
|
sortColumn: $sortColumn,
|
||||||
|
sortDirection: $sortDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->paginateRows(
|
||||||
|
rows: $rows,
|
||||||
|
page: $page,
|
||||||
|
recordsPerPage: $recordsPerPage,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('reason_code')
|
||||||
|
->label(__('baseline-compare.evidence_gap_reason'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('policy_type')
|
||||||
|
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('subject_class')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('operator_action_category')
|
||||||
|
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
|
||||||
|
])
|
||||||
|
->striped()
|
||||||
|
->deferLoading(! app()->runningUnitTests())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('reason_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_reason'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('policy_type')
|
||||||
|
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||||
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('subject_class_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||||
|
->badge()
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('resolution_outcome_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_outcome'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('operator_action_category_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('subject_key')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_key'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->actions([])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
|
||||||
|
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.baseline-compare-evidence-gap-table');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||||
|
{
|
||||||
|
$normalizedSearch = Str::lower(trim((string) $search));
|
||||||
|
$reasonCode = $filters['reason_code']['value'] ?? null;
|
||||||
|
$policyType = $filters['policy_type']['value'] ?? null;
|
||||||
|
$subjectClass = $filters['subject_class']['value'] ?? null;
|
||||||
|
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
|
||||||
|
|
||||||
|
return $rows
|
||||||
|
->when(
|
||||||
|
$normalizedSearch !== '',
|
||||||
|
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||||
|
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||||
|
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($reasonCode),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($policyType),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($subjectClass),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($operatorActionCategory),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
|
||||||
|
)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||||
|
{
|
||||||
|
if (! filled($sortColumn)) {
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
return $rows->sortBy(
|
||||||
|
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
|
||||||
|
SORT_NATURAL | SORT_FLAG_CASE,
|
||||||
|
$direction === 'desc'
|
||||||
|
)->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$perPage = max(1, $recordsPerPage);
|
||||||
|
$currentPage = max(1, $page);
|
||||||
|
$total = $rows->count();
|
||||||
|
$items = $rows->forPage($currentPage, $perPage)
|
||||||
|
->values()
|
||||||
|
->map(fn (array $row, int $index): Model => $this->toTableRecord(
|
||||||
|
row: $row,
|
||||||
|
index: (($currentPage - 1) * $perPage) + $index,
|
||||||
|
));
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$items,
|
||||||
|
$total,
|
||||||
|
$perPage,
|
||||||
|
$currentPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function toTableRecord(array $row, int $index): Model
|
||||||
|
{
|
||||||
|
$record = new class extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $table = 'baseline_compare_evidence_gap_rows';
|
||||||
|
};
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'id' => implode(':', array_filter([
|
||||||
|
(string) ($row['reason_code'] ?? 'reason'),
|
||||||
|
(string) ($row['policy_type'] ?? 'policy'),
|
||||||
|
(string) ($row['subject_key'] ?? 'subject'),
|
||||||
|
(string) $index,
|
||||||
|
])),
|
||||||
|
...$row,
|
||||||
|
]);
|
||||||
|
$record->exists = true;
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -135,6 +135,11 @@ public function isGovernanceArtifactOperation(): bool
|
|||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function supportsOperatorExplanation(): bool
|
||||||
|
{
|
||||||
|
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
public function governanceArtifactFamily(): ?string
|
public function governanceArtifactFamily(): ?string
|
||||||
{
|
{
|
||||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
||||||
@ -188,4 +193,81 @@ public function freshnessState(): OperationRunFreshnessState
|
|||||||
{
|
{
|
||||||
return OperationRunFreshnessState::forRun($this);
|
return OperationRunFreshnessState::forRun($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function baselineGapEnvelope(): array
|
||||||
|
{
|
||||||
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
|
||||||
|
return match ((string) $this->type) {
|
||||||
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||||
|
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||||
|
: [],
|
||||||
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||||
|
? data_get($context, 'baseline_capture.gaps')
|
||||||
|
: [],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasStructuredBaselineGapPayload(): bool
|
||||||
|
{
|
||||||
|
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as $subject) {
|
||||||
|
if (! is_array($subject)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'policy_type',
|
||||||
|
'subject_key',
|
||||||
|
'subject_class',
|
||||||
|
'resolution_path',
|
||||||
|
'resolution_outcome',
|
||||||
|
'reason_code',
|
||||||
|
'operator_action_category',
|
||||||
|
'structural',
|
||||||
|
'retryable',
|
||||||
|
] as $key) {
|
||||||
|
if (! array_key_exists($key, $subject)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasLegacyBaselineGapPayload(): bool
|
||||||
|
{
|
||||||
|
$envelope = $this->baselineGapEnvelope();
|
||||||
|
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = $envelope['subjects'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($subjects)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($subjects)) {
|
||||||
|
return $subjects !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjects === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->hasStructuredBaselineGapPayload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -119,15 +120,10 @@ public function tenantRoleValue(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $this->tenants()
|
/** @var CapabilityResolver $resolver */
|
||||||
->whereKey($tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->value('role');
|
|
||||||
|
|
||||||
if (! is_string($role)) {
|
return $resolver->getRole($this, $tenant)?->value;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $role;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function allowsTenantSync(Tenant $tenant): bool
|
public function allowsTenantSync(Tenant $tenant): bool
|
||||||
@ -145,9 +141,10 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenantMemberships()
|
/** @var CapabilityResolver $resolver */
|
||||||
->where('tenant_id', $tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->exists();
|
|
||||||
|
return $resolver->isMember($this, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
use App\Policies\FindingPolicy;
|
use App\Policies\FindingPolicy;
|
||||||
use App\Policies\OperationRunPolicy;
|
use App\Policies\OperationRunPolicy;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
||||||
@ -60,6 +62,7 @@
|
|||||||
use App\Support\References\Resolvers\PolicyReferenceResolver;
|
use App\Support\References\Resolvers\PolicyReferenceResolver;
|
||||||
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
|
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
|
||||||
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -76,6 +79,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->singleton(CapabilityResolver::class);
|
||||||
|
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
@ -113,6 +119,7 @@ public function register(): void
|
|||||||
$this->app->singleton(ReferenceTypeLabelCatalog::class);
|
$this->app->singleton(ReferenceTypeLabelCatalog::class);
|
||||||
$this->app->singleton(ReferenceStatePresenter::class);
|
$this->app->singleton(ReferenceStatePresenter::class);
|
||||||
$this->app->singleton(ResolvedReferencePresenter::class);
|
$this->app->singleton(ResolvedReferencePresenter::class);
|
||||||
|
$this->app->scoped(RequestScopedDerivedStateStore::class);
|
||||||
$this->app->singleton(FallbackReferenceResolver::class);
|
$this->app->singleton(FallbackReferenceResolver::class);
|
||||||
$this->app->singleton(PolicyReferenceResolver::class);
|
$this->app->singleton(PolicyReferenceResolver::class);
|
||||||
$this->app->singleton(PolicyVersionReferenceResolver::class);
|
$this->app->singleton(PolicyVersionReferenceResolver::class);
|
||||||
|
|||||||
@ -108,7 +108,9 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
|||||||
/**
|
/**
|
||||||
* Prime membership cache for a set of tenants in one query.
|
* Prime membership cache for a set of tenants in one query.
|
||||||
*
|
*
|
||||||
* Used to avoid N+1 queries for bulk selection authorization.
|
* Used to avoid N+1 queries for bulk selection authorization while still
|
||||||
|
* reflecting membership changes that may have happened earlier in the same
|
||||||
|
* request or test process.
|
||||||
*
|
*
|
||||||
* @param array<int, int|string> $tenantIds
|
* @param array<int, int|string> $tenantIds
|
||||||
*/
|
*/
|
||||||
@ -120,26 +122,14 @@ public function primeMemberships(User $user, array $tenantIds): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$missingTenantIds = [];
|
|
||||||
foreach ($tenantIds as $tenantId) {
|
|
||||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
|
||||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
|
||||||
$missingTenantIds[] = $tenantId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($missingTenantIds === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$memberships = TenantMembership::query()
|
$memberships = TenantMembership::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->whereIn('tenant_id', $missingTenantIds)
|
->whereIn('tenant_id', $tenantIds)
|
||||||
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
||||||
|
|
||||||
$byTenantId = $memberships->keyBy('tenant_id');
|
$byTenantId = $memberships->keyBy('tenant_id');
|
||||||
|
|
||||||
foreach ($missingTenantIds as $tenantId) {
|
foreach ($tenantIds as $tenantId) {
|
||||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||||
$membership = $byTenantId->get($tenantId);
|
$membership = $byTenantId->get($tenantId);
|
||||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCaptureService
|
final class BaselineCaptureService
|
||||||
@ -22,6 +23,7 @@ final class BaselineCaptureService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +55,7 @@ public function startCapture(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
|
||||||
final class BaselineCompareService
|
final class BaselineCompareService
|
||||||
{
|
{
|
||||||
@ -25,10 +27,11 @@ public function __construct(
|
|||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
||||||
*/
|
*/
|
||||||
public function startCompare(
|
public function startCompare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -41,19 +44,19 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$precondition = $this->validatePreconditions($profile);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return ['ok' => false, 'reason_code' => $precondition];
|
return $this->failedStart($precondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedSnapshot = null;
|
$selectedSnapshot = null;
|
||||||
@ -66,14 +69,14 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||||
|
|
||||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||||
return ['ok' => false, 'reason_code' => $snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT];
|
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var BaselineSnapshot $snapshot */
|
/** @var BaselineSnapshot $snapshot */
|
||||||
@ -100,7 +103,7 @@ public function startCompare(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -133,4 +136,18 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function failedStart(string $reasonCode): array
|
||||||
|
{
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_translation' => $translation?->toArray(),
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,22 +10,28 @@
|
|||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\ResolutionOutcomeRecord;
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectDescriptor;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class BaselineContentCapturePhase
|
final class BaselineContentCapturePhase
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
|
private readonly ?SubjectResolver $subjectResolver = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||||
*
|
*
|
||||||
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
|
||||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||||
* @return array{
|
* @return array{
|
||||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||||
* gaps: array<string, int>,
|
* gaps: array<string, int>,
|
||||||
|
* gap_subjects: list<array<string, mixed>>,
|
||||||
* resume_token: ?string,
|
* resume_token: ?string,
|
||||||
* captured_versions: array<string, array{
|
* captured_versions: array<string, array{
|
||||||
* policy_type: string,
|
* policy_type: string,
|
||||||
@ -76,6 +82,8 @@ public function capture(
|
|||||||
|
|
||||||
/** @var array<string, int> $gaps */
|
/** @var array<string, int> $gaps */
|
||||||
$gaps = [];
|
$gaps = [];
|
||||||
|
/** @var list<array<string, mixed>> $gapSubjects */
|
||||||
|
$gapSubjects = [];
|
||||||
$capturedVersions = [];
|
$capturedVersions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,24 +95,40 @@ public function capture(
|
|||||||
foreach ($chunk as $subject) {
|
foreach ($chunk as $subject) {
|
||||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||||
|
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
|
||||||
|
$descriptor = $this->resolver()->describeForCapture(
|
||||||
|
$policyType !== '' ? $policyType : 'unknown',
|
||||||
|
$externalId !== '' ? $externalId : null,
|
||||||
|
$subjectKey !== '' ? $subjectKey : null,
|
||||||
|
);
|
||||||
|
|
||||||
if ($policyType === '' || $externalId === '') {
|
if ($policyType === '' || $externalId === '') {
|
||||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subjectKey = $policyType.'|'.$externalId;
|
$captureKey = $policyType.'|'.$externalId;
|
||||||
|
|
||||||
if (isset($seen[$subjectKey])) {
|
if (isset($seen[$captureKey])) {
|
||||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen[$subjectKey] = true;
|
$seen[$captureKey] = true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
||||||
|
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
||||||
|
) {
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$policy = Policy::query()
|
$policy = Policy::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -113,7 +137,7 @@ public function capture(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -152,7 +176,7 @@ public function capture(
|
|||||||
$version = $result['version'] ?? null;
|
$version = $result['version'] ?? null;
|
||||||
|
|
||||||
if ($version instanceof PolicyVersion) {
|
if ($version instanceof PolicyVersion) {
|
||||||
$capturedVersions[$subjectKey] = [
|
$capturedVersions[$captureKey] = [
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'subject_external_id' => $externalId,
|
'subject_external_id' => $externalId,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
@ -178,10 +202,10 @@ public function capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($isThrottled) {
|
if ($isThrottled) {
|
||||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||||
$stats['throttled']++;
|
$stats['throttled']++;
|
||||||
} else {
|
} else {
|
||||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +225,22 @@ public function capture(
|
|||||||
|
|
||||||
$remainingCount = max(0, count($subjects) - $processed);
|
$remainingCount = max(0, count($subjects) - $processed);
|
||||||
if ($remainingCount > 0) {
|
if ($remainingCount > 0) {
|
||||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||||
|
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
||||||
|
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
||||||
|
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
|
||||||
|
|
||||||
|
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingDescriptor = $this->resolver()->describeForCapture(
|
||||||
|
$remainingPolicyType,
|
||||||
|
$remainingExternalId,
|
||||||
|
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
|
||||||
|
);
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,11 +249,27 @@ public function capture(
|
|||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
|
'gap_subjects' => $gapSubjects,
|
||||||
'resume_token' => $resumeTokenOut,
|
'resume_token' => $resumeTokenOut,
|
||||||
'captured_versions' => $capturedVersions,
|
'captured_versions' => $capturedVersions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $gaps
|
||||||
|
* @param list<array<string, mixed>> $gapSubjects
|
||||||
|
*/
|
||||||
|
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
|
||||||
|
{
|
||||||
|
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
|
||||||
|
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return $this->subjectResolver ?? app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
private function retryDelayMs(int $attempt): int
|
private function retryDelayMs(int $attempt): int
|
||||||
{
|
{
|
||||||
$attempt = max(0, $attempt);
|
$attempt = max(0, $attempt);
|
||||||
|
|||||||
@ -136,6 +136,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
$currentTruth['icon'],
|
$currentTruth['icon'],
|
||||||
$currentTruth['iconColor'],
|
$currentTruth['iconColor'],
|
||||||
);
|
);
|
||||||
|
$operatorExplanation = $truth->operatorExplanation;
|
||||||
|
|
||||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||||
->header(new SummaryHeaderData(
|
->header(new SummaryHeaderData(
|
||||||
@ -191,12 +192,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot truth',
|
title: 'Snapshot truth',
|
||||||
items: [
|
items: array_values(array_filter([
|
||||||
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||||
|
: null,
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||||
|
: null,
|
||||||
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||||
$factory->keyFact('Next step', $truth->nextStepText()),
|
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||||
],
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
|
: null,
|
||||||
|
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||||
|
])),
|
||||||
),
|
),
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'coverage',
|
kind: 'coverage',
|
||||||
|
|||||||
@ -12,6 +12,16 @@
|
|||||||
|
|
||||||
final class FindingRiskGovernanceResolver
|
final class FindingRiskGovernanceResolver
|
||||||
{
|
{
|
||||||
|
public function resolveWorkflowFamily(Finding $finding): string
|
||||||
|
{
|
||||||
|
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||||
|
Finding::STATUS_RISK_ACCEPTED => 'accepted_risk',
|
||||||
|
Finding::STATUS_RESOLVED,
|
||||||
|
Finding::STATUS_CLOSED => 'historical',
|
||||||
|
default => 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||||
{
|
{
|
||||||
$now ??= CarbonImmutable::instance(now());
|
$now ??= CarbonImmutable::instance(now());
|
||||||
@ -111,6 +121,43 @@ public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException
|
|||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolveGovernanceValidity(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
|
{
|
||||||
|
$exception ??= $finding->relationLoaded('findingException')
|
||||||
|
? $finding->findingException
|
||||||
|
: $finding->findingException()->first();
|
||||||
|
|
||||||
|
if ($exception instanceof FindingException) {
|
||||||
|
return $this->resolveValidityState($exception, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $finding->isRiskAccepted()
|
||||||
|
? FindingException::VALIDITY_MISSING_SUPPORT
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||||
|
{
|
||||||
|
if (! $finding->isRiskAccepted()) {
|
||||||
|
return 'not_applicable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||||
|
FindingException::VALIDITY_VALID => 'healthy',
|
||||||
|
FindingException::VALIDITY_EXPIRING,
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_REVOKED,
|
||||||
|
FindingException::VALIDITY_REJECTED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT => 'attention_needed',
|
||||||
|
default => 'attention_needed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
|
||||||
|
{
|
||||||
|
return $this->resolveGovernanceAttention($finding, $exception, $now) === 'attention_needed';
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
{
|
{
|
||||||
$exception ??= $finding->relationLoaded('findingException')
|
$exception ??= $finding->relationLoaded('findingException')
|
||||||
@ -135,6 +182,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
if ($finding->isRiskAccepted()) {
|
if ($finding->isRiskAccepted()) {
|
||||||
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
||||||
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
||||||
|
'expiring_exception' => 'The linked exception is still valid, but it is nearing expiry and needs review.',
|
||||||
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
||||||
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||||
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
||||||
@ -147,6 +195,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($exceptionStatus) {
|
return match ($exceptionStatus) {
|
||||||
|
FindingException::STATUS_EXPIRING => 'The linked exception is nearing expiry and needs review.',
|
||||||
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
||||||
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||||
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
||||||
@ -154,6 +203,59 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolveHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return match ((string) $finding->status) {
|
||||||
|
Finding::STATUS_RESOLVED => $this->resolvedHistoricalContext($finding),
|
||||||
|
Finding::STATUS_CLOSED => $this->closedHistoricalContext($finding),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||||
|
{
|
||||||
|
return match ($this->resolveWorkflowFamily($finding)) {
|
||||||
|
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||||
|
? 'Accepted risk remains visible because current governance is still valid.'
|
||||||
|
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||||
|
'historical' => match ((string) $finding->status) {
|
||||||
|
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
default => 'This finding is historical workflow context.',
|
||||||
|
},
|
||||||
|
default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
|
{
|
||||||
|
if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) {
|
||||||
|
return 'Review the overdue finding and update ownership or next workflow step.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') {
|
||||||
|
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||||
|
FindingException::VALIDITY_VALID => 'Keep the exception under review until remediation is possible.',
|
||||||
|
FindingException::VALIDITY_EXPIRING => 'Renew or review the exception before the current governance window lapses.',
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_REVOKED,
|
||||||
|
FindingException::VALIDITY_REJECTED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT => 'Restore valid governance or move the finding back into active remediation.',
|
||||||
|
default => 'Review the current governance state before treating this accepted risk as stable.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||||
|
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->assignee_user_id === null || $finding->owner_user_id === null) {
|
||||||
|
return 'Assign an owner and next workflow step so follow-up does not stall.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.';
|
||||||
|
}
|
||||||
|
|
||||||
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||||
{
|
{
|
||||||
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
||||||
@ -227,4 +329,26 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
|||||||
? CarbonImmutable::instance($fallback)
|
? CarbonImmutable::instance($fallback)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
$reason = (string) ($finding->resolved_reason ?? '');
|
||||||
|
|
||||||
|
return match ($reason) {
|
||||||
|
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||||
|
'permission_granted',
|
||||||
|
'permission_removed_from_registry',
|
||||||
|
'role_assignment_removed',
|
||||||
|
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||||
|
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function closedHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return match ((string) ($finding->closed_reason ?? '')) {
|
||||||
|
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||||
|
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,15 @@
|
|||||||
namespace App\Services\TenantReviews;
|
namespace App\Services\TenantReviews;
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@ -20,6 +23,7 @@ public function __construct(
|
|||||||
private readonly TenantReviewReadinessGate $readinessGate,
|
private readonly TenantReviewReadinessGate $readinessGate,
|
||||||
private readonly TenantReviewService $reviewService,
|
private readonly TenantReviewService $reviewService,
|
||||||
private readonly WorkspaceAuditLogger $auditLogger,
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function publish(TenantReview $review, User $user): TenantReview
|
public function publish(TenantReview $review, User $user): TenantReview
|
||||||
@ -64,6 +68,8 @@ public function publish(TenantReview $review, User $user): TenantReview
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
|
||||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +110,8 @@ public function archive(TenantReview $review, User $user): TenantReview
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
|
||||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +134,7 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
|||||||
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
$nextReview = DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
||||||
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
||||||
|
|
||||||
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
||||||
@ -156,5 +164,23 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
|||||||
|
|
||||||
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
$this->invalidateArtifactTruthCache($nextReview);
|
||||||
|
|
||||||
|
return $nextReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateArtifactTruthCache(TenantReview $review): void
|
||||||
|
{
|
||||||
|
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');
|
||||||
|
|
||||||
|
$review->loadMissing('currentExportReviewPack');
|
||||||
|
|
||||||
|
$pack = $review->currentExportReviewPack;
|
||||||
|
|
||||||
|
if ($pack instanceof ReviewPack) {
|
||||||
|
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $pack, 'review_pack');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor
|
|||||||
family: TenantActionFamily::Neutral,
|
family: TenantActionFamily::Neutral,
|
||||||
label: 'View',
|
label: 'View',
|
||||||
icon: 'heroicon-o-eye',
|
icon: 'heroicon-o-eye',
|
||||||
group: 'primary',
|
group: 'inspect',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
||||||
|
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
|
||||||
|
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
|
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||||
|
|||||||
@ -11,6 +11,8 @@ enum BadgeDomain: string
|
|||||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||||
|
case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
|
||||||
|
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
|
||||||
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
|
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
|
||||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||||
|
|||||||
@ -18,8 +18,8 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
||||||
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
||||||
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
||||||
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
|
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'danger', 'heroicon-o-x-circle'),
|
||||||
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'gray', 'heroicon-o-question-mark-circle'),
|
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'danger', 'heroicon-o-question-mark-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
||||||
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
||||||
Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'),
|
Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'),
|
||||||
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'success', 'heroicon-o-check-circle'),
|
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'gray', 'heroicon-o-check-circle'),
|
||||||
Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
||||||
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?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;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
|
final class OperatorExplanationEvaluationResultBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||||
|
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
||||||
|
'failed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-x-circle'),
|
||||||
|
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||||
|
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?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;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
|
final class OperatorExplanationTrustworthinessBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'trustworthy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'),
|
||||||
|
'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'),
|
||||||
|
'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'partial' => [
|
'partial' => [
|
||||||
'axis' => 'data_coverage',
|
'axis' => 'data_coverage',
|
||||||
'label' => 'Partial',
|
'label' => 'Partially complete',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'required',
|
'next_action_policy' => 'required',
|
||||||
@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'stale' => [
|
'stale' => [
|
||||||
'axis' => 'data_freshness',
|
'axis' => 'data_freshness',
|
||||||
'label' => 'Stale',
|
'label' => 'Refresh recommended',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'optional',
|
'next_action_policy' => 'optional',
|
||||||
@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'axis' => 'publication_readiness',
|
'axis' => 'publication_readiness',
|
||||||
'label' => 'Blocked',
|
'label' => 'Publication blocked',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'required',
|
'next_action_policy' => 'required',
|
||||||
@ -220,6 +220,100 @@ final class OperatorOutcomeTaxonomy
|
|||||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'operator_explanation_evaluation_result' => [
|
||||||
|
'full_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Complete result',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Full result'],
|
||||||
|
'notes' => 'The result can be read as complete for the intended operator decision.',
|
||||||
|
],
|
||||||
|
'incomplete_result' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Incomplete result',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Partial result'],
|
||||||
|
'notes' => 'A result exists, but missing or partial coverage limits what it means.',
|
||||||
|
],
|
||||||
|
'suppressed_result' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Suppressed result',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Suppressed'],
|
||||||
|
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
||||||
|
],
|
||||||
|
'failed_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Failed result',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Execution failed'],
|
||||||
|
'notes' => 'The workflow ended without producing a usable result and needs operator investigation.',
|
||||||
|
],
|
||||||
|
'no_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'No issues detected',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['No result'],
|
||||||
|
'notes' => 'The workflow produced no decision-relevant follow-up for the operator.',
|
||||||
|
],
|
||||||
|
'unavailable' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Result unavailable',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Unavailable'],
|
||||||
|
'notes' => 'A usable result is not currently available for this surface.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'operator_explanation_trustworthiness' => [
|
||||||
|
'trustworthy' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Trustworthy',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Decision grade'],
|
||||||
|
'notes' => 'The operator can rely on this result for the intended task.',
|
||||||
|
],
|
||||||
|
'limited_confidence' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Limited confidence',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['Use with caution'],
|
||||||
|
'notes' => 'The result is still useful, but the operator should account for documented limitations.',
|
||||||
|
],
|
||||||
|
'diagnostic_only' => [
|
||||||
|
'axis' => 'evidence_depth',
|
||||||
|
'label' => 'Diagnostic only',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Diagnostics only'],
|
||||||
|
'notes' => 'The result is suitable for diagnostics only, not for a final decision.',
|
||||||
|
],
|
||||||
|
'unusable' => [
|
||||||
|
'axis' => 'operator_actionability',
|
||||||
|
'label' => 'Not usable yet',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Unusable'],
|
||||||
|
'notes' => 'The operator should not rely on this result until the blocking issue is resolved.',
|
||||||
|
],
|
||||||
|
],
|
||||||
'baseline_snapshot_lifecycle' => [
|
'baseline_snapshot_lifecycle' => [
|
||||||
'building' => [
|
'building' => [
|
||||||
'axis' => 'execution_lifecycle',
|
'axis' => 'execution_lifecycle',
|
||||||
|
|||||||
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class BaselineCompareEvidenceGapDetails
|
||||||
|
{
|
||||||
|
public static function fromOperationRun(?OperationRun $run): array
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
||||||
|
return self::empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fromContext($run->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function fromContext(array $context): array
|
||||||
|
{
|
||||||
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($baselineCompare)) {
|
||||||
|
return self::empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fromBaselineCompare($baselineCompare);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $baselineCompare
|
||||||
|
*/
|
||||||
|
public static function fromBaselineCompare(array $baselineCompare): array
|
||||||
|
{
|
||||||
|
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||||
|
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
||||||
|
|
||||||
|
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
||||||
|
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
||||||
|
|
||||||
|
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
|
||||||
|
if (! array_key_exists($reasonCode, $byReason)) {
|
||||||
|
$byReason[$reasonCode] = count($subjects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = self::normalizeTotalCount(
|
||||||
|
$evidenceGaps['count'] ?? null,
|
||||||
|
$byReason,
|
||||||
|
$normalizedSubjects['subjects'],
|
||||||
|
);
|
||||||
|
$detailState = self::detailState($count, $normalizedSubjects);
|
||||||
|
$buckets = [];
|
||||||
|
|
||||||
|
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
|
||||||
|
$rows = $detailState === 'structured_details_recorded'
|
||||||
|
? array_map(
|
||||||
|
static fn (array $subject): array => self::projectSubjectRow($subject),
|
||||||
|
$normalizedSubjects['subjects'][$reasonCode] ?? [],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
$reasonCount = $byReason[$reasonCode] ?? count($rows);
|
||||||
|
|
||||||
|
if ($reasonCount <= 0 && $rows === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordedCount = count($rows);
|
||||||
|
$structuralCount = count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
|
||||||
|
));
|
||||||
|
$transientCount = count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
|
||||||
|
));
|
||||||
|
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
|
||||||
|
|
||||||
|
$searchText = trim(implode(' ', array_filter([
|
||||||
|
Str::lower($reasonCode),
|
||||||
|
Str::lower(self::reasonLabel($reasonCode)),
|
||||||
|
...array_map(
|
||||||
|
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
||||||
|
$rows,
|
||||||
|
),
|
||||||
|
])));
|
||||||
|
|
||||||
|
$buckets[] = [
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'count' => $reasonCount,
|
||||||
|
'recorded_count' => $recordedCount,
|
||||||
|
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
||||||
|
'structural_count' => $structuralCount,
|
||||||
|
'operational_count' => $operationalCount,
|
||||||
|
'transient_count' => $transientCount,
|
||||||
|
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
|
||||||
|
'search_text' => $searchText,
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordedSubjectsTotal = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$structuralCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$operationalCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$transientCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$legacyMode = $detailState === 'legacy_broad_reason';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'count' => $count,
|
||||||
|
'by_reason' => $byReason,
|
||||||
|
'detail_state' => $detailState,
|
||||||
|
'recorded_subjects_total' => $recordedSubjectsTotal,
|
||||||
|
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
||||||
|
'structural_count' => $structuralCount,
|
||||||
|
'operational_count' => $operationalCount,
|
||||||
|
'transient_count' => $transientCount,
|
||||||
|
'legacy_mode' => $legacyMode,
|
||||||
|
'requires_regeneration' => $legacyMode,
|
||||||
|
],
|
||||||
|
'buckets' => $buckets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $baselineCompare
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function diagnosticsPayload(array $baselineCompare): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
||||||
|
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||||
|
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||||
|
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||||
|
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||||
|
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||||
|
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reasonLabel(string $reason): string
|
||||||
|
{
|
||||||
|
$reason = trim($reason);
|
||||||
|
|
||||||
|
return match ($reason) {
|
||||||
|
'ambiguous_match' => 'Ambiguous inventory match',
|
||||||
|
'policy_record_missing' => 'Policy record missing',
|
||||||
|
'inventory_record_missing' => 'Inventory record missing',
|
||||||
|
'foundation_not_policy_backed' => 'Foundation not policy-backed',
|
||||||
|
'invalid_subject' => 'Invalid subject',
|
||||||
|
'duplicate_subject' => 'Duplicate subject',
|
||||||
|
'capture_failed' => 'Evidence capture failed',
|
||||||
|
'retryable_capture_failure' => 'Retryable evidence capture failure',
|
||||||
|
'budget_exhausted' => 'Capture budget exhausted',
|
||||||
|
'throttled' => 'Graph throttled',
|
||||||
|
'invalid_support_config' => 'Invalid support configuration',
|
||||||
|
'missing_current' => 'Missing current evidence',
|
||||||
|
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
||||||
|
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
||||||
|
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
||||||
|
'rollout_disabled' => 'Rollout disabled',
|
||||||
|
'policy_not_found' => 'Legacy policy not found',
|
||||||
|
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function subjectClassLabel(string $subjectClass): string
|
||||||
|
{
|
||||||
|
return match (trim($subjectClass)) {
|
||||||
|
SubjectClass::PolicyBacked->value => 'Policy-backed',
|
||||||
|
SubjectClass::InventoryBacked->value => 'Inventory-backed',
|
||||||
|
SubjectClass::FoundationBacked->value => 'Foundation-backed',
|
||||||
|
default => 'Derived',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
|
||||||
|
{
|
||||||
|
return match (trim($resolutionOutcome)) {
|
||||||
|
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
|
||||||
|
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
|
||||||
|
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
|
||||||
|
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
|
||||||
|
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
||||||
|
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
|
||||||
|
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
|
||||||
|
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
|
||||||
|
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
|
||||||
|
ResolutionOutcome::Throttled->value => 'Graph throttled',
|
||||||
|
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
|
||||||
|
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
|
||||||
|
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
|
||||||
|
{
|
||||||
|
return match (trim($operatorActionCategory)) {
|
||||||
|
OperatorActionCategory::Retry->value => 'Retry',
|
||||||
|
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
|
||||||
|
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
|
||||||
|
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
|
||||||
|
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
|
||||||
|
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
|
||||||
|
default => 'No action',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
||||||
|
*/
|
||||||
|
public static function topReasons(array $byReason, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeCounts($byReason);
|
||||||
|
arsort($normalized);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (string $reason, int $count): array => [
|
||||||
|
'reason_code' => $reason,
|
||||||
|
'reason_label' => self::reasonLabel($reason),
|
||||||
|
'count' => $count,
|
||||||
|
],
|
||||||
|
array_slice(array_keys($normalized), 0, $limit),
|
||||||
|
array_slice(array_values($normalized), 0, $limit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $buckets
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function tableRows(array $buckets): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($buckets as $bucket) {
|
||||||
|
if (! is_array($bucket)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
||||||
|
|
||||||
|
foreach ($bucketRows as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
||||||
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
||||||
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
||||||
|
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
|
||||||
|
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
|
||||||
|
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
|
||||||
|
|
||||||
|
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'subject_class' => $subjectClass,
|
||||||
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||||
|
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
|
||||||
|
'resolution_outcome' => $resolutionOutcome,
|
||||||
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
'operator_action_category' => $operatorActionCategory,
|
||||||
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
'structural' => (bool) ($row['structural'] ?? false),
|
||||||
|
'retryable' => (bool) ($row['retryable'] ?? false),
|
||||||
|
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function reasonFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['reason_code'] => (string) $row['reason_label'],
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function policyTypeFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->pluck('policy_type')
|
||||||
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->mapWithKeys(fn (string $value): array => [$value => $value])
|
||||||
|
->sortKeysUsing('strnatcasecmp')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function subjectClassFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function actionCategoryFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function empty(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'count' => 0,
|
||||||
|
'by_reason' => [],
|
||||||
|
'detail_state' => 'no_gaps',
|
||||||
|
'recorded_subjects_total' => 0,
|
||||||
|
'missing_detail_count' => 0,
|
||||||
|
'structural_count' => 0,
|
||||||
|
'operational_count' => 0,
|
||||||
|
'transient_count' => 0,
|
||||||
|
'legacy_mode' => false,
|
||||||
|
'requires_regeneration' => false,
|
||||||
|
],
|
||||||
|
'buckets' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private static function normalizeCounts(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($value as $reason => $count) {
|
||||||
|
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intCount = (int) $count;
|
||||||
|
|
||||||
|
if ($intCount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[trim($reason)] = $intCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($normalized);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* subjects: array<string, list<array<string, mixed>>>,
|
||||||
|
* legacy_mode: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function normalizeSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($value)) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
$normalized = self::normalizeStructuredSubject($item);
|
||||||
|
|
||||||
|
if ($normalized === null) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[$normalized['reason_code']][] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as &$bucket) {
|
||||||
|
usort($bucket, static function (array $left, array $right): int {
|
||||||
|
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
|
||||||
|
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($bucket);
|
||||||
|
|
||||||
|
ksort($subjects);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subjects' => $subjects,
|
||||||
|
'legacy_mode' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function normalizeStructuredSubject(mixed $value): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = self::stringOrNull($value['policy_type'] ?? null);
|
||||||
|
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
|
||||||
|
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
|
||||||
|
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
|
||||||
|
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
|
||||||
|
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
|
||||||
|
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
|
||||||
|
|
||||||
|
if ($policyType === null
|
||||||
|
|| $subjectKey === null
|
||||||
|
|| $subjectClass === null
|
||||||
|
|| $resolutionPath === null
|
||||||
|
|| $resolutionOutcome === null
|
||||||
|
|| $reasonCode === null
|
||||||
|
|| $operatorActionCategory === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|
||||||
|
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|
||||||
|
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|
||||||
|
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
|
||||||
|
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
|
||||||
|
|
||||||
|
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
|
||||||
|
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'subject_class' => $subjectClass,
|
||||||
|
'resolution_path' => $resolutionPath,
|
||||||
|
'resolution_outcome' => $resolutionOutcome,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'operator_action_category' => $operatorActionCategory,
|
||||||
|
'structural' => self::boolOrFalse($value['structural'] ?? null),
|
||||||
|
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
|
||||||
|
'source_model_expected' => $sourceModelExpected,
|
||||||
|
'source_model_found' => $sourceModelFound,
|
||||||
|
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @param array<string, list<array<string, mixed>>> $subjects
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function orderedReasons(array $byReason, array $subjects): array
|
||||||
|
{
|
||||||
|
$reasons = array_keys($byReason);
|
||||||
|
|
||||||
|
foreach (array_keys($subjects) as $reason) {
|
||||||
|
if (! in_array($reason, $reasons, true)) {
|
||||||
|
$reasons[] = $reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @param array<string, list<array<string, mixed>>> $subjects
|
||||||
|
*/
|
||||||
|
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
||||||
|
{
|
||||||
|
if (is_numeric($count)) {
|
||||||
|
$intCount = (int) $count;
|
||||||
|
|
||||||
|
if ($intCount >= 0) {
|
||||||
|
return $intCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$byReasonCount = array_sum($byReason);
|
||||||
|
|
||||||
|
if ($byReasonCount > 0) {
|
||||||
|
return $byReasonCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_sum(array_map(
|
||||||
|
static fn (array $rows): int => count($rows),
|
||||||
|
$subjects,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
|
||||||
|
*/
|
||||||
|
private static function detailState(int $count, array $subjects): string
|
||||||
|
{
|
||||||
|
if ($count <= 0) {
|
||||||
|
return 'no_gaps';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjects['legacy_mode']) {
|
||||||
|
return 'legacy_broad_reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bucketDetailState(string $detailState, int $recordedCount): string
|
||||||
|
{
|
||||||
|
if ($detailState === 'legacy_broad_reason') {
|
||||||
|
return 'legacy_broad_reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recordedCount > 0) {
|
||||||
|
return 'structured_details_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'details_not_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function projectSubjectRow(array $subject): array
|
||||||
|
{
|
||||||
|
$reasonCode = (string) $subject['reason_code'];
|
||||||
|
$subjectClass = (string) $subject['subject_class'];
|
||||||
|
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
||||||
|
$operatorActionCategory = (string) $subject['operator_action_category'];
|
||||||
|
|
||||||
|
return array_merge($subject, [
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||||
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
||||||
|
$reasonCode,
|
||||||
|
self::reasonLabel($reasonCode),
|
||||||
|
(string) ($subject['policy_type'] ?? ''),
|
||||||
|
(string) ($subject['subject_key'] ?? ''),
|
||||||
|
$subjectClass,
|
||||||
|
self::subjectClassLabel($subjectClass),
|
||||||
|
(string) ($subject['resolution_path'] ?? ''),
|
||||||
|
$resolutionOutcome,
|
||||||
|
self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
$operatorActionCategory,
|
||||||
|
self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
(string) ($subject['subject_external_id'] ?? ''),
|
||||||
|
])))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return $trimmed !== '' ? $trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function intOrNull(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function boolOrFalse(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value) || is_string($value)) {
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
247
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
|
||||||
|
final class BaselineCompareExplanationRegistry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperatorExplanationBuilder $builder,
|
||||||
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
$reason = $stats->reasonCode !== null
|
||||||
|
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
||||||
|
: null;
|
||||||
|
$isFailed = $stats->state === 'failed';
|
||||||
|
$isInProgress = $stats->state === 'comparing';
|
||||||
|
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
||||||
|
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
||||||
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
|
$findingsCount = (int) ($stats->findingsCount ?? 0);
|
||||||
|
$executionOutcome = match ($stats->state) {
|
||||||
|
'comparing' => 'in_progress',
|
||||||
|
'failed' => 'failed',
|
||||||
|
default => $hasWarnings ? 'completed_with_follow_up' : 'completed',
|
||||||
|
};
|
||||||
|
$executionOutcomeLabel = match ($executionOutcome) {
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
'failed' => 'Execution failed',
|
||||||
|
'completed_with_follow_up' => 'Completed with follow-up',
|
||||||
|
default => 'Completed successfully',
|
||||||
|
};
|
||||||
|
$family = $reason?->absencePattern !== null
|
||||||
|
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
||||||
|
: null;
|
||||||
|
$family ??= match (true) {
|
||||||
|
$isInProgress => ExplanationFamily::InProgress,
|
||||||
|
$isFailed => ExplanationFamily::BlockedPrerequisite,
|
||||||
|
$stats->state === 'no_tenant',
|
||||||
|
$stats->state === 'no_assignment',
|
||||||
|
$stats->state === 'no_snapshot',
|
||||||
|
$stats->state === 'idle' => ExplanationFamily::Unavailable,
|
||||||
|
$findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected,
|
||||||
|
$hasWarnings => ExplanationFamily::CompletedButLimited,
|
||||||
|
default => ExplanationFamily::TrustworthyResult,
|
||||||
|
};
|
||||||
|
$trustworthiness = $reason?->trustImpact !== null
|
||||||
|
? TrustworthinessLevel::tryFrom($reason->trustImpact)
|
||||||
|
: null;
|
||||||
|
$trustworthiness ??= match (true) {
|
||||||
|
$family === ExplanationFamily::NoIssuesDetected,
|
||||||
|
$family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy,
|
||||||
|
$family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
||||||
|
default => TrustworthinessLevel::Unusable,
|
||||||
|
};
|
||||||
|
$evaluationResult = $isFailed
|
||||||
|
? 'failed_result'
|
||||||
|
: match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable,
|
||||||
|
ExplanationFamily::InProgress => 'unavailable',
|
||||||
|
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
||||||
|
? 'suppressed_result'
|
||||||
|
: 'incomplete_result',
|
||||||
|
};
|
||||||
|
$headline = match (true) {
|
||||||
|
$isFailed => 'The comparison failed before it produced a usable result.',
|
||||||
|
default => match ($family) {
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
||||||
|
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
||||||
|
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
||||||
|
? 'The comparison found drift, but the result needs caution.'
|
||||||
|
: 'The comparison finished, but the current result is not an all-clear.',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
||||||
|
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
||||||
|
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
||||||
|
ExplanationFamily::InProgress => 'The comparison is still running.',
|
||||||
|
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
$coverageStatement = match (true) {
|
||||||
|
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
||||||
|
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
|
||||||
|
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
||||||
|
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
||||||
|
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
||||||
|
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
|
||||||
|
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
||||||
|
default => 'Coverage matched the in-scope compare input for this run.',
|
||||||
|
};
|
||||||
|
$reliabilityStatement = $isFailed
|
||||||
|
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
|
||||||
|
: match ($trustworthiness) {
|
||||||
|
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
||||||
|
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
||||||
|
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
||||||
|
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
||||||
|
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
||||||
|
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
||||||
|
};
|
||||||
|
$nextActionText = $isFailed
|
||||||
|
? 'Review the failed compare run before relying on this tenant posture'
|
||||||
|
: ($reason?->firstNextStep()?->label ?? match ($family) {
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
||||||
|
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
||||||
|
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
||||||
|
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
||||||
|
? 'Run the baseline compare to generate a result'
|
||||||
|
: 'Review the blocking baseline or scope prerequisite',
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->builder->build(
|
||||||
|
family: $family,
|
||||||
|
headline: $headline,
|
||||||
|
executionOutcome: $executionOutcome,
|
||||||
|
executionOutcomeLabel: $executionOutcomeLabel,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
trustworthinessLevel: $trustworthiness,
|
||||||
|
reliabilityStatement: $reliabilityStatement,
|
||||||
|
coverageStatement: $coverageStatement,
|
||||||
|
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
||||||
|
dominantCauseLabel: $reason?->operatorLabel,
|
||||||
|
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
||||||
|
nextActionCategory: $isFailed
|
||||||
|
? 'inspect_run'
|
||||||
|
: ($family === ExplanationFamily::NoIssuesDetected
|
||||||
|
? 'none'
|
||||||
|
: match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
||||||
|
default => 'review_evidence_gaps',
|
||||||
|
}),
|
||||||
|
nextActionText: $nextActionText,
|
||||||
|
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
||||||
|
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
||||||
|
diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, CountDescriptor>
|
||||||
|
*/
|
||||||
|
private function countDescriptors(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
bool $hasCoverageWarnings,
|
||||||
|
bool $hasEvidenceGaps,
|
||||||
|
): array {
|
||||||
|
$descriptors = [];
|
||||||
|
|
||||||
|
if ($stats->findingsCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Findings shown',
|
||||||
|
value: (int) $stats->findingsCount,
|
||||||
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||||
|
qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->uncoveredTypesCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Uncovered types',
|
||||||
|
value: (int) $stats->uncoveredTypesCount,
|
||||||
|
role: CountDescriptor::ROLE_COVERAGE,
|
||||||
|
qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapsCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Evidence gaps',
|
||||||
|
value: (int) $stats->evidenceGapsCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Structural gaps',
|
||||||
|
value: (int) $stats->evidenceGapStructuralCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'product or support limit',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Operational gaps',
|
||||||
|
value: (int) $stats->evidenceGapOperationalCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'local evidence missing',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Transient gaps',
|
||||||
|
value: (int) $stats->evidenceGapTransientCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'retry may help',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->severityCounts !== []) {
|
||||||
|
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
||||||
|
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
||||||
|
|
||||||
|
if ($value === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: $label,
|
||||||
|
value: $value,
|
||||||
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $descriptors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
|
||||||
enum BaselineCompareReasonCode: string
|
enum BaselineCompareReasonCode: string
|
||||||
{
|
{
|
||||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||||
@ -11,6 +14,9 @@ enum BaselineCompareReasonCode: string
|
|||||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||||
case RolloutDisabled = 'rollout_disabled';
|
case RolloutDisabled = 'rollout_disabled';
|
||||||
case NoDriftDetected = 'no_drift_detected';
|
case NoDriftDetected = 'no_drift_detected';
|
||||||
|
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||||
|
case GovernanceExpiring = 'governance_expiring';
|
||||||
|
case GovernanceLapsed = 'governance_lapsed';
|
||||||
|
|
||||||
public function message(): string
|
public function message(): string
|
||||||
{
|
{
|
||||||
@ -20,6 +26,56 @@ public function message(): string
|
|||||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||||
|
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
||||||
|
self::GovernanceExpiring => 'Accepted-risk governance is nearing expiry and needs review.',
|
||||||
|
self::GovernanceLapsed => 'Accepted-risk governance has lapsed and needs follow-up.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function explanationFamily(): ExplanationFamily
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::RolloutDisabled,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||||
|
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trustworthinessLevel(): TrustworthinessLevel
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
self::RolloutDisabled,
|
||||||
|
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function absencePattern(): ?string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => 'true_no_result',
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => 'suppressed_output',
|
||||||
|
self::RolloutDisabled => 'blocked_prerequisite',
|
||||||
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsPositiveClaim(): bool
|
||||||
|
{
|
||||||
|
return $this === self::NoDriftDetected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
final class BaselineCompareStats
|
final class BaselineCompareStats
|
||||||
@ -22,6 +24,32 @@ final class BaselineCompareStats
|
|||||||
* @param array<string, int> $severityCounts
|
* @param array<string, int> $severityCounts
|
||||||
* @param list<string> $uncoveredTypes
|
* @param list<string> $uncoveredTypes
|
||||||
* @param array<string, int> $evidenceGapsTopReasons
|
* @param array<string, int> $evidenceGapsTopReasons
|
||||||
|
* @param array{
|
||||||
|
* summary: array{
|
||||||
|
* count: int,
|
||||||
|
* by_reason: array<string, int>,
|
||||||
|
* detail_state: string,
|
||||||
|
* recorded_subjects_total: int,
|
||||||
|
* missing_detail_count: int
|
||||||
|
* },
|
||||||
|
* buckets: list<array{
|
||||||
|
* reason_code: string,
|
||||||
|
* reason_label: string,
|
||||||
|
* count: int,
|
||||||
|
* recorded_count: int,
|
||||||
|
* missing_detail_count: int,
|
||||||
|
* detail_state: string,
|
||||||
|
* search_text: string,
|
||||||
|
* rows: list<array{
|
||||||
|
* reason_code: string,
|
||||||
|
* reason_label: string,
|
||||||
|
* policy_type: string,
|
||||||
|
* subject_key: string,
|
||||||
|
* search_text: string
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* } $evidenceGapDetails
|
||||||
|
* @param array<string, mixed> $baselineCompareDiagnostics
|
||||||
*/
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly string $state,
|
public readonly string $state,
|
||||||
@ -30,6 +58,7 @@ private function __construct(
|
|||||||
public readonly ?int $profileId,
|
public readonly ?int $profileId,
|
||||||
public readonly ?int $snapshotId,
|
public readonly ?int $snapshotId,
|
||||||
public readonly ?int $duplicateNamePoliciesCount,
|
public readonly ?int $duplicateNamePoliciesCount,
|
||||||
|
public readonly ?int $duplicateNameSubjectsCount,
|
||||||
public readonly ?int $operationRunId,
|
public readonly ?int $operationRunId,
|
||||||
public readonly ?int $findingsCount,
|
public readonly ?int $findingsCount,
|
||||||
public readonly array $severityCounts,
|
public readonly array $severityCounts,
|
||||||
@ -45,6 +74,17 @@ private function __construct(
|
|||||||
public readonly ?int $evidenceGapsCount = null,
|
public readonly ?int $evidenceGapsCount = null,
|
||||||
public readonly array $evidenceGapsTopReasons = [],
|
public readonly array $evidenceGapsTopReasons = [],
|
||||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||||
|
public readonly array $evidenceGapDetails = [],
|
||||||
|
public readonly array $baselineCompareDiagnostics = [],
|
||||||
|
public readonly ?int $evidenceGapStructuralCount = null,
|
||||||
|
public readonly ?int $evidenceGapOperationalCount = null,
|
||||||
|
public readonly ?int $evidenceGapTransientCount = null,
|
||||||
|
public readonly ?bool $evidenceGapLegacyMode = null,
|
||||||
|
public readonly int $overdueOpenFindingsCount = 0,
|
||||||
|
public readonly int $expiringGovernanceCount = 0,
|
||||||
|
public readonly int $lapsedGovernanceCount = 0,
|
||||||
|
public readonly int $activeNonNewFindingsCount = 0,
|
||||||
|
public readonly int $highSeverityActiveFindingsCount = 0,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function forTenant(?Tenant $tenant): self
|
public static function forTenant(?Tenant $tenant): self
|
||||||
@ -89,7 +129,9 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
: null;
|
: null;
|
||||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||||
|
|
||||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
|
||||||
|
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
|
||||||
|
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
|
||||||
|
|
||||||
if ($snapshotId === null) {
|
if ($snapshotId === null) {
|
||||||
return new self(
|
return new self(
|
||||||
@ -99,6 +141,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
snapshotId: null,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -120,6 +163,22 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||||
|
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||||
|
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||||
|
$findingAttentionCounts = self::findingAttentionCounts($tenant);
|
||||||
|
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
||||||
|
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['structural_count']
|
||||||
|
: null;
|
||||||
|
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['operational_count']
|
||||||
|
: null;
|
||||||
|
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['transient_count']
|
||||||
|
: null;
|
||||||
|
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
|
||||||
|
? (bool) $evidenceGapSummary['legacy_mode']
|
||||||
|
: null;
|
||||||
|
|
||||||
// Active run (queued/running)
|
// Active run (queued/running)
|
||||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||||
@ -130,6 +189,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -145,6 +205,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
|
evidenceGapDetails: $evidenceGapDetails,
|
||||||
|
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||||
|
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||||
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +233,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -177,6 +249,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
|
evidenceGapDetails: $evidenceGapDetails,
|
||||||
|
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||||
|
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||||
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,6 +299,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -231,6 +315,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
|
evidenceGapDetails: $evidenceGapDetails,
|
||||||
|
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||||
|
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||||
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,6 +339,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: 0,
|
findingsCount: 0,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -259,6 +355,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
|
evidenceGapDetails: $evidenceGapDetails,
|
||||||
|
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||||
|
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||||
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +376,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -284,6 +392,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
|
evidenceGapDetails: $evidenceGapDetails,
|
||||||
|
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||||
|
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||||
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,6 +459,7 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
profileId: (int) $profile->getKey(),
|
profileId: (int) $profile->getKey(),
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: null,
|
duplicateNamePoliciesCount: null,
|
||||||
|
duplicateNameSubjectsCount: null,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
severityCounts: [
|
severityCounts: [
|
||||||
@ -355,17 +475,23 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
/**
|
||||||
|
* @return array{policy_count: int, subject_count: int}
|
||||||
|
*/
|
||||||
|
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
|
||||||
{
|
{
|
||||||
$policyTypes = $effectiveScope->allTypes();
|
$policyTypes = $effectiveScope->allTypes();
|
||||||
|
|
||||||
if ($policyTypes === []) {
|
if ($policyTypes === []) {
|
||||||
return 0;
|
return [
|
||||||
|
'policy_count' => 0,
|
||||||
|
'subject_count' => 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
||||||
|
|
||||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
|
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
|
||||||
/**
|
/**
|
||||||
* @var array<string, int> $countsByKey
|
* @var array<string, int> $countsByKey
|
||||||
*/
|
*/
|
||||||
@ -398,14 +524,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
|||||||
});
|
});
|
||||||
|
|
||||||
$duplicatePolicies = 0;
|
$duplicatePolicies = 0;
|
||||||
|
$duplicateSubjects = 0;
|
||||||
|
|
||||||
foreach ($countsByKey as $count) {
|
foreach ($countsByKey as $count) {
|
||||||
if ($count > 1) {
|
if ($count > 1) {
|
||||||
|
$duplicateSubjects++;
|
||||||
$duplicatePolicies += $count;
|
$duplicatePolicies += $count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $duplicatePolicies;
|
return [
|
||||||
|
'policy_count' => $duplicatePolicies,
|
||||||
|
'subject_count' => $duplicateSubjects,
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (app()->environment('testing')) {
|
if (app()->environment('testing')) {
|
||||||
@ -419,7 +550,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
|||||||
$latestInventorySyncRunId ?? 'all',
|
$latestInventorySyncRunId ?? 'all',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
/** @var array{policy_count: int, subject_count: int} $stats */
|
||||||
|
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||||
|
|
||||||
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
||||||
@ -513,48 +647,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
|||||||
return [null, []];
|
return [null, []];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$details = self::evidenceGapDetailsForRun($run);
|
||||||
|
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
|
||||||
|
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
|
||||||
|
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
|
||||||
|
|
||||||
|
return [$count, array_slice($byReason, 0, 6, true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* summary: array{
|
||||||
|
* count: int,
|
||||||
|
* by_reason: array<string, int>,
|
||||||
|
* detail_state: string,
|
||||||
|
* recorded_subjects_total: int,
|
||||||
|
* missing_detail_count: int
|
||||||
|
* },
|
||||||
|
* buckets: list<array{
|
||||||
|
* reason_code: string,
|
||||||
|
* reason_label: string,
|
||||||
|
* count: int,
|
||||||
|
* recorded_count: int,
|
||||||
|
* missing_detail_count: int,
|
||||||
|
* detail_state: string,
|
||||||
|
* search_text: string,
|
||||||
|
* rows: list<array{
|
||||||
|
* reason_code: string,
|
||||||
|
* reason_label: string,
|
||||||
|
* policy_type: string,
|
||||||
|
* subject_key: string,
|
||||||
|
* search_text: string
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function evidenceGapDetailsForRun(?OperationRun $run): array
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return BaselineCompareEvidenceGapDetails::fromContext([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||||
|
|
||||||
if (! is_array($baselineCompare)) {
|
if (! is_array($baselineCompare)) {
|
||||||
return [null, []];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
||||||
|
|
||||||
if (! is_array($gaps)) {
|
|
||||||
return [null, []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = $gaps['count'] ?? null;
|
|
||||||
$count = is_numeric($count) ? (int) $count : null;
|
|
||||||
|
|
||||||
$byReason = $gaps['by_reason'] ?? null;
|
|
||||||
$byReason = is_array($byReason) ? $byReason : [];
|
|
||||||
|
|
||||||
$normalized = [];
|
|
||||||
|
|
||||||
foreach ($byReason as $reason => $value) {
|
|
||||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$intValue = (int) $value;
|
|
||||||
|
|
||||||
if ($intValue <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized[trim($reason)] = $intValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($count === null) {
|
|
||||||
$count = array_sum($normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
arsort($normalized);
|
|
||||||
|
|
||||||
return [$count, array_slice($normalized, 0, 6, true)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -583,12 +736,148 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* overdue_open_findings_count: int,
|
||||||
|
* expiring_governance_count: int,
|
||||||
|
* lapsed_governance_count: int,
|
||||||
|
* active_non_new_findings_count: int,
|
||||||
|
* high_severity_active_findings_count: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function findingAttentionCounts(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$overdueOpenFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$expiringGovernanceCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereHas('findingException', function ($query): void {
|
||||||
|
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$lapsedGovernanceCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query
|
||||||
|
->whereDoesntHave('findingException')
|
||||||
|
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||||
|
$exceptionQuery->whereIn('current_validity_state', [
|
||||||
|
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||||
|
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$activeNonNewFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', [
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
Finding::STATUS_TRIAGED,
|
||||||
|
Finding::STATUS_IN_PROGRESS,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$highSeverityActiveFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereIn('severity', [
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overdue_open_findings_count' => $overdueOpenFindingsCount,
|
||||||
|
'expiring_governance_count' => $expiringGovernanceCount,
|
||||||
|
'lapsed_governance_count' => $lapsedGovernanceCount,
|
||||||
|
'active_non_new_findings_count' => $activeNonNewFindingsCount,
|
||||||
|
'high_severity_active_findings_count' => $highSeverityActiveFindingsCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operatorExplanation(): OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
/** @var BaselineCompareExplanationRegistry $registry */
|
||||||
|
$registry = app(BaselineCompareExplanationRegistry::class);
|
||||||
|
|
||||||
|
return $registry->forStats($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summaryAssessment(): BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
/** @var BaselineCompareSummaryAssessor $assessor */
|
||||||
|
$assessor = app(BaselineCompareSummaryAssessor::class);
|
||||||
|
|
||||||
|
return $assessor->assess($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toTenantGovernanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
$summaryAssessment = $this->summaryAssessment();
|
||||||
|
|
||||||
|
return new TenantGovernanceAggregate(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
profileName: $this->profileName,
|
||||||
|
compareState: $this->state,
|
||||||
|
stateFamily: $summaryAssessment->stateFamily,
|
||||||
|
tone: $summaryAssessment->tone,
|
||||||
|
headline: $summaryAssessment->headline,
|
||||||
|
supportingMessage: $summaryAssessment->supportingMessage,
|
||||||
|
reasonCode: $summaryAssessment->reasonCode,
|
||||||
|
lastComparedLabel: $summaryAssessment->lastComparedLabel,
|
||||||
|
visibleDriftFindingsCount: $summaryAssessment->findingsVisibleCount,
|
||||||
|
overdueOpenFindingsCount: $this->overdueOpenFindingsCount,
|
||||||
|
expiringGovernanceCount: $this->expiringGovernanceCount,
|
||||||
|
lapsedGovernanceCount: $this->lapsedGovernanceCount,
|
||||||
|
activeNonNewFindingsCount: $this->activeNonNewFindingsCount,
|
||||||
|
highSeverityActiveFindingsCount: $this->highSeverityActiveFindingsCount,
|
||||||
|
nextActionLabel: $summaryAssessment->nextActionLabel(),
|
||||||
|
nextActionTarget: $summaryAssessment->nextActionTarget(),
|
||||||
|
positiveClaimAllowed: $summaryAssessment->positiveClaimAllowed,
|
||||||
|
stats: $this,
|
||||||
|
summaryAssessment: $summaryAssessment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* label: string,
|
||||||
|
* value: int,
|
||||||
|
* role: string,
|
||||||
|
* qualifier: ?string,
|
||||||
|
* visibilityTier: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function explanationCountDescriptors(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||||
|
$this->operatorExplanation()->countDescriptors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function empty(
|
private static function empty(
|
||||||
string $state,
|
string $state,
|
||||||
?string $message,
|
?string $message,
|
||||||
?string $profileName = null,
|
?string $profileName = null,
|
||||||
?int $profileId = null,
|
?int $profileId = null,
|
||||||
?int $duplicateNamePoliciesCount = null,
|
?int $duplicateNamePoliciesCount = null,
|
||||||
|
?int $duplicateNameSubjectsCount = null,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
state: $state,
|
state: $state,
|
||||||
@ -597,6 +886,7 @@ private static function empty(
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
snapshotId: null,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
|
|||||||
159
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
159
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
public const string STATE_POSITIVE = 'positive';
|
||||||
|
|
||||||
|
public const string STATE_CAUTION = 'caution';
|
||||||
|
|
||||||
|
public const string STATE_STALE = 'stale';
|
||||||
|
|
||||||
|
public const string STATE_ACTION_REQUIRED = 'action_required';
|
||||||
|
|
||||||
|
public const string STATE_UNAVAILABLE = 'unavailable';
|
||||||
|
|
||||||
|
public const string STATE_IN_PROGRESS = 'in_progress';
|
||||||
|
|
||||||
|
public const string EVIDENCE_NONE = 'none';
|
||||||
|
|
||||||
|
public const string EVIDENCE_COVERAGE_WARNING = 'coverage_warning';
|
||||||
|
|
||||||
|
public const string EVIDENCE_EVIDENCE_GAP = 'evidence_gap';
|
||||||
|
|
||||||
|
public const string EVIDENCE_STALE_RESULT = 'stale_result';
|
||||||
|
|
||||||
|
public const string EVIDENCE_SUPPRESSED_OUTPUT = 'suppressed_output';
|
||||||
|
|
||||||
|
public const string EVIDENCE_UNAVAILABLE = 'unavailable';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_LANDING = 'landing';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_FINDINGS = 'findings';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_RUN = 'run';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_NONE = 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{label: string, target: string} $nextAction
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $stateFamily,
|
||||||
|
public string $headline,
|
||||||
|
public ?string $supportingMessage,
|
||||||
|
public string $tone,
|
||||||
|
public bool $positiveClaimAllowed,
|
||||||
|
public string $trustworthinessLevel,
|
||||||
|
public string $evaluationResult,
|
||||||
|
public string $evidenceImpact,
|
||||||
|
public int $findingsVisibleCount,
|
||||||
|
public int $highSeverityCount,
|
||||||
|
public array $nextAction,
|
||||||
|
public ?string $lastComparedLabel = null,
|
||||||
|
public ?string $reasonCode = null,
|
||||||
|
public int $overdueOpenFindingsCount = 0,
|
||||||
|
public int $expiringGovernanceCount = 0,
|
||||||
|
public int $lapsedGovernanceCount = 0,
|
||||||
|
) {
|
||||||
|
if (! in_array($this->stateFamily, [
|
||||||
|
self::STATE_POSITIVE,
|
||||||
|
self::STATE_CAUTION,
|
||||||
|
self::STATE_STALE,
|
||||||
|
self::STATE_ACTION_REQUIRED,
|
||||||
|
self::STATE_UNAVAILABLE,
|
||||||
|
self::STATE_IN_PROGRESS,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary state family: '.$this->stateFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->headline) === '') {
|
||||||
|
throw new InvalidArgumentException('Baseline summary assessments require a headline.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->evidenceImpact, [
|
||||||
|
self::EVIDENCE_NONE,
|
||||||
|
self::EVIDENCE_COVERAGE_WARNING,
|
||||||
|
self::EVIDENCE_EVIDENCE_GAP,
|
||||||
|
self::EVIDENCE_STALE_RESULT,
|
||||||
|
self::EVIDENCE_SUPPRESSED_OUTPUT,
|
||||||
|
self::EVIDENCE_UNAVAILABLE,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary evidence impact: '.$this->evidenceImpact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->nextAction['target'] ?? null, [
|
||||||
|
self::NEXT_TARGET_LANDING,
|
||||||
|
self::NEXT_TARGET_FINDINGS,
|
||||||
|
self::NEXT_TARGET_RUN,
|
||||||
|
self::NEXT_TARGET_NONE,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary next-action target.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) ($this->nextAction['label'] ?? '')) === '') {
|
||||||
|
throw new InvalidArgumentException('Baseline summary assessments require a next-action label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->positiveClaimAllowed && $this->stateFamily !== self::STATE_POSITIVE) {
|
||||||
|
throw new InvalidArgumentException('Positive claim eligibility must resolve to the positive summary state.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextActionLabel(): string
|
||||||
|
{
|
||||||
|
return $this->nextAction['label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextActionTarget(): string
|
||||||
|
{
|
||||||
|
return $this->nextAction['target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* stateFamily: string,
|
||||||
|
* headline: string,
|
||||||
|
* supportingMessage: ?string,
|
||||||
|
* tone: string,
|
||||||
|
* positiveClaimAllowed: bool,
|
||||||
|
* trustworthinessLevel: string,
|
||||||
|
* evaluationResult: string,
|
||||||
|
* evidenceImpact: string,
|
||||||
|
* findingsVisibleCount: int,
|
||||||
|
* highSeverityCount: int,
|
||||||
|
* nextAction: array{label: string, target: string},
|
||||||
|
* lastComparedLabel: ?string,
|
||||||
|
* reasonCode: ?string,
|
||||||
|
* overdueOpenFindingsCount: int,
|
||||||
|
* expiringGovernanceCount: int,
|
||||||
|
* lapsedGovernanceCount: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stateFamily' => $this->stateFamily,
|
||||||
|
'headline' => $this->headline,
|
||||||
|
'supportingMessage' => $this->supportingMessage,
|
||||||
|
'tone' => $this->tone,
|
||||||
|
'positiveClaimAllowed' => $this->positiveClaimAllowed,
|
||||||
|
'trustworthinessLevel' => $this->trustworthinessLevel,
|
||||||
|
'evaluationResult' => $this->evaluationResult,
|
||||||
|
'evidenceImpact' => $this->evidenceImpact,
|
||||||
|
'findingsVisibleCount' => $this->findingsVisibleCount,
|
||||||
|
'highSeverityCount' => $this->highSeverityCount,
|
||||||
|
'nextAction' => $this->nextAction,
|
||||||
|
'lastComparedLabel' => $this->lastComparedLabel,
|
||||||
|
'reasonCode' => $this->reasonCode,
|
||||||
|
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||||
|
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||||
|
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
387
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
387
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class BaselineCompareSummaryAssessor
|
||||||
|
{
|
||||||
|
private const int STALE_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
$explanation = $stats->operatorExplanation();
|
||||||
|
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
||||||
|
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
|
||||||
|
$overdueOpenFindingsCount = $stats->overdueOpenFindingsCount;
|
||||||
|
$expiringGovernanceCount = $stats->expiringGovernanceCount;
|
||||||
|
$lapsedGovernanceCount = $stats->lapsedGovernanceCount;
|
||||||
|
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
||||||
|
$evaluationResult = $stats->state === 'failed'
|
||||||
|
? 'failed_result'
|
||||||
|
: $explanation->evaluationResult;
|
||||||
|
$positiveClaimAllowed = $this->positiveClaimAllowed(
|
||||||
|
$stats,
|
||||||
|
$explanation,
|
||||||
|
$reasonCode,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
||||||
|
$stateFamily = $this->stateFamily(
|
||||||
|
$stats,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$positiveClaimAllowed,
|
||||||
|
$isStale,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
$summaryReasonCode = $this->summaryReasonCode(
|
||||||
|
$stats,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new BaselineCompareSummaryAssessment(
|
||||||
|
stateFamily: $stateFamily,
|
||||||
|
headline: $this->headline(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$highSeverityCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
|
supportingMessage: $this->supportingMessage(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
|
tone: $this->tone($stats, $stateFamily),
|
||||||
|
positiveClaimAllowed: $positiveClaimAllowed,
|
||||||
|
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
||||||
|
findingsVisibleCount: $findingsVisibleCount,
|
||||||
|
highSeverityCount: $highSeverityCount,
|
||||||
|
nextAction: $this->nextAction(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
|
lastComparedLabel: $stats->lastComparedHuman,
|
||||||
|
reasonCode: $summaryReasonCode,
|
||||||
|
overdueOpenFindingsCount: $overdueOpenFindingsCount,
|
||||||
|
expiringGovernanceCount: $expiringGovernanceCount,
|
||||||
|
lapsedGovernanceCount: $lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveClaimAllowed(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
OperatorExplanationPattern $explanation,
|
||||||
|
?BaselineCompareReasonCode $reasonCode,
|
||||||
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): bool {
|
||||||
|
if ($stats->state !== 'ready') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->findingsCount ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evaluationResult !== 'no_result') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($explanation->trustworthinessLevel !== TrustworthinessLevel::Trustworthy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->reasonCode === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reasonCode?->supportsPositiveClaim() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateFamily(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
bool $positiveClaimAllowed,
|
||||||
|
bool $isStale,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): string {
|
||||||
|
return match (true) {
|
||||||
|
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
||||||
|
$stats->state === 'failed',
|
||||||
|
$findingsVisibleCount > 0,
|
||||||
|
$overdueOpenFindingsCount > 0,
|
||||||
|
$expiringGovernanceCount > 0,
|
||||||
|
$lapsedGovernanceCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||||
|
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
||||||
|
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
||||||
|
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
||||||
|
default => BaselineCompareSummaryAssessment::STATE_CAUTION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceImpact(BaselineCompareStats $stats, string $evaluationResult, bool $isStale): string
|
||||||
|
{
|
||||||
|
if (in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle', 'failed'], true)) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isStale) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evaluationResult === 'suppressed_result') {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_SUPPRESSED_OUTPUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_EVIDENCE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_COVERAGE_WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function headline(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
int $highSeverityCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): string {
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||||
|
$evaluationResult === 'suppressed_result' => 'The last compare finished, but normal result output was suppressed.',
|
||||||
|
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'No confirmed drift is visible, but evidence gaps still limit this result.',
|
||||||
|
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'No confirmed drift is visible, but coverage limits this compare.',
|
||||||
|
default => 'The latest compare result needs caution before you treat it as an all-clear.',
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
|
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
||||||
|
$lapsedGovernanceCount > 0 => sprintf('Accepted-risk governance has lapsed on %d finding%s.', $lapsedGovernanceCount, $lapsedGovernanceCount === 1 ? '' : 's'),
|
||||||
|
$overdueOpenFindingsCount > 0 => sprintf('%d overdue finding%s still need review.', $overdueOpenFindingsCount, $overdueOpenFindingsCount === 1 ? '' : 's'),
|
||||||
|
$expiringGovernanceCount > 0 => sprintf('Accepted-risk governance is nearing expiry on %d finding%s.', $expiringGovernanceCount, $expiringGovernanceCount === 1 ? '' : 's'),
|
||||||
|
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
|
||||||
|
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
|
||||||
|
default => match ($stats->state) {
|
||||||
|
'no_assignment' => 'This tenant does not have an assigned baseline yet.',
|
||||||
|
'no_snapshot' => 'The current baseline snapshot is not available for compare.',
|
||||||
|
'idle' => 'A current baseline compare result is not available yet.',
|
||||||
|
default => 'A usable baseline compare result is not currently available.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportingMessage(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): ?string {
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
||||||
|
? 'Last compared '.$stats->lastComparedHuman.'.'
|
||||||
|
: 'The latest compare result is trustworthy enough to treat zero findings as current.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||||
|
$evaluationResult === 'suppressed_result' => 'Review the run detail before treating zero visible findings as complete.',
|
||||||
|
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'Review the compare detail to see which evidence gaps still limit trust.',
|
||||||
|
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'Coverage warnings mean zero visible findings are not an all-clear on their own.',
|
||||||
|
default => $stats->reasonMessage ?? $stats->message,
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => $stats->lastComparedHuman !== null
|
||||||
|
? 'Last compared '.$stats->lastComparedHuman.'. Refresh compare before relying on this posture.'
|
||||||
|
: 'Refresh compare before relying on this posture.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
|
$stats->state === 'failed' => $stats->failureReason,
|
||||||
|
$lapsedGovernanceCount > 0 => 'Restore valid governance or move those findings back into active remediation before relying on accepted risk.',
|
||||||
|
$overdueOpenFindingsCount > 0 => 'Overdue workflow items remain even if the latest compare did not introduce new drift findings.',
|
||||||
|
$expiringGovernanceCount > 0 => 'Current governance is still valid, but review or renewal is due soon.',
|
||||||
|
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
||||||
|
default => $stats->message,
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Current counts are diagnostic only until the compare run finishes.',
|
||||||
|
default => $stats->message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tone(BaselineCompareStats $stats, string $stateFamily): string
|
||||||
|
{
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'success',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => 'danger',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'info',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => $stats->state === 'no_snapshot' ? 'warning' : 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, target: string}
|
||||||
|
*/
|
||||||
|
private function nextAction(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): array {
|
||||||
|
if ($findingsVisibleCount > 0 || $overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
|
return [
|
||||||
|
'label' => 'Open findings',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => [
|
||||||
|
'label' => $evaluationResult === 'failed_result' ? 'Review the failed run' : 'Review compare detail',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => [
|
||||||
|
'label' => 'Review compare detail',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => [
|
||||||
|
'label' => 'Open Baseline Compare',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => [
|
||||||
|
'label' => $stats->operationRunId !== null ? 'View run' : 'Open Baseline Compare',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => match ($stats->state) {
|
||||||
|
'no_assignment' => [
|
||||||
|
'label' => 'Assign a baseline first',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
'no_snapshot' => [
|
||||||
|
'label' => 'Review baseline prerequisites',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
'idle' => [
|
||||||
|
'label' => 'Open Baseline Compare',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'label' => 'Review compare availability',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
default => [
|
||||||
|
'label' => 'No action needed',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function summaryReasonCode(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): ?string {
|
||||||
|
if ($lapsedGovernanceCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::GovernanceLapsed->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenFindingsCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::OverdueFindingsRemain->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiringGovernanceCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::GovernanceExpiring->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats->reasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
||||||
|
{
|
||||||
|
if ($stats->state !== 'ready') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->lastComparedIso === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($evaluationResult, ['full_result', 'no_result', 'incomplete_result', 'suppressed_result'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -85,4 +85,58 @@ public static function isKnown(?string $reasonCode): bool
|
|||||||
{
|
{
|
||||||
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
|
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function trustImpact(?string $reasonCode): ?string
|
||||||
|
{
|
||||||
|
return match (trim((string) $reasonCode)) {
|
||||||
|
self::SNAPSHOT_CAPTURE_FAILED => 'limited_confidence',
|
||||||
|
self::COMPARE_ROLLOUT_DISABLED,
|
||||||
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
|
self::SNAPSHOT_BUILDING,
|
||||||
|
self::SNAPSHOT_INCOMPLETE,
|
||||||
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
|
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||||
|
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||||
|
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||||
|
self::COMPARE_NO_ASSIGNMENT,
|
||||||
|
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||||
|
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||||
|
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||||
|
self::COMPARE_INVALID_SNAPSHOT,
|
||||||
|
self::COMPARE_SNAPSHOT_BUILDING,
|
||||||
|
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||||
|
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||||
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
|
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function absencePattern(?string $reasonCode): ?string
|
||||||
|
{
|
||||||
|
return match (trim((string) $reasonCode)) {
|
||||||
|
self::SNAPSHOT_BUILDING,
|
||||||
|
self::SNAPSHOT_INCOMPLETE,
|
||||||
|
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||||
|
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||||
|
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||||
|
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||||
|
self::COMPARE_SNAPSHOT_BUILDING,
|
||||||
|
self::COMPARE_SNAPSHOT_INCOMPLETE => 'missing_input',
|
||||||
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
|
self::COMPARE_NO_ASSIGNMENT,
|
||||||
|
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||||
|
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||||
|
self::COMPARE_INVALID_SNAPSHOT,
|
||||||
|
self::COMPARE_ROLLOUT_DISABLED,
|
||||||
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
|
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||||
|
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,17 @@ public function allTypes(): array
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
|
||||||
|
{
|
||||||
|
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||||
|
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
|
||||||
|
|
||||||
|
return $guardResult['allowed_types'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -134,17 +145,32 @@ public function toJsonb(): array
|
|||||||
*
|
*
|
||||||
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
||||||
*/
|
*/
|
||||||
public function toEffectiveScopeContext(): array
|
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||||
{
|
{
|
||||||
$expanded = $this->expandDefaults();
|
$expanded = $this->expandDefaults();
|
||||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||||
|
|
||||||
return [
|
$context = [
|
||||||
'policy_types' => $expanded->policyTypes,
|
'policy_types' => $expanded->policyTypes,
|
||||||
'foundation_types' => $expanded->foundationTypes,
|
'foundation_types' => $expanded->foundationTypes,
|
||||||
'all_types' => $allTypes,
|
'all_types' => $allTypes,
|
||||||
'foundations_included' => $expanded->foundationTypes !== [],
|
'foundations_included' => $expanded->foundationTypes !== [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! is_string($operation) || $operation === '') {
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||||
|
$guardResult = $guard->guardTypes($allTypes, $operation);
|
||||||
|
|
||||||
|
return array_merge($context, [
|
||||||
|
'truthful_types' => $guardResult['allowed_types'],
|
||||||
|
'limited_types' => $guardResult['limited_types'],
|
||||||
|
'unsupported_types' => $guardResult['unsupported_types'],
|
||||||
|
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||||
|
'capabilities' => $guardResult['capabilities'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
final class BaselineSupportCapabilityGuard
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SubjectResolver $resolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function inspectType(string $policyType): SupportCapabilityRecord
|
||||||
|
{
|
||||||
|
return $this->resolver->capability($policyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $policyTypes
|
||||||
|
* @return array{
|
||||||
|
* allowed_types: list<string>,
|
||||||
|
* limited_types: list<string>,
|
||||||
|
* unsupported_types: list<string>,
|
||||||
|
* invalid_support_types: list<string>,
|
||||||
|
* capabilities: array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function guardTypes(array $policyTypes, string $operation): array
|
||||||
|
{
|
||||||
|
$allowedTypes = [];
|
||||||
|
$limitedTypes = [];
|
||||||
|
$unsupportedTypes = [];
|
||||||
|
$invalidSupportTypes = [];
|
||||||
|
$capabilities = [];
|
||||||
|
|
||||||
|
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
|
||||||
|
$record = $this->inspectType($policyType);
|
||||||
|
$mode = $record->supportModeFor($operation);
|
||||||
|
|
||||||
|
$capabilities[$policyType] = array_merge(
|
||||||
|
$record->toArray(),
|
||||||
|
['support_mode' => $mode],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($mode === 'invalid_support_config') {
|
||||||
|
$invalidSupportTypes[] = $policyType;
|
||||||
|
$unsupportedTypes[] = $policyType;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->allows($operation)) {
|
||||||
|
$allowedTypes[] = $policyType;
|
||||||
|
|
||||||
|
if ($mode === 'limited') {
|
||||||
|
$limitedTypes[] = $policyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unsupportedTypes[] = $policyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($allowedTypes, SORT_STRING);
|
||||||
|
sort($limitedTypes, SORT_STRING);
|
||||||
|
sort($unsupportedTypes, SORT_STRING);
|
||||||
|
sort($invalidSupportTypes, SORT_STRING);
|
||||||
|
ksort($capabilities);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allowed_types' => $allowedTypes,
|
||||||
|
'limited_types' => $limitedTypes,
|
||||||
|
'unsupported_types' => $unsupportedTypes,
|
||||||
|
'invalid_support_types' => $invalidSupportTypes,
|
||||||
|
'capabilities' => $capabilities,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
enum OperatorActionCategory: string
|
||||||
|
{
|
||||||
|
case None = 'none';
|
||||||
|
case Retry = 'retry';
|
||||||
|
case RunInventorySync = 'run_inventory_sync';
|
||||||
|
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
|
||||||
|
case ReviewPermissions = 'review_permissions';
|
||||||
|
case InspectSubjectMapping = 'inspect_subject_mapping';
|
||||||
|
case ProductFollowUp = 'product_follow_up';
|
||||||
|
}
|
||||||
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
enum ResolutionOutcome: string
|
||||||
|
{
|
||||||
|
case ResolvedPolicy = 'resolved_policy';
|
||||||
|
case ResolvedInventory = 'resolved_inventory';
|
||||||
|
case PolicyRecordMissing = 'policy_record_missing';
|
||||||
|
case InventoryRecordMissing = 'inventory_record_missing';
|
||||||
|
case FoundationInventoryOnly = 'foundation_inventory_only';
|
||||||
|
case ResolutionTypeMismatch = 'resolution_type_mismatch';
|
||||||
|
case UnresolvableSubject = 'unresolvable_subject';
|
||||||
|
case InvalidSupportConfig = 'invalid_support_config';
|
||||||
|
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
|
||||||
|
case AmbiguousMatch = 'ambiguous_match';
|
||||||
|
case InvalidSubject = 'invalid_subject';
|
||||||
|
case DuplicateSubject = 'duplicate_subject';
|
||||||
|
case RetryableCaptureFailure = 'retryable_capture_failure';
|
||||||
|
case CaptureFailed = 'capture_failed';
|
||||||
|
case Throttled = 'throttled';
|
||||||
|
case BudgetExhausted = 'budget_exhausted';
|
||||||
|
}
|
||||||
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
final class ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $reasonCode
|
||||||
|
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||||
|
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly ResolutionOutcome $resolutionOutcome,
|
||||||
|
public readonly string $reasonCode,
|
||||||
|
public readonly OperatorActionCategory $operatorActionCategory,
|
||||||
|
public readonly bool $structural,
|
||||||
|
public readonly bool $retryable,
|
||||||
|
public readonly ?string $sourceModelExpected = null,
|
||||||
|
public readonly ?string $sourceModelFound = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'resolution_outcome' => $this->resolutionOutcome->value,
|
||||||
|
'reason_code' => $this->reasonCode,
|
||||||
|
'operator_action_category' => $this->operatorActionCategory->value,
|
||||||
|
'structural' => $this->structural,
|
||||||
|
'retryable' => $this->retryable,
|
||||||
|
'source_model_expected' => $this->sourceModelExpected,
|
||||||
|
'source_model_found' => $this->sourceModelFound,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Support/Baselines/ResolutionPath.php
Normal file
14
app/Support/Baselines/ResolutionPath.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
enum ResolutionPath: string
|
||||||
|
{
|
||||||
|
case Policy = 'policy';
|
||||||
|
case Inventory = 'inventory';
|
||||||
|
case FoundationPolicy = 'foundation_policy';
|
||||||
|
case FoundationInventory = 'foundation_inventory';
|
||||||
|
case Derived = 'derived';
|
||||||
|
}
|
||||||
13
app/Support/Baselines/SubjectClass.php
Normal file
13
app/Support/Baselines/SubjectClass.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
enum SubjectClass: string
|
||||||
|
{
|
||||||
|
case PolicyBacked = 'policy_backed';
|
||||||
|
case InventoryBacked = 'inventory_backed';
|
||||||
|
case FoundationBacked = 'foundation_backed';
|
||||||
|
case Derived = 'derived';
|
||||||
|
}
|
||||||
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
final class SubjectDescriptor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $policyType
|
||||||
|
* @param non-empty-string $subjectKey
|
||||||
|
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
|
||||||
|
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $policyType,
|
||||||
|
public readonly ?string $subjectExternalId,
|
||||||
|
public readonly string $subjectKey,
|
||||||
|
public readonly SubjectClass $subjectClass,
|
||||||
|
public readonly ResolutionPath $resolutionPath,
|
||||||
|
public readonly string $supportMode,
|
||||||
|
public readonly ?string $sourceModelExpected,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function expectsPolicy(): bool
|
||||||
|
{
|
||||||
|
return $this->sourceModelExpected === 'policy';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expectsInventory(): bool
|
||||||
|
{
|
||||||
|
return $this->sourceModelExpected === 'inventory';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'policy_type' => $this->policyType,
|
||||||
|
'subject_external_id' => $this->subjectExternalId,
|
||||||
|
'subject_key' => $this->subjectKey,
|
||||||
|
'subject_class' => $this->subjectClass->value,
|
||||||
|
'resolution_path' => $this->resolutionPath->value,
|
||||||
|
'support_mode' => $this->supportMode,
|
||||||
|
'source_model_expected' => $this->sourceModelExpected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Support/Baselines/SubjectResolver.php
Normal file
201
app/Support/Baselines/SubjectResolver.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
|
||||||
|
final class SubjectResolver
|
||||||
|
{
|
||||||
|
public function capability(string $policyType): SupportCapabilityRecord
|
||||||
|
{
|
||||||
|
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
|
||||||
|
|
||||||
|
return new SupportCapabilityRecord(
|
||||||
|
policyType: $policyType,
|
||||||
|
subjectClass: SubjectClass::from($contract['subject_class']),
|
||||||
|
compareCapability: $contract['compare_capability'],
|
||||||
|
captureCapability: $contract['capture_capability'],
|
||||||
|
resolutionPath: ResolutionPath::from($contract['resolution_path']),
|
||||||
|
configSupported: (bool) $contract['config_supported'],
|
||||||
|
runtimeValid: (bool) $contract['runtime_valid'],
|
||||||
|
sourceModelExpected: $contract['source_model_expected'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||||
|
{
|
||||||
|
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||||
|
{
|
||||||
|
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
$outcome = $descriptor->expectsPolicy()
|
||||||
|
? ResolutionOutcome::ResolvedPolicy
|
||||||
|
: ResolutionOutcome::ResolvedInventory;
|
||||||
|
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: $outcome,
|
||||||
|
reasonCode: $outcome->value,
|
||||||
|
operatorActionCategory: OperatorActionCategory::None,
|
||||||
|
structural: false,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
$expectsPolicy = $descriptor->expectsPolicy();
|
||||||
|
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
|
||||||
|
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
|
||||||
|
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
|
||||||
|
structural: false,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||||
|
reasonCode: 'foundation_not_policy_backed',
|
||||||
|
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||||
|
structural: true,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
sourceModelFound: 'inventory',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::InvalidSubject,
|
||||||
|
reasonCode: 'invalid_subject',
|
||||||
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||||
|
structural: false,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
|
||||||
|
reasonCode: 'duplicate_subject',
|
||||||
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||||
|
structural: false,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
|
||||||
|
reasonCode: 'ambiguous_match',
|
||||||
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||||
|
structural: false,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
|
||||||
|
reasonCode: 'invalid_support_config',
|
||||||
|
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||||
|
structural: true,
|
||||||
|
retryable: false,
|
||||||
|
sourceModelExpected: $capability->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::Throttled,
|
||||||
|
reasonCode: 'throttled',
|
||||||
|
operatorActionCategory: OperatorActionCategory::Retry,
|
||||||
|
structural: false,
|
||||||
|
retryable: true,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
|
||||||
|
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
|
||||||
|
operatorActionCategory: OperatorActionCategory::Retry,
|
||||||
|
structural: false,
|
||||||
|
retryable: $retryable,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||||
|
{
|
||||||
|
return new ResolutionOutcomeRecord(
|
||||||
|
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
|
||||||
|
reasonCode: 'budget_exhausted',
|
||||||
|
operatorActionCategory: OperatorActionCategory::Retry,
|
||||||
|
structural: false,
|
||||||
|
retryable: true,
|
||||||
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||||
|
{
|
||||||
|
$capability = $this->capability($policyType);
|
||||||
|
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
|
||||||
|
|
||||||
|
return new SubjectDescriptor(
|
||||||
|
policyType: $policyType,
|
||||||
|
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
|
||||||
|
subjectKey: $resolvedSubjectKey,
|
||||||
|
subjectClass: $capability->subjectClass,
|
||||||
|
resolutionPath: $capability->resolutionPath,
|
||||||
|
supportMode: $capability->supportModeFor($operation),
|
||||||
|
sourceModelExpected: $capability->sourceModelExpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
|
||||||
|
{
|
||||||
|
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
|
||||||
|
|
||||||
|
if ($trimmedSubjectKey !== '') {
|
||||||
|
return $trimmedSubjectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
|
||||||
|
|
||||||
|
if (is_string($generated) && $generated !== '') {
|
||||||
|
return $generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
|
||||||
|
? trim($subjectExternalId)
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
return trim($policyType).'|'.$fallbackExternalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class SupportCapabilityRecord
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $policyType
|
||||||
|
* @param 'supported'|'limited'|'unsupported' $compareCapability
|
||||||
|
* @param 'supported'|'limited'|'unsupported' $captureCapability
|
||||||
|
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $policyType,
|
||||||
|
public readonly SubjectClass $subjectClass,
|
||||||
|
public readonly string $compareCapability,
|
||||||
|
public readonly string $captureCapability,
|
||||||
|
public readonly ResolutionPath $resolutionPath,
|
||||||
|
public readonly bool $configSupported,
|
||||||
|
public readonly bool $runtimeValid,
|
||||||
|
public readonly ?string $sourceModelExpected = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
|
||||||
|
*/
|
||||||
|
public function supportModeFor(string $operation): string
|
||||||
|
{
|
||||||
|
$capability = match ($operation) {
|
||||||
|
'compare' => $this->compareCapability,
|
||||||
|
'capture' => $this->captureCapability,
|
||||||
|
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($this->configSupported && ! $this->runtimeValid) {
|
||||||
|
return 'invalid_support_config';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($capability) {
|
||||||
|
'supported', 'limited' => $capability,
|
||||||
|
default => 'excluded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allows(string $operation): bool
|
||||||
|
{
|
||||||
|
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'policy_type' => $this->policyType,
|
||||||
|
'subject_class' => $this->subjectClass->value,
|
||||||
|
'compare_capability' => $this->compareCapability,
|
||||||
|
'capture_capability' => $this->captureCapability,
|
||||||
|
'resolution_path' => $this->resolutionPath->value,
|
||||||
|
'config_supported' => $this->configSupported,
|
||||||
|
'runtime_valid' => $this->runtimeValid,
|
||||||
|
'source_model_expected' => $this->sourceModelExpected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal file
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $workspaceId,
|
||||||
|
public ?string $profileName,
|
||||||
|
public string $compareState,
|
||||||
|
public string $stateFamily,
|
||||||
|
public string $tone,
|
||||||
|
public string $headline,
|
||||||
|
public ?string $supportingMessage,
|
||||||
|
public ?string $reasonCode,
|
||||||
|
public ?string $lastComparedLabel,
|
||||||
|
public int $visibleDriftFindingsCount,
|
||||||
|
public int $overdueOpenFindingsCount,
|
||||||
|
public int $expiringGovernanceCount,
|
||||||
|
public int $lapsedGovernanceCount,
|
||||||
|
public int $activeNonNewFindingsCount,
|
||||||
|
public int $highSeverityActiveFindingsCount,
|
||||||
|
public string $nextActionLabel,
|
||||||
|
public string $nextActionTarget,
|
||||||
|
public bool $positiveClaimAllowed,
|
||||||
|
public BaselineCompareStats $stats,
|
||||||
|
public BaselineCompareSummaryAssessment $summaryAssessment,
|
||||||
|
) {
|
||||||
|
if ($this->tenantId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a positive tenant id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspaceId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a positive workspace id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->compareState) === '') {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a compare state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->headline) === '') {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a headline.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->nextActionLabel) === '') {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a next-action label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->nextActionTarget, [
|
||||||
|
BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
|
BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
BaselineCompareSummaryAssessment::NEXT_TARGET_RUN,
|
||||||
|
BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Tenant governance aggregates require a supported next-action target.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* tenantId: int,
|
||||||
|
* workspaceId: int,
|
||||||
|
* profileName: ?string,
|
||||||
|
* compareState: string,
|
||||||
|
* stateFamily: string,
|
||||||
|
* tone: string,
|
||||||
|
* headline: string,
|
||||||
|
* supportingMessage: ?string,
|
||||||
|
* reasonCode: ?string,
|
||||||
|
* lastComparedLabel: ?string,
|
||||||
|
* visibleDriftFindingsCount: int,
|
||||||
|
* overdueOpenFindingsCount: int,
|
||||||
|
* expiringGovernanceCount: int,
|
||||||
|
* lapsedGovernanceCount: int,
|
||||||
|
* activeNonNewFindingsCount: int,
|
||||||
|
* highSeverityActiveFindingsCount: int,
|
||||||
|
* nextActionLabel: string,
|
||||||
|
* nextActionTarget: string,
|
||||||
|
* positiveClaimAllowed: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'workspaceId' => $this->workspaceId,
|
||||||
|
'profileName' => $this->profileName,
|
||||||
|
'compareState' => $this->compareState,
|
||||||
|
'stateFamily' => $this->stateFamily,
|
||||||
|
'tone' => $this->tone,
|
||||||
|
'headline' => $this->headline,
|
||||||
|
'supportingMessage' => $this->supportingMessage,
|
||||||
|
'reasonCode' => $this->reasonCode,
|
||||||
|
'lastComparedLabel' => $this->lastComparedLabel,
|
||||||
|
'visibleDriftFindingsCount' => $this->visibleDriftFindingsCount,
|
||||||
|
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||||
|
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||||
|
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||||
|
'activeNonNewFindingsCount' => $this->activeNonNewFindingsCount,
|
||||||
|
'highSeverityActiveFindingsCount' => $this->highSeverityActiveFindingsCount,
|
||||||
|
'nextActionLabel' => $this->nextActionLabel,
|
||||||
|
'nextActionTarget' => $this->nextActionTarget,
|
||||||
|
'positiveClaimAllowed' => $this->positiveClaimAllowed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal file
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
|
||||||
|
final class TenantGovernanceAggregateResolver
|
||||||
|
{
|
||||||
|
public const string VARIANT_TENANT_GOVERNANCE_SUMMARY = 'tenant_governance_summary';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function forTenant(?Tenant $tenant, bool $fresh = false): ?TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveAggregate(
|
||||||
|
tenant: $tenant,
|
||||||
|
resolver: fn (): TenantGovernanceAggregate => BaselineCompareStats::forTenant($tenant)
|
||||||
|
->toTenantGovernanceAggregate($tenant),
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromStats(?Tenant $tenant, BaselineCompareStats $stats, bool $fresh = false): ?TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveAggregate(
|
||||||
|
tenant: $tenant,
|
||||||
|
resolver: fn (): TenantGovernanceAggregate => $stats->toTenantGovernanceAggregate($tenant),
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAggregate(Tenant $tenant, callable $resolver, bool $fresh = false): TenantGovernanceAggregate
|
||||||
|
{
|
||||||
|
$key = DerivedStateKey::fromModel(
|
||||||
|
DerivedStateFamily::TenantGovernanceAggregate,
|
||||||
|
$tenant,
|
||||||
|
self::VARIANT_TENANT_GOVERNANCE_SUMMARY,
|
||||||
|
);
|
||||||
|
|
||||||
|
$value = $fresh
|
||||||
|
? $this->derivedStateStore->resolveFresh(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: $this->derivedStateStore->resolve(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -130,6 +130,31 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingWorkflowFamilies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'active' => 'Active workflow',
|
||||||
|
'accepted_risk' => 'Accepted risk',
|
||||||
|
'historical' => 'Historical',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingSeverities(): array
|
||||||
|
{
|
||||||
|
return self::badgeOptions(BadgeDomain::FindingSeverity, [
|
||||||
|
Finding::SEVERITY_LOW,
|
||||||
|
Finding::SEVERITY_MEDIUM,
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@ -161,6 +186,17 @@ public static function findingExceptionValidityStates(): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingGovernanceAttentionStates(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'healthy' => 'Healthy governance',
|
||||||
|
'attention_needed' => 'Governance attention needed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param iterable<mixed>|null $types
|
* @param iterable<mixed>|null $types
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Inventory;
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectClass;
|
||||||
|
|
||||||
class InventoryPolicyTypeMeta
|
class InventoryPolicyTypeMeta
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
|
|||||||
|
|
||||||
return static::label($type);
|
return static::label($type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* config_supported: bool,
|
||||||
|
* runtime_valid: bool,
|
||||||
|
* subject_class: string,
|
||||||
|
* resolution_path: string,
|
||||||
|
* compare_capability: string,
|
||||||
|
* capture_capability: string,
|
||||||
|
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function baselineSupportContract(?string $type): array
|
||||||
|
{
|
||||||
|
$contract = static::defaultBaselineSupportContract($type);
|
||||||
|
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($resolution)) {
|
||||||
|
$contract = array_replace($contract, array_filter([
|
||||||
|
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
|
||||||
|
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
|
||||||
|
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
|
||||||
|
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
|
||||||
|
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
|
||||||
|
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
|
||||||
|
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||||
|
? (string) $contract['compare_capability']
|
||||||
|
: 'unsupported';
|
||||||
|
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||||
|
? (string) $contract['capture_capability']
|
||||||
|
: 'unsupported';
|
||||||
|
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
|
||||||
|
? (string) $contract['source_model_expected']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$runtimeValid = $subjectClass instanceof SubjectClass
|
||||||
|
&& $resolutionPath instanceof ResolutionPath
|
||||||
|
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
|
||||||
|
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
|
||||||
|
|
||||||
|
if (! $runtimeValid) {
|
||||||
|
$compareCapability = 'unsupported';
|
||||||
|
$captureCapability = 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config_supported' => (bool) ($contract['config_supported'] ?? false),
|
||||||
|
'runtime_valid' => $runtimeValid,
|
||||||
|
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
|
||||||
|
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
|
||||||
|
'compare_capability' => $compareCapability,
|
||||||
|
'capture_capability' => $captureCapability,
|
||||||
|
'source_model_expected' => $sourceModelExpected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* config_supported: bool,
|
||||||
|
* subject_class: string,
|
||||||
|
* resolution_path: string,
|
||||||
|
* compare_capability: string,
|
||||||
|
* capture_capability: string,
|
||||||
|
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function defaultBaselineSupportContract(?string $type): array
|
||||||
|
{
|
||||||
|
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
|
||||||
|
return [
|
||||||
|
'config_supported' => true,
|
||||||
|
'subject_class' => SubjectClass::PolicyBacked->value,
|
||||||
|
'resolution_path' => ResolutionPath::Policy->value,
|
||||||
|
'compare_capability' => 'supported',
|
||||||
|
'capture_capability' => 'supported',
|
||||||
|
'source_model_expected' => 'policy',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::isFoundation($type)) {
|
||||||
|
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
|
||||||
|
$identityStrategy = static::baselineCompareIdentityStrategy($type);
|
||||||
|
$usesPolicyPath = $identityStrategy === 'external_id';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config_supported' => $supported,
|
||||||
|
'subject_class' => SubjectClass::FoundationBacked->value,
|
||||||
|
'resolution_path' => $usesPolicyPath
|
||||||
|
? ResolutionPath::FoundationPolicy->value
|
||||||
|
: ResolutionPath::FoundationInventory->value,
|
||||||
|
'compare_capability' => ! $supported
|
||||||
|
? 'unsupported'
|
||||||
|
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||||
|
'capture_capability' => ! $supported
|
||||||
|
? 'unsupported'
|
||||||
|
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||||
|
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config_supported' => false,
|
||||||
|
'subject_class' => SubjectClass::Derived->value,
|
||||||
|
'resolution_path' => ResolutionPath::Derived->value,
|
||||||
|
'compare_capability' => 'unsupported',
|
||||||
|
'capture_capability' => 'unsupported',
|
||||||
|
'source_model_expected' => 'derived',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
|
||||||
|
{
|
||||||
|
return match ($subjectClass) {
|
||||||
|
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
|
||||||
|
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
|
||||||
|
SubjectClass::FoundationBacked => in_array($resolutionPath, [
|
||||||
|
ResolutionPath::FoundationInventory,
|
||||||
|
ResolutionPath::FoundationPolicy,
|
||||||
|
], true),
|
||||||
|
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
|
||||||
|
{
|
||||||
|
return match ($resolutionPath) {
|
||||||
|
ResolutionPath::Policy,
|
||||||
|
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
|
||||||
|
ResolutionPath::Inventory,
|
||||||
|
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
|
||||||
|
ResolutionPath::Derived => $sourceModelExpected === 'derived',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,9 @@
|
|||||||
use App\Support\References\ReferenceDescriptor;
|
use App\Support\References\ReferenceDescriptor;
|
||||||
use App\Support\References\ReferenceResolverRegistry;
|
use App\Support\References\ReferenceResolverRegistry;
|
||||||
use App\Support\References\RelatedContextReferenceAdapter;
|
use App\Support\References\RelatedContextReferenceAdapter;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -48,6 +51,7 @@ public function __construct(
|
|||||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||||
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
|
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
|
||||||
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
|
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,18 +73,41 @@ public function detailEntries(string $sourceType, Model $record): array
|
|||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $record),
|
$this->detailEntryObjects($sourceType, $record),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function detailEntriesFresh(string $sourceType, Model $record): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||||
|
$this->detailEntryObjects($sourceType, $record, fresh: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
$entries = array_values(array_filter(
|
return $this->resolvePrimaryListAction($sourceType, $record);
|
||||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_LIST_ROW, $record),
|
}
|
||||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $entries[0] ?? null;
|
public function primaryListActionFresh(string $sourceType, Model $record): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return $this->resolvePrimaryListAction($sourceType, $record, fresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +116,7 @@ public function primaryListAction(string $sourceType, Model $record): ?RelatedCo
|
|||||||
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
||||||
{
|
{
|
||||||
$entries = array_filter(
|
$entries = array_filter(
|
||||||
$this->resolveEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $run),
|
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run),
|
||||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -100,20 +127,51 @@ public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$links = ['Open operations' => OperationRunLinks::index($tenant)] + $links;
|
$links = ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||||
} else {
|
} else {
|
||||||
$links = ['Open operations' => OperationRunLinks::index()] + $links;
|
$links = ['Operations' => OperationRunLinks::index()] + $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $links;
|
return $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function operationLinksFresh(OperationRun $run, ?Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$entries = array_filter(
|
||||||
|
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run, fresh: true),
|
||||||
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$links[$entry->actionLabel] = (string) $entry->targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Operations' => OperationRunLinks::index()] + $links;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<RelatedContextEntry>
|
* @return list<RelatedContextEntry>
|
||||||
*/
|
*/
|
||||||
public function headerEntries(string $sourceType, Model $record): array
|
public function headerEntries(string $sourceType, Model $record): array
|
||||||
{
|
{
|
||||||
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
|
return $this->headerEntryObjects($sourceType, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
public function headerEntriesFresh(string $sourceType, Model $record): array
|
||||||
|
{
|
||||||
|
return $this->headerEntryObjects($sourceType, $record, fresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -247,6 +305,91 @@ private function resolveEntries(string $sourceType, string $surface, Model $reco
|
|||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function detailEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
return $this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationDetail,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION,
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function headerEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
return $this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationHeader,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER,
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePrimaryListAction(string $sourceType, Model $record, bool $fresh = false): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
$entries = array_values(array_filter(
|
||||||
|
$this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationPrimary,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
|
||||||
|
fresh: $fresh,
|
||||||
|
),
|
||||||
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $entries[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function memoizedEntries(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
string $sourceType,
|
||||||
|
Model $record,
|
||||||
|
string $surface,
|
||||||
|
bool $fresh = false,
|
||||||
|
): array {
|
||||||
|
$key = DerivedStateKey::fromModel(
|
||||||
|
family: $family,
|
||||||
|
record: $record,
|
||||||
|
variant: $sourceType,
|
||||||
|
context: [
|
||||||
|
'source_type' => $sourceType,
|
||||||
|
'surface' => $surface,
|
||||||
|
'active_tenant_id' => $this->activeTenantId(),
|
||||||
|
'route_name' => request()?->route()?->getName(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var list<RelatedContextEntry> $entries */
|
||||||
|
$entries = $fresh
|
||||||
|
? $this->derivedStateStore->resolveFresh(
|
||||||
|
$key,
|
||||||
|
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||||
|
$family->defaultFreshnessPolicy(),
|
||||||
|
$family->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: $this->derivedStateStore->resolve(
|
||||||
|
$key,
|
||||||
|
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||||
|
$family->defaultFreshnessPolicy(),
|
||||||
|
$family->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return match ($rule->sourceType) {
|
return match ($rule->sourceType) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user