Compare commits
6 Commits
175-worksp
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e840007127 | |||
| a107e7e41b | |||
| 1142d283eb | |||
| f52d52540c | |||
| dc46c4fa58 | |||
| 98be510362 |
18
.github/agents/copilot-instructions.md
vendored
18
.github/agents/copilot-instructions.md
vendored
@ -127,6 +127,18 @@ ## Active Technologies
|
||||
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
|
||||
- PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages (174-evidence-freshness-publication-trust)
|
||||
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention)
|
||||
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup)
|
||||
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
|
||||
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
|
||||
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers (181-restore-safety-integrity)
|
||||
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -146,8 +158,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
|
||||
- 173-tenant-dashboard-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page
|
||||
- 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
||||
- 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||
- 181-restore-safety-integrity: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers
|
||||
- 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,32 +1,20 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.14.0 -> 2.0.0
|
||||
- Version change: 2.0.0 -> 2.1.0
|
||||
- Modified principles:
|
||||
- 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)
|
||||
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||
with cross-reference to new HDR-001
|
||||
- Added sections:
|
||||
- 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
|
||||
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
||||
UI/UX Surface Classification and Operator Surface Contract tables already
|
||||
cover header action placement implicitly)
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
@ -535,7 +523,7 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||
|
||||
Actions and flows
|
||||
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups.
|
||||
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
||||
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||
- Destructive actions remain non-primary and confirmed.
|
||||
|
||||
@ -548,6 +536,121 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||
|
||||
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
|
||||
Goal: record and detail pages MUST be comprehensible within seconds.
|
||||
Header actions are reserved for the primary workflow of the current page
|
||||
and MUST NOT become a dumping ground for every available action or
|
||||
navigation jump.
|
||||
|
||||
##### Core rule
|
||||
|
||||
Header actions MUST contain only workflow-critical actions of the
|
||||
currently displayed record. Pure navigation, relational jumps, and
|
||||
contextual references do not belong in the header; they belong directly
|
||||
at the affected field, status indicator, or relation.
|
||||
|
||||
##### Maximum one primary visible header action
|
||||
|
||||
- Each record/detail page MUST expose at most one clearly prioritized
|
||||
primary visible header action.
|
||||
- That action MUST represent the most obvious next operator step on
|
||||
exactly this page.
|
||||
|
||||
##### Navigation does not belong in headers
|
||||
|
||||
- Actions such as "Open finding", "Open queue", "View related run",
|
||||
"Open tenant", or similar jumps are navigation actions, not primary
|
||||
object actions.
|
||||
- They MUST be placed as contextual navigation at fields, badges,
|
||||
relation entries, or status displays — never in the header.
|
||||
|
||||
##### Destructive or governance-changing actions require friction
|
||||
|
||||
- Actions with operational, security-relevant, or governance-changing
|
||||
effect MUST NOT stand at the same visual level as the primary action.
|
||||
- They MUST either:
|
||||
- be rendered as a clearly separated danger action, or
|
||||
- be placed in an Action Group / More Actions.
|
||||
- They MUST always require explicit confirmation
|
||||
(`->requiresConfirmation()`).
|
||||
- If an action changes governance truth, compliance status, risk
|
||||
acceptance, exception validity, or equivalent system truths,
|
||||
additional friction is mandatory (e.g., typed confirmation, reason
|
||||
field, or staged flow).
|
||||
|
||||
##### Rare secondary actions belong in an Action Group
|
||||
|
||||
- Actions that are not part of the expected core workflow of the page
|
||||
or are only occasionally needed MUST NOT appear as equally weighted
|
||||
visible header buttons.
|
||||
- They MUST be placed in an Action Group.
|
||||
|
||||
##### Header clarity over implementation convenience
|
||||
|
||||
- The fact that a framework makes header actions easy to add is not a
|
||||
reason to place actions there.
|
||||
- Information architecture, scanability, and operator clarity take
|
||||
precedence over implementation convenience.
|
||||
|
||||
##### 5-second scan rule
|
||||
|
||||
Every record/detail page MUST pass the 5-second scan rule:
|
||||
|
||||
1. The operator instantly recognizes where they are.
|
||||
2. The operator instantly sees the status of the object.
|
||||
3. The operator instantly identifies the one central next action.
|
||||
4. The operator immediately understands where secondary or dangerous
|
||||
actions live.
|
||||
|
||||
If multiple equally weighted header buttons degrade this readability,
|
||||
it is a constitution violation.
|
||||
|
||||
##### Placement rules
|
||||
|
||||
Allowed in the header:
|
||||
- One primary workflow action.
|
||||
- Optionally one clearly justified secondary action.
|
||||
- Rare or administrative actions only when grouped.
|
||||
- Critical/destructive actions only when separated and with friction.
|
||||
|
||||
Forbidden in the header:
|
||||
- Pure navigation to related objects.
|
||||
- Relational jumps without immediate workflow relevance.
|
||||
- Collections of technically available standard actions.
|
||||
- Multiple equally weighted buttons without clear prioritization.
|
||||
|
||||
##### Preferred pattern
|
||||
|
||||
| Slot | Placement |
|
||||
|---|---|
|
||||
| Primary visible | Exactly 1 |
|
||||
| Danger | Separated or grouped, never casual beside Primary |
|
||||
| Navigation | Inline at context (field, badge, relation) |
|
||||
| Rare actions | More / Action Group |
|
||||
|
||||
##### Binding decision — Exception / Approval surfaces
|
||||
|
||||
For exception detail pages specifically:
|
||||
- **Renew exception** MAY appear as the primary visible header action.
|
||||
- **Revoke exception** is a governance-changing danger action and MUST
|
||||
require friction (separated + confirmation).
|
||||
- **Open finding** MUST be placed as a link at the Finding field, not
|
||||
in the header.
|
||||
- **Open approval queue** MUST be placed as a contextual link at
|
||||
approval / status context, not in the header.
|
||||
|
||||
##### Reviewer heuristics
|
||||
|
||||
A page violates HDR-001 if any of the following are true:
|
||||
- Multiple equally weighted header actions without clear workflow
|
||||
priority.
|
||||
- Pure navigation buttons in the header.
|
||||
- Danger actions beside normal actions without clear separation.
|
||||
- Rarely used administrative actions as visible standard buttons.
|
||||
- The header resembles an action stockpile instead of a focused
|
||||
workflow entry point.
|
||||
|
||||
#### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
|
||||
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
|
||||
@ -672,6 +775,7 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Standard lists stay scanable.
|
||||
- Exceptions are catalogued, justified, and tested.
|
||||
- Features with ambiguous interaction semantics do not ship.
|
||||
- Header actions on record/detail pages expose at most one primary action; navigation belongs at context, not in the header.
|
||||
|
||||
#### Appendix B - Feature Review Checklist
|
||||
|
||||
@ -690,6 +794,9 @@ #### Appendix B - Feature Review Checklist
|
||||
- Critical truth is visible.
|
||||
- Scanability is preserved.
|
||||
- Exceptions are documented and tested.
|
||||
- Header passes the 5-second scan rule (HDR-001).
|
||||
- No pure navigation in the header.
|
||||
- Governance-changing actions have extra friction.
|
||||
|
||||
#### Appendix C - Red Flags for Future PRs
|
||||
|
||||
@ -704,6 +811,9 @@ #### Appendix C - Red Flags for Future PRs
|
||||
- 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.
|
||||
- Header has multiple equally weighted buttons without clear prioritization.
|
||||
- "Open X" navigation links placed in the header instead of at the related field.
|
||||
- Governance-changing actions sit casually beside the primary action without friction.
|
||||
|
||||
### Data Minimization & Safe Logging
|
||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||
@ -787,4 +897,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28
|
||||
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||
|
||||
@ -70,7 +70,8 @@ ## Constitution Check
|
||||
- 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
|
||||
- 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 (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
@ -72,6 +72,7 @@ # Tasks: [FEATURE NAME]
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
|
||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
|
||||
@ -6,19 +6,20 @@
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -56,6 +57,8 @@ class InventoryCoverage extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -67,7 +70,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->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.');
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->searchable()
|
||||
->searchPlaceholder('Search by policy type or label')
|
||||
->defaultSort('label')
|
||||
->searchPlaceholder('Search by type or label')
|
||||
->defaultSort('follow_up_priority')
|
||||
->defaultPaginationPageOption(50)
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
||||
->records(function (
|
||||
@ -142,14 +148,16 @@ public function table(Table $table): Table
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->sortable()
|
||||
->fontFamily(FontFamily::Mono)
|
||||
->copyable()
|
||||
->wrap(),
|
||||
TextColumn::make('coverage_state')
|
||||
->label('Coverage state')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
|
||||
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
|
||||
->sortable(),
|
||||
TextColumn::make('label')
|
||||
->label('Label')
|
||||
->label('Type')
|
||||
->sortable()
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state, array $record): string {
|
||||
@ -179,17 +187,29 @@ public function table(Table $table): Table
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('risk')
|
||||
->label('Risk')
|
||||
TextColumn::make('follow_up_guidance')
|
||||
->label('Follow-up guidance')
|
||||
->wrap()
|
||||
->toggleable(),
|
||||
TextColumn::make('observed_item_count')
|
||||
->label('Observed items')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('category')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
|
||||
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
|
||||
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
|
||||
->iconColor(function (?string $state): ?string {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->toggleable()
|
||||
->wrap(),
|
||||
TextColumn::make('restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (array $record): ?string => $record['restore'])
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
return filled($state)
|
||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
||||
@ -213,20 +233,7 @@ public function table(Table $table): Table
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
}),
|
||||
TextColumn::make('category')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
|
||||
->toggleable()
|
||||
->wrap(),
|
||||
TextColumn::make('segment')
|
||||
->label('Segment')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
||||
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
||||
})
|
||||
->toggleable(),
|
||||
IconColumn::make('dependencies')
|
||||
->label('Dependencies')
|
||||
@ -237,10 +244,31 @@ public function table(Table $table): Table
|
||||
->falseColor('gray')
|
||||
->alignCenter()
|
||||
->toggleable(),
|
||||
TextColumn::make('type')
|
||||
->label('Type key')
|
||||
->sortable()
|
||||
->fontFamily(FontFamily::Mono)
|
||||
->copyable()
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('segment')
|
||||
->label('Segment')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
||||
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('risk')
|
||||
->label('Risk')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters($this->tableFilters())
|
||||
->emptyStateHeading('No coverage entries match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
|
||||
->emptyStateHeading('No coverage rows match this report')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
@ -261,6 +289,14 @@ public function table(Table $table): Table
|
||||
protected function tableFilters(): array
|
||||
{
|
||||
$filters = [
|
||||
SelectFilter::make('coverage_state')
|
||||
->label('Coverage state')
|
||||
->options([
|
||||
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
|
||||
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
|
||||
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
|
||||
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
|
||||
]),
|
||||
SelectFilter::make('category')
|
||||
->label('Category')
|
||||
->options($this->categoryFilterOptions()),
|
||||
@ -279,84 +315,36 @@ protected function tableFilters(): array
|
||||
* @return Collection<string, array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* segment: string,
|
||||
* type: string,
|
||||
* segment: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* dependencies: bool,
|
||||
* platform: ?string,
|
||||
* coverage_state: string,
|
||||
* follow_up_required: bool,
|
||||
* follow_up_priority: int,
|
||||
* follow_up_guidance: string,
|
||||
* observed_item_count: int,
|
||||
* basis_item_count: ?int,
|
||||
* basis_error_code: ?string,
|
||||
* restore: ?string,
|
||||
* risk: string,
|
||||
* source_order: int
|
||||
* risk: ?string,
|
||||
* dependencies: bool,
|
||||
* is_basis_payload_backed: bool
|
||||
* }>
|
||||
*/
|
||||
protected function coverageRows(): Collection
|
||||
{
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
$truth = $this->coverageTruth();
|
||||
|
||||
$supported = $this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::supported(),
|
||||
segment: 'policy',
|
||||
sourceOrderOffset: 0,
|
||||
resolver: $resolver,
|
||||
);
|
||||
|
||||
return $supported->merge($this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::foundations(),
|
||||
segment: 'foundation',
|
||||
sourceOrderOffset: $supported->count(),
|
||||
resolver: $resolver,
|
||||
));
|
||||
if (! $truth instanceof TenantCoverageTruth) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return Collection<string, array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* segment: string,
|
||||
* type: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* dependencies: bool,
|
||||
* restore: ?string,
|
||||
* risk: string,
|
||||
* source_order: int
|
||||
* }>
|
||||
*/
|
||||
protected function mapCoverageRows(
|
||||
array $rows,
|
||||
string $segment,
|
||||
int $sourceOrderOffset,
|
||||
CoverageCapabilitiesResolver $resolver
|
||||
): Collection {
|
||||
return collect($rows)
|
||||
->values()
|
||||
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
|
||||
if ($type === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$key = "{$segment}:{$type}";
|
||||
$restore = $row['restore'] ?? null;
|
||||
$risk = $row['risk'] ?? 'n/a';
|
||||
|
||||
return [
|
||||
$key => [
|
||||
'__key' => $key,
|
||||
'key' => $key,
|
||||
'segment' => $segment,
|
||||
'type' => $type,
|
||||
'label' => (string) ($row['label'] ?? $type),
|
||||
'category' => (string) ($row['category'] ?? 'Other'),
|
||||
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
|
||||
'restore' => is_string($restore) ? $restore : null,
|
||||
'risk' => is_string($risk) ? $risk : 'n/a',
|
||||
'source_order' => $sourceOrderOffset + $index,
|
||||
],
|
||||
];
|
||||
});
|
||||
return collect($truth->rows)
|
||||
->mapWithKeys(static fn ($row): array => [
|
||||
$row->key => $row->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -367,6 +355,7 @@ protected function mapCoverageRows(
|
||||
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||
{
|
||||
$normalizedSearch = Str::lower(trim((string) $search));
|
||||
$coverageState = $filters['coverage_state']['value'] ?? null;
|
||||
$category = $filters['category']['value'] ?? null;
|
||||
$restore = $filters['restore']['value'] ?? null;
|
||||
|
||||
@ -380,6 +369,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
});
|
||||
},
|
||||
)
|
||||
->when(
|
||||
filled($coverageState),
|
||||
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
|
||||
)
|
||||
->when(
|
||||
filled($category),
|
||||
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
||||
@ -396,22 +389,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
*/
|
||||
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
|
||||
$sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
|
||||
? $sortColumn
|
||||
: null;
|
||||
|
||||
if ($sortColumn === null) {
|
||||
return $rows->sortBy('source_order');
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
||||
$comparison = strnatcasecmp(
|
||||
$comparison = match ($sortColumn) {
|
||||
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||
default => strnatcasecmp(
|
||||
(string) ($left[$sortColumn] ?? ''),
|
||||
(string) ($right[$sortColumn] ?? ''),
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
|
||||
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
|
||||
}
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left['label'] ?? ''),
|
||||
(string) ($right['label'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
||||
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function coverageSummary(): array
|
||||
{
|
||||
$truth = $this->coverageTruth();
|
||||
|
||||
if (! $truth instanceof TenantCoverageTruth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'supportedTypes' => $truth->supportedTypeCount,
|
||||
'succeededTypes' => $truth->succeededTypeCount,
|
||||
'followUpTypes' => $truth->followUpTypeCount,
|
||||
'observedItems' => $truth->observedItemTotal,
|
||||
'observedTypes' => $truth->observedTypeCount(),
|
||||
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
|
||||
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
|
||||
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function basisRunSummary(): array
|
||||
{
|
||||
$truth = $this->coverageTruth();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $truth->basisRun instanceof OperationRun) {
|
||||
return [
|
||||
'title' => 'No current coverage basis',
|
||||
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
|
||||
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
|
||||
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
|
||||
'badgeLabel' => null,
|
||||
'badgeColor' => null,
|
||||
'runUrl' => null,
|
||||
'historyUrl' => null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
|
||||
|
||||
return [
|
||||
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
|
||||
'body' => $canViewRun
|
||||
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
|
||||
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
||||
'badgeLabel' => $badge->label,
|
||||
'badgeColor' => $badge->color,
|
||||
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
protected function coverageTruth(): ?TenantCoverageTruth
|
||||
{
|
||||
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
|
||||
return $this->cachedCoverageTruth;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
||||
|
||||
return $this->cachedCoverageTruth;
|
||||
}
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
{
|
||||
return route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
@ -79,9 +80,23 @@ protected function getHeaderWidgets(): array
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
|
||||
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
array_splice($actions, 1, 0, [
|
||||
Action::make('operate_hub_back_to_origin_alerts')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@ -49,6 +50,8 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
public bool $showSelectedExceptionSummary = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
@ -116,11 +119,12 @@ public static function canAccess(): bool
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->selectedFindingException();
|
||||
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,6 +145,7 @@ protected function getHeaderActions(): array
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
@ -165,6 +170,7 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
});
|
||||
|
||||
$actions[] = Action::make('open_selected_exception')
|
||||
@ -325,8 +331,31 @@ public function table(Table $table): Table
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (FindingException $record): void {
|
||||
->before(function (FindingException $record): void {
|
||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(function (): string {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
return $record instanceof FindingException
|
||||
? 'Finding exception #'.$record->getKey()
|
||||
: 'Finding exception';
|
||||
})
|
||||
->modalDescription(fn (): ?string => $this->inspectedFindingException()?->requested_at?->toDayDateTimeString())
|
||||
->modalContent(function (): View {
|
||||
$record = $this->inspectedFindingException();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-unavailable');
|
||||
}
|
||||
|
||||
return view('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||
'selectedException' => $record,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
->bulkActions([])
|
||||
@ -343,6 +372,7 @@ public function table(Table $table): Table
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
@ -354,15 +384,7 @@ public function selectedFindingException(): ?FindingException
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->queueBaseQuery()
|
||||
->whereKey($this->selectedFindingExceptionId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
@ -508,6 +530,30 @@ private function hasActiveQueueFilters(): bool
|
||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||
}
|
||||
|
||||
private function resolveSelectedFindingException(int $findingExceptionId): FindingException
|
||||
{
|
||||
$record = $this->queueBaseQuery()
|
||||
->whereKey($findingExceptionId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function inspectedFindingException(): ?FindingException
|
||||
{
|
||||
$mountedRecord = $this->getMountedTableActionRecord();
|
||||
|
||||
if ($mountedRecord instanceof FindingException) {
|
||||
return $mountedRecord;
|
||||
}
|
||||
|
||||
return $this->selectedFindingException();
|
||||
}
|
||||
|
||||
private function governanceWarning(FindingException $record): ?string
|
||||
{
|
||||
$finding = $record->relationLoaded('finding')
|
||||
|
||||
@ -74,6 +74,8 @@ public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
$this->applyRequestedTenantScope();
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['type', 'initiator_name'],
|
||||
@ -187,6 +189,17 @@ public function table(Table $table): Table
|
||||
});
|
||||
}
|
||||
|
||||
private function applyRequestedTenantScope(): void
|
||||
{
|
||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{likely_stale:int,reconciled:int}
|
||||
*/
|
||||
@ -220,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
'active' => $query->healthyActive(),
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
|
||||
'blocked' => $query->dashboardNeedsFollowUp(),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
@ -258,6 +273,7 @@ private function scopedSummaryQuery(): ?Builder
|
||||
|
||||
private function applyRequestedDashboardPrefilter(): void
|
||||
{
|
||||
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||
$requestedTenantId = request()->query('tenant_id');
|
||||
|
||||
if (is_numeric($requestedTenantId)) {
|
||||
@ -265,11 +281,37 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
$requestedProblemClass = request()->query('problemClass');
|
||||
|
||||
if (in_array($requestedProblemClass, [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
$this->activeTab = (string) $requestedProblemClass;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedTab = request()->query('activeTab');
|
||||
|
||||
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
|
||||
if (in_array($requestedTab, [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
$this->activeTab = (string) $requestedTab;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldForceWorkspaceWideTenantScope(): bool
|
||||
{
|
||||
return request()->query('tenant_scope') === 'all';
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -244,6 +245,42 @@ public function lifecycleBanner(): ?array
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string, url: ?string, link_label: ?string}|null
|
||||
*/
|
||||
public function restoreContinuationBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$continuation = OperationRunResource::restoreContinuation($this->run);
|
||||
|
||||
if (! is_array($continuation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tone = ($continuation['follow_up_required'] ?? false) ? 'amber' : 'sky';
|
||||
$body = $continuation['summary'] ?? 'Restore continuation detail is unavailable.';
|
||||
$boundary = $continuation['recovery_claim_boundary'] ?? null;
|
||||
|
||||
if (is_string($boundary) && $boundary !== '') {
|
||||
$body .= ' '.RestoreSafetyCopy::recoveryBoundary($boundary);
|
||||
}
|
||||
|
||||
if (! ($continuation['link_available'] ?? false)) {
|
||||
$body .= ' Restore detail is not available from this session.';
|
||||
}
|
||||
|
||||
return [
|
||||
'tone' => $tone,
|
||||
'title' => 'Restore continuation',
|
||||
'body' => $body,
|
||||
'url' => is_string($continuation['link_url'] ?? null) ? $continuation['link_url'] : null,
|
||||
'link_label' => ($continuation['link_available'] ?? false) ? 'Open restore run' : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
|
||||
@ -87,7 +87,6 @@
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
@ -3334,7 +3333,7 @@ private function canInspectOperationRun(OperationRun $run): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows('view', $run);
|
||||
return $user->can('view', $run);
|
||||
}
|
||||
|
||||
public function verificationSucceeded(): bool
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
@ -161,6 +162,15 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
|
||||
'items' => fn ($itemQuery) => $itemQuery->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
@ -172,6 +182,11 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->state(fn (BackupSet $record): string => static::backupQualitySummary($record)->compactSummary)
|
||||
->description(fn (BackupSet $record): string => static::backupQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
||||
@ -659,6 +674,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
$metadataKeyCount = count($metadata);
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$isArchived = $record->trashed();
|
||||
$qualitySummary = static::backupQualitySummary($record);
|
||||
$qualityBadge = match (true) {
|
||||
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
|
||||
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
|
||||
};
|
||||
|
||||
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -667,14 +688,37 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
$qualityBadge,
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
|
||||
descriptionHint: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.',
|
||||
))
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
|
||||
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
|
||||
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
|
||||
$qualitySummary->unknownQualityCount > 0
|
||||
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$qualitySummary->nextAction,
|
||||
'Backup quality',
|
||||
),
|
||||
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
|
||||
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
|
||||
attentionNote: $qualitySummary->positiveClaimBoundary,
|
||||
title: 'Backup quality',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
@ -700,11 +744,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Recovery readiness',
|
||||
title: 'Backup quality counts',
|
||||
items: [
|
||||
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
|
||||
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
@ -740,4 +785,29 @@ private static function formatDetailTimestamp(mixed $value): string
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
if ($record->trashed()) {
|
||||
$record->setRelation('items', $record->items()->withTrashed()->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
])->get());
|
||||
} elseif (! $record->relationLoaded('items')) {
|
||||
$record->loadMissing([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
@ -279,11 +280,32 @@ public function table(Table $table): Table
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->badge()
|
||||
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('policyVersion.version_number')
|
||||
->label('Version')
|
||||
->badge()
|
||||
->default('—')
|
||||
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->compactSummary)
|
||||
->description(function (BackupItem $record): string {
|
||||
$summary = $this->backupItemQualitySummary($record);
|
||||
|
||||
if ($summary->assignmentCaptureReason === 'separate_role_assignments') {
|
||||
return 'Assignments are captured separately for this item type.';
|
||||
}
|
||||
|
||||
return $summary->nextAction;
|
||||
})
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
@ -480,6 +502,11 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
private function backupItemQualitySummary(BackupItem $record): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
return app(BackupQualityResolver::class)->forBackupItem($record);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
@ -16,6 +17,9 @@
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
@ -27,6 +31,7 @@
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
@ -264,6 +269,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||
$restoreContinuation = static::restoreContinuation($record);
|
||||
$supportingGroups = static::supportingGroups(
|
||||
record: $record,
|
||||
factory: $factory,
|
||||
@ -316,6 +322,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
),
|
||||
)
|
||||
: null,
|
||||
is_array($restoreContinuation)
|
||||
? $factory->keyFact(
|
||||
'Restore continuation',
|
||||
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
|
||||
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
|
||||
)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$primaryNextStep['text'],
|
||||
@ -472,6 +485,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
}
|
||||
}
|
||||
|
||||
$inventorySyncCoverageSection = static::inventorySyncCoverageSection($record);
|
||||
|
||||
if ($inventorySyncCoverageSection !== null) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'inventory_sync_coverage',
|
||||
kind: 'type_specific_detail',
|
||||
title: 'Inventory sync coverage',
|
||||
description: 'Per-type run results explain what this sync established without forcing operators into raw JSON first.',
|
||||
view: 'filament.infolists.entries.inventory-coverage-truth',
|
||||
viewData: $inventorySyncCoverageSection,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
@ -766,7 +794,7 @@ private static function artifactTruthFact(
|
||||
|
||||
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||
{
|
||||
return null;
|
||||
return OperationUxPresenter::decisionAttentionNote($record);
|
||||
}
|
||||
|
||||
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||
@ -1169,6 +1197,106 @@ private static function reconciliationPayload(OperationRun $record): array
|
||||
return $reconciliation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* rows: list<array{
|
||||
* type: string,
|
||||
* label: string,
|
||||
* segment: string,
|
||||
* category: string,
|
||||
* coverageState: string,
|
||||
* followUpRequired: bool,
|
||||
* followUpPriority: int,
|
||||
* followUpGuidance: string,
|
||||
* itemCount: int,
|
||||
* errorCode: ?string
|
||||
* }>,
|
||||
* summary: array{
|
||||
* totalTypes: int,
|
||||
* succeededTypes: int,
|
||||
* failedTypes: int,
|
||||
* skippedTypes: int,
|
||||
* followUpTypes: int,
|
||||
* observedItems: int
|
||||
* },
|
||||
* runOutcomeLabel: string,
|
||||
* runOutcomeColor: string,
|
||||
* runOutcomeIcon: ?string
|
||||
* }|null
|
||||
*/
|
||||
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
||||
{
|
||||
if ((string) $record->type !== 'inventory_sync') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage = $record->inventoryCoverage();
|
||||
|
||||
if (! $coverage instanceof InventoryCoverage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = collect($coverage->rows())
|
||||
->map(function (array $row): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$meta = InventoryPolicyTypeMeta::metaFor($type);
|
||||
$status = is_string($row['status'] ?? null) ? (string) $row['status'] : InventoryCoverage::StatusFailed;
|
||||
$errorCode = is_string($row['error_code'] ?? null) ? (string) $row['error_code'] : null;
|
||||
$itemCount = is_int($row['item_count'] ?? null) ? (int) $row['item_count'] : 0;
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'label' => is_string($meta['label'] ?? null) && $meta['label'] !== ''
|
||||
? (string) $meta['label']
|
||||
: $type,
|
||||
'segment' => (string) ($row['segment'] ?? 'policy'),
|
||||
'category' => is_string($meta['category'] ?? null) && $meta['category'] !== ''
|
||||
? (string) $meta['category']
|
||||
: 'Other',
|
||||
'coverageState' => $status,
|
||||
'followUpRequired' => $status !== InventoryCoverage::StatusSucceeded,
|
||||
'followUpPriority' => TenantCoverageTruthResolver::followUpPriorityForState($status),
|
||||
'followUpGuidance' => TenantCoverageTruthResolver::followUpGuidanceForState($status, $errorCode),
|
||||
'itemCount' => $itemCount,
|
||||
'errorCode' => $errorCode,
|
||||
];
|
||||
})
|
||||
->sort(function (array $left, array $right): int {
|
||||
$priority = ((int) ($left['followUpPriority'] ?? 0)) <=> ((int) ($right['followUpPriority'] ?? 0));
|
||||
|
||||
if ($priority !== 0) {
|
||||
return $priority;
|
||||
}
|
||||
|
||||
$items = ((int) ($right['itemCount'] ?? 0)) <=> ((int) ($left['itemCount'] ?? 0));
|
||||
|
||||
if ($items !== 0) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) ($left['label'] ?? ''), (string) ($right['label'] ?? ''));
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'summary' => [
|
||||
'totalTypes' => count($rows),
|
||||
'succeededTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSucceeded)),
|
||||
'failedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusFailed)),
|
||||
'skippedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSkipped)),
|
||||
'followUpTypes' => count(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false))),
|
||||
'observedItems' => array_sum(array_map(static fn (array $row): int => (int) ($row['itemCount'] ?? 0), $rows)),
|
||||
],
|
||||
'runOutcomeLabel' => $outcomeSpec->label,
|
||||
'runOutcomeColor' => $outcomeSpec->color,
|
||||
'runOutcomeIcon' => $outcomeSpec->icon,
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||
@ -1210,6 +1338,58 @@ private static function surfaceGuidance(OperationRun $record, bool $fresh = fals
|
||||
: OperationUxPresenter::surfaceGuidance($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* restore_run_id: int,
|
||||
* state: string,
|
||||
* summary: string,
|
||||
* primary_next_action: string,
|
||||
* recovery_claim_boundary: string,
|
||||
* follow_up_required: bool,
|
||||
* badge_label: string,
|
||||
* link_url: ?string,
|
||||
* link_available: bool
|
||||
* }|null
|
||||
*/
|
||||
public static function restoreContinuation(OperationRun $record): ?array
|
||||
{
|
||||
if ($record->type !== 'restore.execute') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$restoreRunId = is_numeric($context['restore_run_id'] ?? null) ? (int) $context['restore_run_id'] : null;
|
||||
|
||||
$restoreRun = $restoreRunId !== null
|
||||
? RestoreRun::query()->find($restoreRunId)
|
||||
: RestoreRun::query()->where('operation_run_id', (int) $record->getKey())->latest('id')->first();
|
||||
|
||||
if (! $restoreRun instanceof RestoreRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
$canOpenRestore = $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& app(\App\Services\Auth\CapabilityResolver::class)->isMember($user, $tenant);
|
||||
|
||||
return [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'state' => $attention->state,
|
||||
'summary' => $attention->summary,
|
||||
'primary_next_action' => $attention->primaryNextAction,
|
||||
'recovery_claim_boundary' => $attention->recoveryClaimBoundary,
|
||||
'follow_up_required' => $attention->followUpRequired,
|
||||
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
|
||||
'link_url' => $canOpenRestore
|
||||
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
|
||||
: null,
|
||||
'link_available' => $canOpenRestore,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
|
||||
@ -21,6 +21,9 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
@ -107,7 +110,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA routes operators to backup sets when no versions are available yet.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
|
||||
}
|
||||
|
||||
@ -129,6 +132,37 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Section::make('Backup quality')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Infolists\Components\TextEntry::make('quality_summary')
|
||||
->label('Backup quality')
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
||||
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
||||
->label('Assignment quality')
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
||||
Infolists\Components\TextEntry::make('quality_next_action')
|
||||
->label('Next action')
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
||||
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
||||
->label('Integrity note')
|
||||
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
||||
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('quality_boundary')
|
||||
->label('Boundary')
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
@ -528,6 +562,19 @@ public static function table(Table $table): Table
|
||||
->searchable()
|
||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
||||
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
@ -536,7 +583,7 @@ public static function table(Table $table): Table
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
@ -578,7 +625,7 @@ public static function table(Table $table): Table
|
||||
return $resolver->isMember($user, $tenant);
|
||||
})
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -617,7 +664,7 @@ public static function table(Table $table): Table
|
||||
return 'You do not have permission to create restore runs.';
|
||||
}
|
||||
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
}
|
||||
|
||||
@ -642,7 +689,7 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
Notification::make()
|
||||
->title('Restore disabled for metadata-only snapshot')
|
||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||
@ -699,11 +746,15 @@ public static function table(Table $table): Table
|
||||
|
||||
$backupItemMetadata = [
|
||||
'source' => 'policy_version',
|
||||
'snapshot_source' => $record->snapshotSource(),
|
||||
'display_name' => $policy->display_name,
|
||||
'policy_version_id' => $record->id,
|
||||
'policy_version_number' => $record->version_number,
|
||||
'version_captured_at' => $record->captured_at?->toIso8601String(),
|
||||
'redaction_version' => $record->redaction_version,
|
||||
'warnings' => $record->warningMessages(),
|
||||
'assignments_fetch_failed' => $record->assignmentsFetchFailed(),
|
||||
'has_orphaned_assignments' => $record->hasOrphanedAssignments(),
|
||||
];
|
||||
|
||||
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($record);
|
||||
@ -891,7 +942,13 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->emptyStateHeading('No policy versions')
|
||||
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
||||
->emptyStateIcon('heroicon-o-clock');
|
||||
->emptyStateIcon('heroicon-o-clock')
|
||||
->emptyStateActions([
|
||||
Actions\Action::make('open_backup_sets')
|
||||
->label('Open backup sets')
|
||||
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->color('gray'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -980,6 +1037,23 @@ private static function primaryRelatedAction(): Actions\Action
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function policyVersionQualitySummary(PolicyVersion $record): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
return app(BackupQualityResolver::class)->forPolicyVersion($record);
|
||||
}
|
||||
|
||||
private static function policyVersionAssignmentQualityLabel(PolicyVersion $record): string
|
||||
{
|
||||
$summary = static::policyVersionQualitySummary($record);
|
||||
|
||||
return match (true) {
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
|
||||
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
|
||||
default => 'No assignment issues were detected from captured metadata.',
|
||||
};
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
|
||||
@ -469,29 +469,12 @@ private static function migrationReviewDescription(?ProviderConnection $record):
|
||||
|
||||
private static function consentStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'required' => 'Required',
|
||||
'granted' => 'Granted',
|
||||
'failed' => 'Failed',
|
||||
'revoked' => 'Revoked',
|
||||
default => 'Unknown',
|
||||
};
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
|
||||
}
|
||||
|
||||
private static function verificationStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||
|
||||
return match ($value) {
|
||||
'pending' => 'Pending',
|
||||
'healthy' => 'Healthy',
|
||||
'degraded' => 'Degraded',
|
||||
'blocked' => 'Blocked',
|
||||
'error' => 'Error',
|
||||
default => 'Unknown',
|
||||
};
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -527,7 +510,7 @@ public static function form(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
Section::make('Current state')
|
||||
->schema([
|
||||
Placeholder::make('consent_status_display')
|
||||
->label('Consent')
|
||||
@ -535,18 +518,31 @@ public static function form(Schema $schema): Schema
|
||||
Placeholder::make('verification_status_display')
|
||||
->label('Verification')
|
||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||
TextInput::make('status')
|
||||
->label('Status')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
TextInput::make('health_status')
|
||||
->label('Health')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Placeholder::make('last_health_check_at_display')
|
||||
->label('Last check')
|
||||
->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Diagnostics')
|
||||
->schema([
|
||||
Placeholder::make('status_display')
|
||||
->label('Legacy status')
|
||||
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
|
||||
Placeholder::make('health_status_display')
|
||||
->label('Legacy health')
|
||||
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
|
||||
Placeholder::make('migration_review_status_display')
|
||||
->label('Migration review')
|
||||
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
Placeholder::make('last_error_reason_code_display')
|
||||
->label('Last error reason')
|
||||
->content(fn (?ProviderConnection $record): string => filled($record?->last_error_reason_code) ? (string) $record->last_error_reason_code : 'n/a'),
|
||||
Placeholder::make('last_error_message_display')
|
||||
->label('Last error message')
|
||||
->content(fn (?ProviderConnection $record): string => static::sanitizeErrorMessage($record?->last_error_message) ?? 'n/a')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -580,22 +576,55 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Status')
|
||||
Section::make('Current state')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('consent_status')
|
||||
->label('Consent')
|
||||
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||
Infolists\Components\TextEntry::make('verification_status')
|
||||
->label('Verification')
|
||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||
Infolists\Components\TextEntry::make('last_health_check_at')
|
||||
->label('Last check')
|
||||
->since(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Diagnostics')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->label('Status'),
|
||||
->label('Legacy status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
||||
Infolists\Components\TextEntry::make('health_status')
|
||||
->label('Health'),
|
||||
->label('Legacy health')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||
Infolists\Components\TextEntry::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
->placeholder('n/a'),
|
||||
Infolists\Components\TextEntry::make('last_error_message')
|
||||
->label('Last error message')
|
||||
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
||||
->placeholder('n/a')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
@ -655,20 +684,36 @@ public static function table(Table $table): Table
|
||||
? 'Dedicated'
|
||||
: 'Platform')
|
||||
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('consent_status')
|
||||
->label('Consent')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConsentStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||
Tables\Columns\TextColumn::make('verification_status')
|
||||
->label('Verification')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label('Status')
|
||||
->label('Legacy status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('health_status')
|
||||
->label('Health')
|
||||
->label('Legacy health')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->badge()
|
||||
@ -714,8 +759,45 @@ public static function table(Table $table): Table
|
||||
|
||||
return $query->where('provider_connections.provider', $value);
|
||||
}),
|
||||
SelectFilter::make('consent_status')
|
||||
->label('Consent')
|
||||
->options([
|
||||
'unknown' => 'Unknown',
|
||||
'required' => 'Required',
|
||||
'granted' => 'Granted',
|
||||
'failed' => 'Failed',
|
||||
'revoked' => 'Revoked',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('provider_connections.consent_status', $value);
|
||||
}),
|
||||
SelectFilter::make('verification_status')
|
||||
->label('Verification')
|
||||
->options([
|
||||
'unknown' => 'Unknown',
|
||||
'pending' => 'Pending',
|
||||
'healthy' => 'Healthy',
|
||||
'degraded' => 'Degraded',
|
||||
'blocked' => 'Blocked',
|
||||
'error' => 'Error',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('provider_connections.verification_status', $value);
|
||||
}),
|
||||
SelectFilter::make('status')
|
||||
->label('Status')
|
||||
->label('Diagnostic status')
|
||||
->options([
|
||||
'connected' => 'Connected',
|
||||
'needs_consent' => 'Needs consent',
|
||||
@ -732,7 +814,7 @@ public static function table(Table $table): Table
|
||||
return $query->where('provider_connections.status', $value);
|
||||
}),
|
||||
SelectFilter::make('health_status')
|
||||
->label('Health')
|
||||
->label('Diagnostic health')
|
||||
->options([
|
||||
'ok' => 'OK',
|
||||
'degraded' => 'Degraded',
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
@ -37,6 +38,10 @@
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\RestoreSafety\ChecksIntegrityState;
|
||||
use App\Support\RestoreSafety\PreviewIntegrityState;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -124,24 +129,8 @@ public static function form(Schema $schema): Schema
|
||||
->schema([
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->mapWithKeys(function (BackupSet $set) {
|
||||
$label = sprintf(
|
||||
'%s • %s items • %s',
|
||||
$set->name,
|
||||
$set->item_count ?? 0,
|
||||
optional($set->created_at)->format('Y-m-d H:i')
|
||||
);
|
||||
|
||||
return [$set->id => $label];
|
||||
});
|
||||
})
|
||||
->options(fn () => static::restoreBackupSetOptions())
|
||||
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('scope_mode', 'all');
|
||||
@ -159,7 +148,7 @@ public static function form(Schema $schema): Schema
|
||||
->bulkToggleable()
|
||||
->reactive()
|
||||
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||
->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'),
|
||||
->helperText(fn (): string => static::restoreItemQualityHelperText()),
|
||||
Section::make('Group mapping')
|
||||
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||
->schema(function (Get $get): array {
|
||||
@ -187,7 +176,7 @@ public static function form(Schema $schema): Schema
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
$isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
@ -306,52 +295,43 @@ public static function getWizardSteps(): array
|
||||
{
|
||||
return [
|
||||
Step::make('Select Backup Set')
|
||||
->description('What are we restoring from?')
|
||||
->description('What are we restoring from? Backup quality is visible here before safety checks run.')
|
||||
->schema([
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->mapWithKeys(function (BackupSet $set) {
|
||||
$label = sprintf(
|
||||
'%s • %s items • %s',
|
||||
$set->name,
|
||||
$set->item_count ?? 0,
|
||||
optional($set->created_at)->format('Y-m-d H:i')
|
||||
);
|
||||
|
||||
return [$set->id => $label];
|
||||
});
|
||||
})
|
||||
->options(fn () => static::restoreBackupSetOptions())
|
||||
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set, Get $get): void {
|
||||
$set('scope_mode', 'all');
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', static::groupMappingPlaceholders(
|
||||
$groupMapping = static::groupMappingPlaceholders(
|
||||
backupSetId: $get('backup_set_id'),
|
||||
scopeMode: 'all',
|
||||
selectedItemIds: null,
|
||||
tenant: static::resolveTenantContextForCurrentPanel(),
|
||||
));
|
||||
);
|
||||
|
||||
$set('scope_mode', 'all');
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', $groupMapping);
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
|
||||
$draft = static::synchronizeRestoreSafetyDraft([
|
||||
...static::draftDataSnapshot($get),
|
||||
'scope_mode' => 'all',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => $groupMapping,
|
||||
]);
|
||||
|
||||
$set('scope_basis', $draft['scope_basis']);
|
||||
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
|
||||
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
|
||||
})
|
||||
->required(),
|
||||
]),
|
||||
Step::make('Define Restore Scope')
|
||||
->description('What exactly should be restored?')
|
||||
->description('What exactly should be restored? Item quality hints appear here before restore risk checks.')
|
||||
->schema([
|
||||
Forms\Components\Radio::make('scope_mode')
|
||||
->label('Scope')
|
||||
@ -367,27 +347,45 @@ public static function getWizardSteps(): array
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
|
||||
if ($state === 'all') {
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', static::groupMappingPlaceholders(
|
||||
$groupMapping = static::groupMappingPlaceholders(
|
||||
backupSetId: $backupSetId,
|
||||
scopeMode: 'all',
|
||||
selectedItemIds: null,
|
||||
tenant: $tenant,
|
||||
));
|
||||
);
|
||||
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', $groupMapping);
|
||||
|
||||
$draft = static::synchronizeRestoreSafetyDraft([
|
||||
...static::draftDataSnapshot($get),
|
||||
'scope_mode' => 'all',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => $groupMapping,
|
||||
]);
|
||||
|
||||
$set('scope_basis', $draft['scope_basis']);
|
||||
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
|
||||
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$set('group_mapping', []);
|
||||
$set('backup_item_ids', []);
|
||||
|
||||
$draft = static::synchronizeRestoreSafetyDraft([
|
||||
...static::draftDataSnapshot($get),
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [],
|
||||
'group_mapping' => [],
|
||||
]);
|
||||
|
||||
$set('scope_basis', $draft['scope_basis']);
|
||||
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
|
||||
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
|
||||
})
|
||||
->required(),
|
||||
Forms\Components\Select::make('backup_item_ids')
|
||||
@ -414,12 +412,21 @@ public static function getWizardSteps(): array
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
|
||||
$draft = static::synchronizeRestoreSafetyDraft([
|
||||
...static::draftDataSnapshot($get),
|
||||
'backup_item_ids' => $selectedItemIds ?? [],
|
||||
'group_mapping' => static::groupMappingPlaceholders(
|
||||
backupSetId: $backupSetId,
|
||||
scopeMode: 'selected',
|
||||
selectedItemIds: $selectedItemIds,
|
||||
tenant: $tenant,
|
||||
),
|
||||
]);
|
||||
|
||||
$set('scope_basis', $draft['scope_basis']);
|
||||
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
|
||||
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
@ -447,7 +454,7 @@ public static function getWizardSteps(): array
|
||||
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
|
||||
])
|
||||
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'),
|
||||
->helperText(fn (): string => static::restoreItemQualityHelperText()),
|
||||
Section::make('Group mapping')
|
||||
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||
->schema(function (Get $get): array {
|
||||
@ -482,7 +489,7 @@ public static function getWizardSteps(): array
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
$isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
@ -495,13 +502,16 @@ public static function getWizardSteps(): array
|
||||
->placeholder('SKIP or target group Object ID (GUID)')
|
||||
->rules([new SkipOrUuidRule])
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
$set('preview_summary', null);
|
||||
$set('preview_diffs', []);
|
||||
$set('preview_ran_at', null);
|
||||
->afterStateUpdated(function (Set $set, Get $get): void {
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
|
||||
$draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get));
|
||||
|
||||
$set('scope_basis', $draft['scope_basis']);
|
||||
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
|
||||
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
|
||||
})
|
||||
->required()
|
||||
->suffixAction(
|
||||
@ -554,10 +564,16 @@ public static function getWizardSteps(): array
|
||||
Step::make('Safety & Conflict Checks')
|
||||
->description('Is this dangerous?')
|
||||
->schema([
|
||||
Forms\Components\Hidden::make('scope_basis')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('check_summary')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('checks_ran_at')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('check_basis')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('check_invalidation_reasons')
|
||||
->default([]),
|
||||
Forms\Components\ViewField::make('check_results')
|
||||
->label('Checks')
|
||||
->default([])
|
||||
@ -565,6 +581,7 @@ public static function getWizardSteps(): array
|
||||
->viewData(fn (Get $get): array => [
|
||||
'summary' => $get('check_summary'),
|
||||
'ranAt' => $get('checks_ran_at'),
|
||||
...static::wizardSafetyState(static::draftDataSnapshot($get)),
|
||||
])
|
||||
->hintActions([
|
||||
Actions\Action::make('run_restore_checks')
|
||||
@ -614,9 +631,23 @@ public static function getWizardSteps(): array
|
||||
groupMapping: $groupMapping,
|
||||
);
|
||||
|
||||
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
|
||||
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
|
||||
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||
$ranAt = now('UTC')->toIso8601String();
|
||||
$draft = [
|
||||
...static::draftDataSnapshot($get),
|
||||
'check_summary' => $outcome['summary'] ?? [],
|
||||
'check_results' => $outcome['results'] ?? [],
|
||||
'checks_ran_at' => $ranAt,
|
||||
];
|
||||
$draft['check_basis'] = static::restoreSafetyResolver()->checksBasisFromData($draft);
|
||||
$draft['check_invalidation_reasons'] = [];
|
||||
$draft = static::synchronizeRestoreSafetyDraft($draft);
|
||||
|
||||
$set('check_summary', $draft['check_summary'], shouldCallUpdatedHooks: true);
|
||||
$set('check_results', $draft['check_results'], shouldCallUpdatedHooks: true);
|
||||
$set('checks_ran_at', $ranAt, shouldCallUpdatedHooks: true);
|
||||
$set('check_basis', $draft['check_basis'], shouldCallUpdatedHooks: true);
|
||||
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
|
||||
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
|
||||
|
||||
$summary = $outcome['summary'] ?? [];
|
||||
$blockers = (int) ($summary['blocking'] ?? 0);
|
||||
@ -644,6 +675,8 @@ public static function getWizardSteps(): array
|
||||
$set('check_summary', null, shouldCallUpdatedHooks: true);
|
||||
$set('check_results', [], shouldCallUpdatedHooks: true);
|
||||
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
|
||||
$set('check_basis', null, shouldCallUpdatedHooks: true);
|
||||
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
|
||||
}),
|
||||
])
|
||||
->helperText('Run checks after defining scope and mapping missing groups.'),
|
||||
@ -656,6 +689,10 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Hidden::make('preview_ran_at')
|
||||
->default(null)
|
||||
->required(),
|
||||
Forms\Components\Hidden::make('preview_basis')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('preview_invalidation_reasons')
|
||||
->default([]),
|
||||
Forms\Components\ViewField::make('preview_diffs')
|
||||
->label('Preview')
|
||||
->default([])
|
||||
@ -663,6 +700,7 @@ public static function getWizardSteps(): array
|
||||
->viewData(fn (Get $get): array => [
|
||||
'summary' => $get('preview_summary'),
|
||||
'ranAt' => $get('preview_ran_at'),
|
||||
...static::wizardSafetyState(static::draftDataSnapshot($get)),
|
||||
])
|
||||
->hintActions([
|
||||
Actions\Action::make('run_restore_preview')
|
||||
@ -711,10 +749,23 @@ public static function getWizardSteps(): array
|
||||
|
||||
$summary = $outcome['summary'] ?? [];
|
||||
$diffs = $outcome['diffs'] ?? [];
|
||||
$ranAt = (string) ($summary['generated_at'] ?? now('UTC')->toIso8601String());
|
||||
$draft = [
|
||||
...static::draftDataSnapshot($get),
|
||||
'preview_summary' => $summary,
|
||||
'preview_diffs' => $diffs,
|
||||
'preview_ran_at' => $ranAt,
|
||||
];
|
||||
$draft['preview_basis'] = static::restoreSafetyResolver()->previewBasisFromData($draft);
|
||||
$draft['preview_invalidation_reasons'] = [];
|
||||
$draft = static::synchronizeRestoreSafetyDraft($draft);
|
||||
|
||||
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
|
||||
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
|
||||
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||
$set('preview_ran_at', $ranAt, shouldCallUpdatedHooks: true);
|
||||
$set('preview_basis', $draft['preview_basis'], shouldCallUpdatedHooks: true);
|
||||
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
|
||||
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
|
||||
|
||||
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
@ -737,6 +788,8 @@ public static function getWizardSteps(): array
|
||||
$set('preview_summary', null, shouldCallUpdatedHooks: true);
|
||||
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
|
||||
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
|
||||
$set('preview_basis', null, shouldCallUpdatedHooks: true);
|
||||
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
|
||||
}),
|
||||
])
|
||||
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
|
||||
@ -760,24 +813,66 @@ public static function getWizardSteps(): array
|
||||
|
||||
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
}),
|
||||
Forms\Components\Placeholder::make('confirm_execution_readiness')
|
||||
->label('Technical startability')
|
||||
->content(function (Get $get): string {
|
||||
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
|
||||
$readiness = $state['executionReadiness'];
|
||||
|
||||
if (! is_array($readiness)) {
|
||||
return 'Execution readiness is unavailable.';
|
||||
}
|
||||
|
||||
return (string) ($readiness['display_summary'] ?? 'Execution readiness is unavailable.');
|
||||
}),
|
||||
Forms\Components\Placeholder::make('confirm_safety_readiness')
|
||||
->label('Safety readiness')
|
||||
->content(function (Get $get): string {
|
||||
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
|
||||
$assessment = $state['safetyAssessment'];
|
||||
|
||||
if (! is_array($assessment)) {
|
||||
return 'Safety readiness is unavailable.';
|
||||
}
|
||||
|
||||
return (string) ($assessment['summary'] ?? 'Safety readiness is unavailable.');
|
||||
}),
|
||||
Forms\Components\Placeholder::make('confirm_primary_next_step')
|
||||
->label('Primary next step')
|
||||
->content(function (Get $get): string {
|
||||
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
|
||||
$assessment = $state['safetyAssessment'];
|
||||
|
||||
if (! is_array($assessment)) {
|
||||
return 'Review the current scope and safety evidence.';
|
||||
}
|
||||
|
||||
return RestoreSafetyCopy::primaryNextAction(
|
||||
is_string($assessment['primary_next_action'] ?? null)
|
||||
? $assessment['primary_next_action']
|
||||
: 'review_scope'
|
||||
);
|
||||
}),
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true)
|
||||
->reactive()
|
||||
->disabled(function (Get $get): bool {
|
||||
if (! filled($get('checks_ran_at'))) {
|
||||
return true;
|
||||
}
|
||||
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
|
||||
$readiness = $state['executionReadiness'];
|
||||
|
||||
$summary = $get('check_summary');
|
||||
|
||||
if (! is_array($summary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($summary['blocking'] ?? 0) > 0;
|
||||
return ! is_array($readiness) || ! (bool) ($readiness['allowed'] ?? false);
|
||||
})
|
||||
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'),
|
||||
->helperText(function (Get $get): string {
|
||||
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
|
||||
$assessment = $state['safetyAssessment'];
|
||||
|
||||
if (! is_array($assessment)) {
|
||||
return 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.';
|
||||
}
|
||||
|
||||
return (string) ($assessment['summary'] ?? 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.');
|
||||
}),
|
||||
Forms\Components\Checkbox::make('acknowledged_impact')
|
||||
->label('I reviewed the impact (checks + preview)')
|
||||
->accepted()
|
||||
@ -1290,11 +1385,11 @@ public static function infolist(Schema $schema): Schema
|
||||
Infolists\Components\ViewEntry::make('preview')
|
||||
->label('Preview')
|
||||
->view('filament.infolists.entries.restore-preview')
|
||||
->state(fn ($record) => $record->preview ?? []),
|
||||
->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
|
||||
Infolists\Components\ViewEntry::make('results')
|
||||
->label('Results')
|
||||
->view('filament.infolists.entries.restore-results')
|
||||
->state(fn ($record) => $record->results ?? []),
|
||||
->state(fn (RestoreRun $record): array => static::detailResultsState($record)),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1366,6 +1461,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
|
||||
foreach ($items as $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
$qualitySummary = static::backupItemQualitySummary($item);
|
||||
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||
$category = $meta['category'] ?? 'Policies';
|
||||
$restore = $meta['restore'] ?? 'enabled';
|
||||
@ -1380,6 +1476,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$category,
|
||||
$typeLabel,
|
||||
$platform,
|
||||
'quality: '.$qualitySummary->compactSummary,
|
||||
"restore: {$restore}",
|
||||
$versionNumber ? "version: {$versionNumber}" : null,
|
||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||
@ -1445,12 +1542,111 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
]));
|
||||
|
||||
$groups[$groupLabel] ??= [];
|
||||
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
|
||||
$groups[$groupLabel][$item->id] = static::restoreItemSelectionLabel($item);
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function restoreBackupSetOptions(): array
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
])
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->mapWithKeys(fn (BackupSet $set): array => [
|
||||
(int) $set->getKey() => static::restoreBackupSetSelectionLabel($set),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function restoreBackupSetSelectionLabel(BackupSet $set): string
|
||||
{
|
||||
$qualitySummary = static::backupSetQualitySummary($set);
|
||||
|
||||
return implode(' • ', array_filter([
|
||||
$set->name,
|
||||
sprintf('%d items', (int) ($set->item_count ?? 0)),
|
||||
optional($set->created_at)->format('Y-m-d H:i'),
|
||||
$qualitySummary->compactSummary,
|
||||
]));
|
||||
}
|
||||
|
||||
private static function restoreBackupSetHelperText(mixed $backupSetId): string
|
||||
{
|
||||
$default = 'Backup quality hints describe input strength only. They do not approve restore execution or prove recoverability.';
|
||||
|
||||
if (! is_numeric($backupSetId)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->with([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
])
|
||||
->find((int) $backupSetId);
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$summary = static::backupSetQualitySummary($backupSet);
|
||||
|
||||
return $summary->compactSummary.'. '.$summary->positiveClaimBoundary;
|
||||
}
|
||||
|
||||
private static function restoreItemSelectionLabel(BackupItem $item): string
|
||||
{
|
||||
$summary = static::backupItemQualitySummary($item);
|
||||
|
||||
return implode(' • ', array_filter([
|
||||
$item->resolvedDisplayName(),
|
||||
$summary->compactSummary,
|
||||
]));
|
||||
}
|
||||
|
||||
private static function restoreItemQualityHelperText(): string
|
||||
{
|
||||
return 'Quality hints describe input strength before risk checks. Include foundations with policies when you need ID re-mapping context.';
|
||||
}
|
||||
|
||||
private static function backupSetQualitySummary(BackupSet $backupSet): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
return app(BackupQualityResolver::class)->summarizeBackupSet($backupSet);
|
||||
}
|
||||
|
||||
private static function backupItemQualitySummary(BackupItem $backupItem): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
return app(BackupQualityResolver::class)->forBackupItem($backupItem);
|
||||
}
|
||||
|
||||
public static function createRestoreRun(array $data): RestoreRun
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1471,6 +1667,56 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BackupSet $backupSet */
|
||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||
|
||||
if ($backupSet->tenant_id !== $tenant->id) {
|
||||
abort(403, 'Backup set does not belong to the active tenant.');
|
||||
}
|
||||
|
||||
/** @var RestoreService $service */
|
||||
$service = app(RestoreService::class);
|
||||
|
||||
$scopeMode = $data['scope_mode'] ?? 'all';
|
||||
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
|
||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||
|
||||
$actorEmail = auth()->user()?->email;
|
||||
$actorName = auth()->user()?->name;
|
||||
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
|
||||
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
|
||||
$data = static::synchronizeRestoreSafetyDraft([
|
||||
...$data,
|
||||
'group_mapping' => $groupMapping,
|
||||
]);
|
||||
$restoreSafetyResolver = static::restoreSafetyResolver();
|
||||
$scopeBasis = is_array($data['scope_basis'] ?? null)
|
||||
? $data['scope_basis']
|
||||
: $restoreSafetyResolver->scopeBasisFromData($data);
|
||||
$checkBasis = is_array($data['check_basis'] ?? null)
|
||||
? $data['check_basis']
|
||||
: $restoreSafetyResolver->checksBasisFromData($data);
|
||||
$previewBasis = is_array($data['preview_basis'] ?? null)
|
||||
? $data['preview_basis']
|
||||
: $restoreSafetyResolver->previewBasisFromData($data);
|
||||
$data = [
|
||||
...$data,
|
||||
'scope_basis' => $scopeBasis,
|
||||
'check_basis' => $checkBasis,
|
||||
'preview_basis' => $previewBasis,
|
||||
];
|
||||
|
||||
$checkSummary = $data['check_summary'] ?? null;
|
||||
$checkResults = $data['check_results'] ?? null;
|
||||
$checksRanAt = $data['checks_ran_at'] ?? null;
|
||||
$previewSummary = $data['preview_summary'] ?? null;
|
||||
$previewDiffs = $data['preview_diffs'] ?? null;
|
||||
$previewRanAt = $data['preview_ran_at'] ?? null;
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
|
||||
if (! $isDryRun) {
|
||||
try {
|
||||
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
|
||||
} catch (ProviderAccessHardeningRequired $e) {
|
||||
@ -1502,57 +1748,40 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var BackupSet $backupSet */
|
||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||
$previewIntegrity = $restoreSafetyResolver->previewIntegrityFromData($data);
|
||||
$checksIntegrity = $restoreSafetyResolver->checksIntegrityFromData($data);
|
||||
$assessment = $restoreSafetyResolver->safetyAssessment($tenant, $user, $data);
|
||||
|
||||
if ($backupSet->tenant_id !== $tenant->id) {
|
||||
abort(403, 'Backup set does not belong to the active tenant.');
|
||||
}
|
||||
|
||||
/** @var RestoreService $service */
|
||||
$service = app(RestoreService::class);
|
||||
|
||||
$scopeMode = $data['scope_mode'] ?? 'all';
|
||||
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
|
||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||
|
||||
$actorEmail = auth()->user()?->email;
|
||||
$actorName = auth()->user()?->name;
|
||||
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
|
||||
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
|
||||
|
||||
$checkSummary = $data['check_summary'] ?? null;
|
||||
$checkResults = $data['check_results'] ?? null;
|
||||
$checksRanAt = $data['checks_ran_at'] ?? null;
|
||||
$previewSummary = $data['preview_summary'] ?? null;
|
||||
$previewDiffs = $data['preview_diffs'] ?? null;
|
||||
$previewRanAt = $data['preview_ran_at'] ?? null;
|
||||
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||
|
||||
if (! $isDryRun) {
|
||||
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
|
||||
if ($checksIntegrity->state === ChecksIntegrityState::STATE_NOT_RUN) {
|
||||
throw ValidationException::withMessages([
|
||||
'check_summary' => 'Run safety checks before executing.',
|
||||
]);
|
||||
}
|
||||
|
||||
$blocking = (int) ($checkSummary['blocking'] ?? 0);
|
||||
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
|
||||
if ($checksIntegrity->state !== ChecksIntegrityState::STATE_CURRENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'check_summary' => 'Run safety checks again for the current scope before executing.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($blocking > 0 || $hasBlockers) {
|
||||
if ($checksIntegrity->blockingCount > 0 || $assessment->state === 'blocked') {
|
||||
throw ValidationException::withMessages([
|
||||
'check_summary' => 'Blocking checks must be resolved before executing.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! filled($previewRanAt)) {
|
||||
if ($previewIntegrity->state === PreviewIntegrityState::STATE_NOT_GENERATED) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_ran_at' => 'Generate preview before executing.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($previewIntegrity->state !== PreviewIntegrityState::STATE_CURRENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_ran_at' => 'Generate preview again for the current scope before executing.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
|
||||
throw ValidationException::withMessages([
|
||||
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
|
||||
@ -1605,6 +1834,16 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$metadata['preview_ran_at'] = $previewRanAt;
|
||||
}
|
||||
|
||||
$metadata['scope_basis'] = $scopeBasis;
|
||||
|
||||
if (is_array($checkBasis)) {
|
||||
$metadata['check_basis'] = $checkBasis;
|
||||
}
|
||||
|
||||
if (is_array($previewBasis)) {
|
||||
$metadata['preview_basis'] = $previewBasis;
|
||||
}
|
||||
|
||||
$restoreRun->update(['metadata' => $metadata]);
|
||||
|
||||
return $restoreRun->refresh();
|
||||
@ -1619,6 +1858,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
'confirmed_at' => now()->toIso8601String(),
|
||||
'confirmed_by' => $actorEmail,
|
||||
'confirmed_by_name' => $actorName,
|
||||
'scope_basis' => $scopeBasis,
|
||||
];
|
||||
|
||||
if (is_array($checkSummary)) {
|
||||
@ -1645,6 +1885,18 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$metadata['preview_ran_at'] = $previewRanAt;
|
||||
}
|
||||
|
||||
if (is_array($checkBasis)) {
|
||||
$metadata['check_basis'] = $checkBasis;
|
||||
}
|
||||
|
||||
if (is_array($previewBasis)) {
|
||||
$metadata['preview_basis'] = $previewBasis;
|
||||
}
|
||||
|
||||
$metadata['execution_safety_snapshot'] = $restoreSafetyResolver
|
||||
->executionSafetySnapshot($tenant, $user, $data)
|
||||
->toArray();
|
||||
|
||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
@ -1768,6 +2020,145 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
return $restoreRun->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function synchronizeRestoreSafetyDraft(array $data): array
|
||||
{
|
||||
$resolver = static::restoreSafetyResolver();
|
||||
$scope = $resolver->scopeFingerprintFromData($data);
|
||||
|
||||
$data['scope_basis'] = $resolver->scopeBasisFromData($data);
|
||||
$data['check_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
|
||||
currentScope: $scope,
|
||||
basis: is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null,
|
||||
explicitReasons: $data['check_invalidation_reasons'] ?? null,
|
||||
);
|
||||
$data['preview_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
|
||||
currentScope: $scope,
|
||||
basis: is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null,
|
||||
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function wizardSafetyState(array $data): array
|
||||
{
|
||||
$data = static::synchronizeRestoreSafetyDraft($data);
|
||||
$resolver = static::restoreSafetyResolver();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$scope = $resolver->scopeFingerprintFromData($data)->toArray();
|
||||
$previewIntegrity = $resolver->previewIntegrityFromData($data);
|
||||
$checksIntegrity = $resolver->checksIntegrityFromData($data);
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return [
|
||||
'currentScope' => $scope,
|
||||
'previewIntegrity' => $previewIntegrity->toArray(),
|
||||
'checksIntegrity' => $checksIntegrity->toArray(),
|
||||
'executionReadiness' => null,
|
||||
'safetyAssessment' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$assessment = $resolver->safetyAssessment($tenant, $user, $data);
|
||||
|
||||
return [
|
||||
'currentScope' => $scope,
|
||||
'previewIntegrity' => $previewIntegrity->toArray(),
|
||||
'checksIntegrity' => $checksIntegrity->toArray(),
|
||||
'executionReadiness' => $assessment->executionReadiness->toArray(),
|
||||
'safetyAssessment' => $assessment->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function draftDataSnapshot(Get $get): array
|
||||
{
|
||||
return [
|
||||
'backup_set_id' => $get('backup_set_id'),
|
||||
'scope_mode' => $get('scope_mode'),
|
||||
'backup_item_ids' => $get('backup_item_ids'),
|
||||
'group_mapping' => static::normalizeGroupMapping($get('group_mapping')),
|
||||
'check_summary' => $get('check_summary'),
|
||||
'check_results' => $get('check_results'),
|
||||
'checks_ran_at' => $get('checks_ran_at'),
|
||||
'check_basis' => $get('check_basis'),
|
||||
'check_invalidation_reasons' => $get('check_invalidation_reasons'),
|
||||
'preview_summary' => $get('preview_summary'),
|
||||
'preview_diffs' => $get('preview_diffs'),
|
||||
'preview_ran_at' => $get('preview_ran_at'),
|
||||
'preview_basis' => $get('preview_basis'),
|
||||
'preview_invalidation_reasons' => $get('preview_invalidation_reasons'),
|
||||
'scope_basis' => $get('scope_basis'),
|
||||
'is_dry_run' => $get('is_dry_run'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* preview: array<int|string, mixed>,
|
||||
* previewIntegrity: array<string, mixed>,
|
||||
* checksIntegrity: array<string, mixed>,
|
||||
* executionSafetySnapshot: array<string, mixed>,
|
||||
* scopeBasis: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private static function detailPreviewState(RestoreRun $record): array
|
||||
{
|
||||
$resolver = static::restoreSafetyResolver();
|
||||
$data = [
|
||||
'backup_set_id' => $record->backup_set_id,
|
||||
'scope_mode' => (string) (($record->scopeBasis()['scope_mode'] ?? null) ?: ((is_array($record->requested_items) && $record->requested_items !== []) ? 'selected' : 'all')),
|
||||
'backup_item_ids' => is_array($record->requested_items) ? $record->requested_items : [],
|
||||
'group_mapping' => is_array($record->group_mapping) ? $record->group_mapping : [],
|
||||
'preview_basis' => $record->previewBasis(),
|
||||
'check_basis' => $record->checkBasis(),
|
||||
'check_summary' => is_array(($record->metadata ?? [])['check_summary'] ?? null) ? $record->metadata['check_summary'] : [],
|
||||
'checks_ran_at' => $record->checkBasis()['ran_at'] ?? (($record->metadata ?? [])['checks_ran_at'] ?? null),
|
||||
'preview_summary' => is_array(($record->metadata ?? [])['preview_summary'] ?? null) ? $record->metadata['preview_summary'] : [],
|
||||
'preview_ran_at' => $record->previewBasis()['generated_at'] ?? (($record->metadata ?? [])['preview_ran_at'] ?? null),
|
||||
];
|
||||
|
||||
return [
|
||||
'preview' => is_array($record->preview) ? $record->preview : [],
|
||||
'previewIntegrity' => $resolver->previewIntegrityFromData($data)->toArray(),
|
||||
'checksIntegrity' => $resolver->checksIntegrityFromData($data)->toArray(),
|
||||
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
|
||||
'scopeBasis' => $record->scopeBasis(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* results: array<string, mixed>|array<int|string, mixed>,
|
||||
* resultAttention: array<string, mixed>,
|
||||
* executionSafetySnapshot: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private static function detailResultsState(RestoreRun $record): array
|
||||
{
|
||||
return [
|
||||
'results' => is_array($record->results) ? $record->results : [],
|
||||
'resultAttention' => static::restoreSafetyResolver()->resultAttentionForRun($record)->toArray(),
|
||||
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function restoreSafetyResolver(): RestoreSafetyResolver
|
||||
{
|
||||
return app(RestoreSafetyResolver::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @return array<int, array{id:string,label:string}>
|
||||
|
||||
@ -96,6 +96,9 @@ protected function afterFill(): void
|
||||
|
||||
$this->form->callAfterStateUpdated('data.backup_item_ids');
|
||||
}
|
||||
|
||||
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data);
|
||||
$this->form->fill($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,13 +154,10 @@ protected function handleRecordCreation(array $data): Model
|
||||
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
|
||||
{
|
||||
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
|
||||
|
||||
$this->data['check_summary'] = null;
|
||||
$this->data['check_results'] = [];
|
||||
$this->data['checks_ran_at'] = null;
|
||||
$this->data['preview_summary'] = null;
|
||||
$this->data['preview_diffs'] = [];
|
||||
$this->data['preview_ran_at'] = null;
|
||||
$this->data['is_dry_run'] = true;
|
||||
$this->data['acknowledged_impact'] = false;
|
||||
$this->data['tenant_confirm'] = null;
|
||||
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data);
|
||||
|
||||
$this->form->fill($this->data);
|
||||
|
||||
|
||||
@ -284,13 +284,6 @@ public static function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('app_status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->since()
|
||||
@ -310,13 +303,6 @@ public static function table(Table $table): Table
|
||||
'staging' => 'STAGING',
|
||||
'other' => 'Other',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('app_status')
|
||||
->options([
|
||||
'ok' => 'OK',
|
||||
'consent_required' => 'Consent required',
|
||||
'error' => 'Error',
|
||||
'unknown' => 'Unknown',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('related_onboarding')
|
||||
@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Lifecycle summary')
|
||||
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('app_status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
{
|
||||
$ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin');
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
$defaultConnection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$connection = $defaultConnection instanceof ProviderConnection
|
||||
? $defaultConnection
|
||||
: ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return [
|
||||
'state' => 'needs_action',
|
||||
'state' => 'missing',
|
||||
'cta_url' => $ctaUrl,
|
||||
'needs_default_connection' => false,
|
||||
'display_name' => null,
|
||||
'provider' => null,
|
||||
'consent_status' => null,
|
||||
'verification_status' => null,
|
||||
'status' => null,
|
||||
'health_status' => null,
|
||||
'last_health_check_at' => null,
|
||||
@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
return [
|
||||
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
||||
'cta_url' => $ctaUrl,
|
||||
'needs_default_connection' => ! $connection->is_default,
|
||||
'display_name' => (string) $connection->display_name,
|
||||
'provider' => (string) $connection->provider,
|
||||
'consent_status' => $connection->consent_status instanceof BackedEnum
|
||||
? (string) $connection->consent_status->value
|
||||
: (is_string($connection->consent_status) ? $connection->consent_status : null),
|
||||
'verification_status' => $connection->verification_status instanceof BackedEnum
|
||||
? (string) $connection->verification_status->value
|
||||
: (is_string($connection->verification_status) ? $connection->verification_status : null),
|
||||
'status' => is_string($connection->status) ? $connection->status : null,
|
||||
'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
|
||||
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -113,16 +114,46 @@ public function table(Table $table): Table
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -94,16 +95,46 @@ public function table(Table $table): Table
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -112,10 +113,23 @@ public function table(Table $table): Table
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(null),
|
||||
TextColumn::make('stuck_class')
|
||||
->label('Stuck class')
|
||||
->state(function (OperationRun $record): string {
|
||||
@ -126,6 +140,7 @@ public function table(Table $table): Table
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
|
||||
@ -23,13 +23,7 @@ class DashboardKpis extends StatsOverviewWidget
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,9 +54,14 @@ protected function getStats(): array
|
||||
->healthyActive()
|
||||
->count();
|
||||
|
||||
$followUpRuns = (int) OperationRun::query()
|
||||
$staleActiveRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->dashboardNeedsFollowUp()
|
||||
->activeStaleAttention()
|
||||
->count();
|
||||
|
||||
$terminalFollowUpRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->terminalFollowUp()
|
||||
->count();
|
||||
|
||||
$openDriftUrl = $openDriftFindings > 0
|
||||
@ -96,10 +95,26 @@ protected function getStats(): array
|
||||
->description('healthy queued or running tenant work')
|
||||
->color($activeRuns > 0 ? 'info' : 'gray')
|
||||
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
||||
Stat::make('Operations needing follow-up', $followUpRuns)
|
||||
->description('failed, warning, or stalled runs')
|
||||
->color($followUpRuns > 0 ? 'danger' : 'gray')
|
||||
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
|
||||
Stat::make('Likely stale operations', $staleActiveRuns)
|
||||
->description('queued or running past the lifecycle window')
|
||||
->color($staleActiveRuns > 0 ? 'warning' : 'gray')
|
||||
->url($staleActiveRuns > 0
|
||||
? OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
)
|
||||
: null),
|
||||
Stat::make('Terminal follow-up operations', $terminalFollowUpRuns)
|
||||
->description('blocked, partial, failed, or auto-reconciled runs')
|
||||
->color($terminalFollowUpRuns > 0 ? 'danger' : 'gray')
|
||||
->url($terminalFollowUpRuns > 0
|
||||
? OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
)
|
||||
: null),
|
||||
];
|
||||
}
|
||||
|
||||
@ -112,7 +127,8 @@ private function emptyStats(): array
|
||||
Stat::make('Open drift findings', 0),
|
||||
Stat::make('High severity active findings', 0),
|
||||
Stat::make('Active operations', 0),
|
||||
Stat::make('Operations needing follow-up', 0),
|
||||
Stat::make('Likely stale operations', 0),
|
||||
Stat::make('Terminal follow-up operations', 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -48,9 +48,13 @@ protected function getViewData(): array
|
||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||
$operationsFollowUpCount = (int) OperationRun::query()
|
||||
$staleActiveOperationsCount = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->dashboardNeedsFollowUp()
|
||||
->activeStaleAttention()
|
||||
->count();
|
||||
$terminalFollowUpOperationsCount = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->terminalFollowUp()
|
||||
->count();
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -139,15 +143,35 @@ protected function getViewData(): array
|
||||
];
|
||||
}
|
||||
|
||||
if ($operationsFollowUpCount > 0) {
|
||||
if ($staleActiveOperationsCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'operations_follow_up',
|
||||
'title' => 'Operations need follow-up',
|
||||
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
|
||||
'key' => 'operations_stale_attention',
|
||||
'title' => 'Active operations look stale',
|
||||
'body' => "{$staleActiveOperationsCount} run(s) are still marked active but are past the lifecycle window.",
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'warning',
|
||||
'actionLabel' => 'Open stale operations',
|
||||
'actionUrl' => OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($terminalFollowUpOperationsCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'operations_terminal_follow_up',
|
||||
'title' => 'Terminal operations need follow-up',
|
||||
'body' => "{$terminalFollowUpOperationsCount} run(s) finished blocked, partially, failed, or were automatically reconciled.",
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'danger',
|
||||
'actionLabel' => 'Open operations',
|
||||
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
||||
'actionLabel' => 'Open terminal follow-up',
|
||||
'actionUrl' => OperationRunLinks::index(
|
||||
$tenant,
|
||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@ -184,7 +208,7 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
|
||||
'items' => $items,
|
||||
'healthyChecks' => $healthyChecks,
|
||||
];
|
||||
|
||||
@ -29,7 +29,7 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->heading('Recent Operations')
|
||||
->query($this->getQuery())
|
||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
||||
->poll(fn (): ?string => ActiveRuns::pollingIntervalForTenant($tenant instanceof Tenant ? $tenant : null))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
|
||||
->columns([
|
||||
@ -43,22 +43,52 @@ public function table(Table $table): Table
|
||||
->sortable()
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->limit(40)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
|
||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(null),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
])->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
|
||||
@ -5,16 +5,16 @@
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@ -28,12 +28,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
/**
|
||||
* Inventory KPI aggregation source-of-truth:
|
||||
* - `inventory_items.policy_type`
|
||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
||||
*
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
@ -43,126 +40,85 @@ protected function getStats(): array
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
Stat::make('Total items', 0),
|
||||
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
|
||||
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
|
||||
Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
|
||||
Stat::make('Need follow-up', '—')->description('Select a tenant to review follow-up types.'),
|
||||
Stat::make('Coverage basis', '—')->description('Select a tenant to see the latest coverage basis.'),
|
||||
Stat::make('Active ops', 0),
|
||||
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var array<string, int> $countsByPolicyType */
|
||||
$countsByPolicyType = InventoryItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||
->groupBy('policy_type')
|
||||
->pluck('aggregate', 'policy_type')
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$totalItems = array_sum($countsByPolicyType);
|
||||
|
||||
$restorableItems = 0;
|
||||
$partialItems = 0;
|
||||
$riskItems = 0;
|
||||
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
||||
$restorableItems += $count;
|
||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
||||
$partialItems += $count;
|
||||
}
|
||||
|
||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
||||
$riskItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
$coveragePercent = $totalItems > 0
|
||||
? (int) round(($restorableItems / $totalItems) * 100)
|
||||
: 0;
|
||||
|
||||
$lastRun = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$lastInventorySyncTimeLabel = '—';
|
||||
$lastInventorySyncStatusLabel = '—';
|
||||
$lastInventorySyncStatusColor = 'gray';
|
||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
||||
$lastInventorySyncViewUrl = null;
|
||||
|
||||
if ($lastRun instanceof OperationRun) {
|
||||
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
|
||||
|
||||
if ($timestamp) {
|
||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
||||
}
|
||||
|
||||
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||
$lastInventorySyncStatusLabel = $badge->label;
|
||||
$lastInventorySyncStatusColor = $badge->color;
|
||||
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
|
||||
|
||||
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
|
||||
}
|
||||
|
||||
$badgeColor = $lastInventorySyncStatusColor;
|
||||
|
||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge :color="$badgeColor" size="sm">
|
||||
{{ $statusLabel }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($viewUrl)
|
||||
<x-filament::link :href="$viewUrl" size="sm">
|
||||
Open operation
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
BLADE, [
|
||||
'badgeColor' => $badgeColor,
|
||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
||||
'viewUrl' => $lastInventorySyncViewUrl,
|
||||
]);
|
||||
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
||||
|
||||
$activeOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$dependenciesItems = 0;
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
||||
$dependenciesItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make('Total items', $totalItems),
|
||||
Stat::make('Coverage', $coveragePercent.'%')
|
||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
||||
->description(new HtmlString($lastInventorySyncDescription)),
|
||||
Stat::make('Active ops', $activeOps),
|
||||
Stat::make('Inventory ops', $inventoryOps)
|
||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
||||
Stat::make('Total items', $truth->observedItemTotal)
|
||||
->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
|
||||
Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
|
||||
->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
|
||||
$truth->failedTypeCount,
|
||||
$truth->skippedTypeCount,
|
||||
$truth->unknownTypeCount,
|
||||
))),
|
||||
Stat::make('Need follow-up', $truth->followUpTypeCount)
|
||||
->description(new HtmlString(InventoryKpiBadges::followUpSummary(
|
||||
$truth->topPriorityFollowUpRow(),
|
||||
$truth->observedItemTotal,
|
||||
$truth->observedTypeCount(),
|
||||
))),
|
||||
$this->coverageBasisStat($truth, $tenant),
|
||||
Stat::make('Active ops', $activeOps)
|
||||
->description($inventoryOps > 0 ? 'A tenant inventory sync is queued or running.' : 'No inventory sync is currently active.'),
|
||||
];
|
||||
}
|
||||
|
||||
private function coverageBasisStat(TenantCoverageTruth $truth, Tenant $tenant): Stat
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $truth->basisRun instanceof OperationRun) {
|
||||
return Stat::make('Coverage basis', 'No current result')
|
||||
->description($user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
|
||||
? 'Run Inventory Sync from Inventory Items to establish current coverage truth.'
|
||||
: 'A tenant operator with inventory sync permission must establish current coverage truth.');
|
||||
}
|
||||
|
||||
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
|
||||
|
||||
$description = Blade::render(<<<'BLADE'
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$badgeColor" size="sm">
|
||||
{{ $statusLabel }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($canViewRun && $viewUrl)
|
||||
<x-filament::link :href="$viewUrl" size="sm">
|
||||
Open basis run
|
||||
</x-filament::link>
|
||||
@else
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||
Latest run detail is not available with your current role.
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
BLADE, [
|
||||
'badgeColor' => $outcomeBadge->color,
|
||||
'statusLabel' => $outcomeBadge->label,
|
||||
'canViewRun' => $canViewRun,
|
||||
'viewUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||
]);
|
||||
|
||||
return Stat::make('Coverage basis', $truth->basisCompletedAtLabel() ?? 'Completed')
|
||||
->description(new HtmlString($description));
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,8 @@ protected function getViewData(): array
|
||||
'type',
|
||||
'status',
|
||||
'outcome',
|
||||
'context',
|
||||
'failure_summary',
|
||||
'created_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
|
||||
@ -201,7 +201,7 @@ protected function getViewData(): array
|
||||
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
$lifecycleNotice = $isTenantMember && ! $canOperate
|
||||
? 'Verification can be started from tenant management only while the tenant is active.'
|
||||
? 'Verification can be started from tenant management only while the tenant is active. Consent and connection configuration remain separate from this stored verification report.'
|
||||
: null;
|
||||
|
||||
$runData = null;
|
||||
|
||||
@ -16,11 +16,21 @@ class WorkspaceNeedsAttention extends Widget
|
||||
|
||||
/**
|
||||
* @var array<int, array{
|
||||
* key: string,
|
||||
* tenant_id: int,
|
||||
* tenant_label: string,
|
||||
* tenant_route_key: string,
|
||||
* family: string,
|
||||
* urgency: string,
|
||||
* title: string,
|
||||
* body: string,
|
||||
* url: string,
|
||||
* supporting_message: ?string,
|
||||
* badge: string,
|
||||
* badge_color: string
|
||||
* badge_color: string,
|
||||
* destination: array<string, mixed>,
|
||||
* action_disabled: bool,
|
||||
* helper_text: ?string,
|
||||
* url: ?string
|
||||
* }>
|
||||
*/
|
||||
public array $items = [];
|
||||
@ -37,11 +47,21 @@ class WorkspaceNeedsAttention extends Widget
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* key: string,
|
||||
* tenant_id: int,
|
||||
* tenant_label: string,
|
||||
* tenant_route_key: string,
|
||||
* family: string,
|
||||
* urgency: string,
|
||||
* title: string,
|
||||
* body: string,
|
||||
* url: string,
|
||||
* supporting_message: ?string,
|
||||
* badge: string,
|
||||
* badge_color: string
|
||||
* badge_color: string,
|
||||
* destination: array<string, mixed>,
|
||||
* action_disabled: bool,
|
||||
* helper_text: ?string,
|
||||
* url: ?string
|
||||
* }> $items
|
||||
* @param array{
|
||||
* title: string,
|
||||
|
||||
@ -23,8 +23,10 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* lifecycle_label: ?string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* destination: array<string, mixed>,
|
||||
* url: string
|
||||
* }>
|
||||
*/
|
||||
@ -49,8 +51,10 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* lifecycle_label: ?string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* destination: array<string, mixed>,
|
||||
* url: string
|
||||
* }> $operations
|
||||
* @param array{
|
||||
|
||||
@ -20,9 +20,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: int,
|
||||
* category: string,
|
||||
* description: string,
|
||||
* destination: ?array<string, mixed>,
|
||||
* destination_url: ?string,
|
||||
* color: string
|
||||
* color: string,
|
||||
* }>
|
||||
*/
|
||||
public array $metrics = [];
|
||||
@ -32,9 +34,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: int,
|
||||
* category: string,
|
||||
* description: string,
|
||||
* destination: ?array<string, mixed>,
|
||||
* destination_url: ?string,
|
||||
* color: string
|
||||
* color: string,
|
||||
* }> $metrics
|
||||
*/
|
||||
public function mount(array $metrics = []): void
|
||||
@ -53,8 +57,13 @@ protected function getStats(): array
|
||||
->description($metric['description'])
|
||||
->color($metric['color']);
|
||||
|
||||
if ($metric['destination_url'] !== null) {
|
||||
$stat->url($metric['destination_url']);
|
||||
$destination = $metric['destination'] ?? null;
|
||||
$destinationUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
|
||||
? ($destination['url'] ?? null)
|
||||
: ($metric['destination_url'] ?? null);
|
||||
|
||||
if (is_string($destinationUrl) && $destinationUrl !== '') {
|
||||
$stat->url($destinationUrl);
|
||||
}
|
||||
|
||||
return $stat;
|
||||
|
||||
@ -103,11 +103,15 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
$this->operationRun,
|
||||
$tenant,
|
||||
$context,
|
||||
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||
$processedPolicyTypes[] = $policyType;
|
||||
$coverageStatusByType[$policyType] = $success
|
||||
$coverageStatusByType[$policyType] = array_filter([
|
||||
'status' => $success
|
||||
? InventoryCoverage::StatusSucceeded
|
||||
: InventoryCoverage::StatusFailed;
|
||||
: InventoryCoverage::StatusFailed,
|
||||
'item_count' => $itemCount,
|
||||
'error_code' => $success ? null : $errorCode,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
|
||||
if ($success) {
|
||||
$successCount++;
|
||||
@ -126,7 +130,10 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
||||
$statusByType[$type] = [
|
||||
'status' => InventoryCoverage::StatusSkipped,
|
||||
'item_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($coverageStatusByType as $type => $status) {
|
||||
@ -138,8 +145,16 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
}
|
||||
|
||||
if ((string) ($result['status'] ?? '') === 'skipped') {
|
||||
$skippedErrorCode = is_string($result['error_codes'][0] ?? null)
|
||||
? (string) $result['error_codes'][0]
|
||||
: null;
|
||||
|
||||
foreach ($statusByType as $type => $status) {
|
||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
||||
$statusByType[$type] = array_filter([
|
||||
'status' => InventoryCoverage::StatusSkipped,
|
||||
'item_count' => 0,
|
||||
'error_code' => $skippedErrorCode,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -85,13 +86,13 @@ public function refreshRuns(): void
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->healthyActive()
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$activeCount = (clone $query)->count();
|
||||
$this->runs = (clone $query)->limit(6)->get();
|
||||
$this->overflowCount = max(0, $activeCount - 5);
|
||||
$this->hasActiveRuns = $this->runs->isNotEmpty();
|
||||
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
|
||||
@ -86,6 +86,63 @@ public function assignmentsFetchFailed(): bool
|
||||
return $this->metadata['assignments_fetch_failed'] ?? false;
|
||||
}
|
||||
|
||||
public function assignmentCaptureReason(): ?string
|
||||
{
|
||||
$reason = $this->metadata['assignment_capture_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && trim($reason) !== ''
|
||||
? trim($reason)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function snapshotSource(): ?string
|
||||
{
|
||||
$source = $this->metadata['snapshot_source']
|
||||
?? $this->metadata['source']
|
||||
?? null;
|
||||
|
||||
return is_string($source) && trim($source) !== ''
|
||||
? trim($source)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function warningMessages(): array
|
||||
{
|
||||
$warnings = $this->metadata['warnings'] ?? [];
|
||||
|
||||
if (! is_array($warnings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($warnings)
|
||||
->filter(fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
|
||||
->map(fn (string $warning): string => trim($warning))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function integrityWarning(): ?string
|
||||
{
|
||||
$warning = $this->metadata['integrity_warning'] ?? null;
|
||||
|
||||
return is_string($warning) && trim($warning) !== ''
|
||||
? trim($warning)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function protectedPathsCount(): int
|
||||
{
|
||||
return max(0, (int) ($this->metadata['protected_paths_count'] ?? 0));
|
||||
}
|
||||
|
||||
public function hasCapturedPayload(): bool
|
||||
{
|
||||
return is_array($this->payload) && $this->payload !== [];
|
||||
}
|
||||
|
||||
public function isFoundation(): bool
|
||||
{
|
||||
$types = array_column(config('tenantpilot.foundation_types', []), 'type');
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -17,6 +18,12 @@ class OperationRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const string PROBLEM_CLASS_NONE = 'none';
|
||||
|
||||
public const string PROBLEM_CLASS_ACTIVE_STALE_ATTENTION = 'active_stale_attention';
|
||||
|
||||
public const string PROBLEM_CLASS_TERMINAL_FOLLOW_UP = 'terminal_follow_up';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
@ -178,17 +185,31 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->where(function (Builder $terminalQuery): void {
|
||||
$terminalQuery
|
||||
$terminalQuery->terminalFollowUp();
|
||||
})
|
||||
->orWhere(function (Builder $activeQuery): void {
|
||||
$activeQuery->activeStaleAttention();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeActiveStaleAttention(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||
{
|
||||
return $query->likelyStale($policy);
|
||||
}
|
||||
|
||||
public function scopeTerminalFollowUp(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Blocked->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
]);
|
||||
})
|
||||
->orWhere(function (Builder $activeQuery): void {
|
||||
$activeQuery->likelyStale();
|
||||
});
|
||||
])
|
||||
->orWhereNotNull('context->reconciliation->reconciled_at');
|
||||
});
|
||||
}
|
||||
|
||||
@ -253,11 +274,33 @@ public function setFinishedAtAttribute(mixed $value): void
|
||||
$this->completed_at = $value;
|
||||
}
|
||||
|
||||
public function inventoryCoverage(): ?InventoryCoverage
|
||||
{
|
||||
return InventoryCoverage::fromContext($this->context);
|
||||
}
|
||||
|
||||
public function isGovernanceArtifactOperation(): bool
|
||||
{
|
||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||
}
|
||||
|
||||
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->latest('id')
|
||||
->cursor()
|
||||
->first(static fn (self $run): bool => $run->inventoryCoverage() instanceof InventoryCoverage);
|
||||
}
|
||||
|
||||
public function supportsOperatorExplanation(): bool
|
||||
{
|
||||
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
||||
@ -317,17 +360,64 @@ public function freshnessState(): OperationRunFreshnessState
|
||||
return OperationRunFreshnessState::forRun($this);
|
||||
}
|
||||
|
||||
public function requiresDashboardFollowUp(): bool
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function problemClasses(): array
|
||||
{
|
||||
return [
|
||||
self::PROBLEM_CLASS_NONE,
|
||||
self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
];
|
||||
}
|
||||
|
||||
public function problemClass(): string
|
||||
{
|
||||
$freshnessState = $this->freshnessState();
|
||||
|
||||
if ($freshnessState->isLikelyStale()) {
|
||||
return self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
||||
}
|
||||
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
|
||||
}
|
||||
|
||||
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
||||
return in_array((string) $this->outcome, [
|
||||
OperationRunOutcome::Blocked->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
], true)
|
||||
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
: self::PROBLEM_CLASS_NONE;
|
||||
}
|
||||
|
||||
return self::PROBLEM_CLASS_NONE;
|
||||
}
|
||||
|
||||
public function hasStaleLineage(): bool
|
||||
{
|
||||
return $this->freshnessState()->isReconciledFailed();
|
||||
}
|
||||
|
||||
public function isCurrentlyActive(): bool
|
||||
{
|
||||
return in_array((string) $this->status, [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
], true);
|
||||
}
|
||||
|
||||
return $this->freshnessState()->isLikelyStale();
|
||||
public function requiresOperatorReview(): bool
|
||||
{
|
||||
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
|
||||
}
|
||||
|
||||
public function requiresDashboardFollowUp(): bool
|
||||
{
|
||||
return $this->requiresOperatorReview();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -59,6 +60,55 @@ public function baselineProfile(): BelongsTo
|
||||
return $this->belongsTo(BaselineProfile::class);
|
||||
}
|
||||
|
||||
public function snapshotSource(): ?string
|
||||
{
|
||||
$source = $this->metadata['source']
|
||||
?? $this->metadata['snapshot_source']
|
||||
?? null;
|
||||
|
||||
return is_string($source) && trim($source) !== ''
|
||||
? trim($source)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function warningMessages(): array
|
||||
{
|
||||
$warnings = $this->metadata['warnings'] ?? [];
|
||||
|
||||
if (! is_array($warnings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($warnings)
|
||||
->filter(fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
|
||||
->map(fn (string $warning): string => trim($warning))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function assignmentsFetchFailed(): bool
|
||||
{
|
||||
return (bool) ($this->metadata['assignments_fetch_failed'] ?? false);
|
||||
}
|
||||
|
||||
public function hasOrphanedAssignments(): bool
|
||||
{
|
||||
return (bool) ($this->metadata['has_orphaned_assignments'] ?? false);
|
||||
}
|
||||
|
||||
public function integrityWarning(): ?string
|
||||
{
|
||||
return RedactionIntegrity::noteForPolicyVersion($this);
|
||||
}
|
||||
|
||||
public function hasCapturedPayload(): bool
|
||||
{
|
||||
return is_array($this->snapshot) && $this->snapshot !== [];
|
||||
}
|
||||
|
||||
public function scopePruneEligible($query, int $days = 90)
|
||||
{
|
||||
return $query
|
||||
|
||||
@ -156,4 +156,46 @@ public function getSkippedAssignmentsCount(): int
|
||||
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function scopeBasis(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
|
||||
return is_array($metadata['scope_basis'] ?? null) ? $metadata['scope_basis'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function checkBasis(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
|
||||
return is_array($metadata['check_basis'] ?? null) ? $metadata['check_basis'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function previewBasis(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
|
||||
return is_array($metadata['preview_basis'] ?? null) ? $metadata['preview_basis'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function executionSafetySnapshot(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
|
||||
return is_array($metadata['execution_safety_snapshot'] ?? null)
|
||||
? $metadata['execution_safety_snapshot']
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,17 +82,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
||||
$statusByType[$type] = [
|
||||
'status' => InventoryCoverage::StatusSkipped,
|
||||
'item_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->executeSelection(
|
||||
$operationRun,
|
||||
$tenant,
|
||||
$normalizedSelection,
|
||||
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
|
||||
$statusByType[$policyType] = $success
|
||||
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
|
||||
$statusByType[$policyType] = array_filter([
|
||||
'status' => $success
|
||||
? InventoryCoverage::StatusSucceeded
|
||||
: InventoryCoverage::StatusFailed;
|
||||
: InventoryCoverage::StatusFailed,
|
||||
'item_count' => $itemCount,
|
||||
'error_code' => $success ? null : $errorCode,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
},
|
||||
);
|
||||
|
||||
@ -126,10 +133,15 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
|
||||
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
|
||||
|
||||
$coverageStatusByType = $statusByType;
|
||||
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
|
||||
|
||||
if ($status === 'skipped') {
|
||||
foreach ($coverageStatusByType as $type => $coverageStatus) {
|
||||
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped;
|
||||
$coverageStatusByType[$type] = array_filter([
|
||||
'status' => InventoryCoverage::StatusSkipped,
|
||||
'item_count' => 0,
|
||||
'error_code' => $skippedErrorCode,
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +188,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
|
||||
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
|
||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||
*/
|
||||
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
|
||||
@ -245,7 +257,7 @@ public function normalizeAndHashSelection(array $selectionPayload): array
|
||||
|
||||
/**
|
||||
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
|
||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||
*/
|
||||
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
|
||||
@ -256,6 +268,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
$errorCodes = [];
|
||||
$hadErrors = false;
|
||||
$warnings = [];
|
||||
$observedByType = [];
|
||||
|
||||
try {
|
||||
$connection = $this->resolveProviderConnection($tenant);
|
||||
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
$hadErrors = true;
|
||||
$errors++;
|
||||
$errorCodes[] = 'unsupported_type';
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
$errors++;
|
||||
$errorCode = $this->mapGraphFailureToErrorCode($response);
|
||||
$errorCodes[] = $errorCode;
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
}
|
||||
|
||||
$observed++;
|
||||
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
|
||||
|
||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
||||
|
||||
@ -384,7 +398,12 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
}
|
||||
}
|
||||
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed(
|
||||
$policyType,
|
||||
true,
|
||||
null,
|
||||
(int) ($observedByType[$policyType] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
474
app/Support/BackupQuality/BackupQualityResolver.php
Normal file
474
app/Support/BackupQuality/BackupQualityResolver.php
Normal file
@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupQuality;
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\PolicyVersion;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BackupQualityResolver
|
||||
{
|
||||
public function summarizeBackupSet(BackupSet $backupSet): BackupQualitySummary
|
||||
{
|
||||
$items = $backupSet->relationLoaded('items')
|
||||
? $backupSet->items
|
||||
: $backupSet->items()->get();
|
||||
|
||||
return $this->summarizeBackupItems(
|
||||
$items,
|
||||
max((int) ($backupSet->item_count ?? 0), $items->count()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<BackupItem> $items
|
||||
*/
|
||||
public function summarizeBackupItems(iterable $items, ?int $totalItems = null): BackupQualitySummary
|
||||
{
|
||||
$itemSummaries = Collection::make($items)
|
||||
->map(fn (BackupItem $item): BackupQualitySummary => $this->forBackupItem($item))
|
||||
->values();
|
||||
|
||||
$resolvedTotalItems = max($itemSummaries->count(), (int) ($totalItems ?? 0));
|
||||
$metadataOnlyCount = $itemSummaries->where('metadataOnlyCount', '>', 0)->count();
|
||||
$assignmentIssueCount = $itemSummaries->where('assignmentIssueCount', '>', 0)->count();
|
||||
$orphanedAssignmentCount = $itemSummaries->where('orphanedAssignmentCount', '>', 0)->count();
|
||||
$integrityWarningCount = $itemSummaries->where('integrityWarningCount', '>', 0)->count();
|
||||
$unknownQualityCount = $itemSummaries->where('unknownQualityCount', '>', 0)->count();
|
||||
$degradedItemCount = $itemSummaries->filter(
|
||||
fn (BackupQualitySummary $summary): bool => $summary->hasDegradations()
|
||||
)->count();
|
||||
|
||||
$degradationFamilies = $this->orderedFamilies(
|
||||
$itemSummaries
|
||||
->flatMap(fn (BackupQualitySummary $summary): array => $summary->degradationFamilies)
|
||||
->all(),
|
||||
);
|
||||
|
||||
$qualityHighlights = $this->setHighlights(
|
||||
totalItems: $resolvedTotalItems,
|
||||
degradedItemCount: $degradedItemCount,
|
||||
metadataOnlyCount: $metadataOnlyCount,
|
||||
assignmentIssueCount: $assignmentIssueCount,
|
||||
orphanedAssignmentCount: $orphanedAssignmentCount,
|
||||
integrityWarningCount: $integrityWarningCount,
|
||||
unknownQualityCount: $unknownQualityCount,
|
||||
);
|
||||
|
||||
$compactSummary = $qualityHighlights === []
|
||||
? $this->defaultSetCompactSummary($resolvedTotalItems)
|
||||
: implode(' • ', $qualityHighlights);
|
||||
|
||||
$summaryMessage = match (true) {
|
||||
$resolvedTotalItems === 0 => 'No backup items were captured in this set.',
|
||||
$degradedItemCount === 0 => sprintf(
|
||||
'No degradations were detected across %d captured item%s.',
|
||||
$resolvedTotalItems,
|
||||
$resolvedTotalItems === 1 ? '' : 's',
|
||||
),
|
||||
default => sprintf(
|
||||
'%d of %d captured item%s show degraded input quality.',
|
||||
$degradedItemCount,
|
||||
$resolvedTotalItems,
|
||||
$resolvedTotalItems === 1 ? '' : 's',
|
||||
),
|
||||
};
|
||||
|
||||
$nextAction = match (true) {
|
||||
$resolvedTotalItems === 0 => 'Create or refresh a backup set before starting a restore review.',
|
||||
$degradedItemCount > 0 => 'Open the backup set detail and inspect degraded items before continuing into restore.',
|
||||
default => 'Open the backup set detail to verify item-level context before relying on it for restore work.',
|
||||
};
|
||||
|
||||
return new BackupQualitySummary(
|
||||
kind: 'backup_set',
|
||||
snapshotMode: $this->aggregateSnapshotMode($resolvedTotalItems, $metadataOnlyCount, $unknownQualityCount),
|
||||
totalItems: $resolvedTotalItems,
|
||||
degradedItemCount: $degradedItemCount,
|
||||
metadataOnlyCount: $metadataOnlyCount,
|
||||
assignmentIssueCount: $assignmentIssueCount,
|
||||
orphanedAssignmentCount: $orphanedAssignmentCount,
|
||||
integrityWarningCount: $integrityWarningCount,
|
||||
unknownQualityCount: $unknownQualityCount,
|
||||
hasAssignmentIssues: $assignmentIssueCount > 0,
|
||||
hasOrphanedAssignments: $orphanedAssignmentCount > 0,
|
||||
assignmentCaptureReason: null,
|
||||
integrityWarning: null,
|
||||
degradationFamilies: $degradationFamilies,
|
||||
qualityHighlights: $qualityHighlights,
|
||||
compactSummary: $compactSummary,
|
||||
summaryMessage: $summaryMessage,
|
||||
nextAction: $nextAction,
|
||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||
);
|
||||
}
|
||||
|
||||
public function forBackupItem(BackupItem $backupItem): BackupQualitySummary
|
||||
{
|
||||
$snapshotMode = $this->resolveSnapshotMode(
|
||||
source: $backupItem->snapshotSource(),
|
||||
warnings: $backupItem->warningMessages(),
|
||||
hasCapturedPayload: $backupItem->hasCapturedPayload(),
|
||||
);
|
||||
|
||||
$assignmentCaptureReason = $backupItem->assignmentCaptureReason();
|
||||
$integrityWarning = $backupItem->integrityWarning();
|
||||
$hasAssignmentIssues = $backupItem->assignmentsFetchFailed();
|
||||
$hasOrphanedAssignments = $backupItem->hasOrphanedAssignments();
|
||||
|
||||
$degradationFamilies = $this->singleRecordFamilies(
|
||||
snapshotMode: $snapshotMode,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
integrityWarning: $integrityWarning,
|
||||
);
|
||||
|
||||
$qualityHighlights = $this->singleRecordHighlights(
|
||||
snapshotMode: $snapshotMode,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
integrityWarning: $integrityWarning,
|
||||
assignmentCaptureReason: $assignmentCaptureReason,
|
||||
);
|
||||
|
||||
return new BackupQualitySummary(
|
||||
kind: 'backup_item',
|
||||
snapshotMode: $snapshotMode,
|
||||
totalItems: 1,
|
||||
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
|
||||
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
|
||||
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
|
||||
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
|
||||
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
|
||||
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
assignmentCaptureReason: $assignmentCaptureReason,
|
||||
integrityWarning: $integrityWarning,
|
||||
degradationFamilies: $degradationFamilies,
|
||||
qualityHighlights: $qualityHighlights,
|
||||
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
||||
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
||||
nextAction: $degradationFamilies === []
|
||||
? 'Open the linked detail if you need deeper restore context.'
|
||||
: 'Inspect the linked detail before relying on this backup item for restore.',
|
||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||
);
|
||||
}
|
||||
|
||||
public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySummary
|
||||
{
|
||||
$snapshotMode = $this->resolveSnapshotMode(
|
||||
source: $policyVersion->snapshotSource(),
|
||||
warnings: $policyVersion->warningMessages(),
|
||||
hasCapturedPayload: $policyVersion->hasCapturedPayload(),
|
||||
);
|
||||
|
||||
$integrityWarning = $policyVersion->integrityWarning();
|
||||
$hasAssignmentIssues = $policyVersion->assignmentsFetchFailed();
|
||||
$hasOrphanedAssignments = $policyVersion->hasOrphanedAssignments();
|
||||
|
||||
$degradationFamilies = $this->singleRecordFamilies(
|
||||
snapshotMode: $snapshotMode,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
integrityWarning: $integrityWarning,
|
||||
);
|
||||
|
||||
$qualityHighlights = $this->singleRecordHighlights(
|
||||
snapshotMode: $snapshotMode,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
integrityWarning: $integrityWarning,
|
||||
);
|
||||
|
||||
return new BackupQualitySummary(
|
||||
kind: 'policy_version',
|
||||
snapshotMode: $snapshotMode,
|
||||
totalItems: 1,
|
||||
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
|
||||
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
|
||||
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
|
||||
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
|
||||
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
|
||||
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
|
||||
hasAssignmentIssues: $hasAssignmentIssues,
|
||||
hasOrphanedAssignments: $hasOrphanedAssignments,
|
||||
assignmentCaptureReason: null,
|
||||
integrityWarning: $integrityWarning,
|
||||
degradationFamilies: $degradationFamilies,
|
||||
qualityHighlights: $qualityHighlights,
|
||||
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
||||
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
||||
nextAction: $degradationFamilies === []
|
||||
? 'Open the version detail if you need raw settings or diff context.'
|
||||
: 'Prefer a stronger version or inspect the version detail before restore.',
|
||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private function resolveSnapshotMode(?string $source, array $warnings, bool $hasCapturedPayload): string
|
||||
{
|
||||
if ($source === 'metadata_only' || $this->warningsIndicateMetadataOnly($warnings)) {
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
if ($hasCapturedPayload) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private function warningsIndicateMetadataOnly(array $warnings): bool
|
||||
{
|
||||
return Collection::make($warnings)
|
||||
->contains(function (mixed $warning): bool {
|
||||
if (! is_string($warning)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = Str::lower($warning);
|
||||
|
||||
return str_contains($normalized, 'metadata')
|
||||
&& (
|
||||
str_contains($normalized, 'only')
|
||||
|| str_contains($normalized, 'fallback')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function singleRecordFamilies(
|
||||
string $snapshotMode,
|
||||
bool $hasAssignmentIssues,
|
||||
bool $hasOrphanedAssignments,
|
||||
?string $integrityWarning,
|
||||
): array {
|
||||
$families = [];
|
||||
|
||||
if ($snapshotMode === 'metadata_only') {
|
||||
$families[] = 'metadata_only';
|
||||
}
|
||||
|
||||
if ($hasAssignmentIssues) {
|
||||
$families[] = 'assignment_capture_issue';
|
||||
}
|
||||
|
||||
if ($hasOrphanedAssignments) {
|
||||
$families[] = 'orphaned_assignments';
|
||||
}
|
||||
|
||||
if ($integrityWarning !== null) {
|
||||
$families[] = 'integrity_warning';
|
||||
}
|
||||
|
||||
if ($families === [] && $snapshotMode === 'unknown') {
|
||||
$families[] = 'unknown_quality';
|
||||
}
|
||||
|
||||
return $this->orderedFamilies($families);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function singleRecordHighlights(
|
||||
string $snapshotMode,
|
||||
bool $hasAssignmentIssues,
|
||||
bool $hasOrphanedAssignments,
|
||||
?string $integrityWarning,
|
||||
?string $assignmentCaptureReason = null,
|
||||
): array {
|
||||
$highlights = [];
|
||||
|
||||
if ($snapshotMode === 'metadata_only') {
|
||||
$highlights[] = 'Metadata only';
|
||||
}
|
||||
|
||||
if ($hasAssignmentIssues) {
|
||||
$highlights[] = 'Assignment fetch failed';
|
||||
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
||||
$highlights[] = 'Assignments captured separately';
|
||||
}
|
||||
|
||||
if ($hasOrphanedAssignments) {
|
||||
$highlights[] = 'Orphaned assignments';
|
||||
}
|
||||
|
||||
if ($integrityWarning !== null) {
|
||||
$highlights[] = 'Integrity warning';
|
||||
}
|
||||
|
||||
if ($snapshotMode === 'unknown' && $highlights === []) {
|
||||
$highlights[] = 'Unknown quality';
|
||||
}
|
||||
|
||||
return array_values(array_unique($highlights));
|
||||
}
|
||||
|
||||
private function compactSummaryFromHighlights(array $qualityHighlights, string $snapshotMode): string
|
||||
{
|
||||
if ($qualityHighlights !== []) {
|
||||
return implode(' • ', $qualityHighlights);
|
||||
}
|
||||
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'Full payload',
|
||||
'unknown' => 'Unknown quality',
|
||||
default => 'No degradations detected',
|
||||
};
|
||||
}
|
||||
|
||||
private function singleRecordSummaryMessage(array $qualityHighlights, string $snapshotMode): string
|
||||
{
|
||||
if ($qualityHighlights === []) {
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
||||
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
||||
default => 'No degradations were detected.',
|
||||
};
|
||||
}
|
||||
|
||||
return implode(' • ', $qualityHighlights).'.';
|
||||
}
|
||||
|
||||
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
||||
{
|
||||
if ($totalItems === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ($metadataOnlyCount === $totalItems) {
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
if ($metadataOnlyCount === 0 && $unknownQualityCount === 0) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function orderedFamilies(array $families): array
|
||||
{
|
||||
$weights = [
|
||||
'metadata_only' => 10,
|
||||
'assignment_capture_issue' => 20,
|
||||
'orphaned_assignments' => 30,
|
||||
'integrity_warning' => 40,
|
||||
'unknown_quality' => 50,
|
||||
];
|
||||
|
||||
$families = array_values(array_unique(array_filter(
|
||||
$families,
|
||||
static fn (mixed $family): bool => is_string($family) && $family !== '',
|
||||
)));
|
||||
|
||||
usort($families, static function (string $left, string $right) use ($weights): int {
|
||||
return ($weights[$left] ?? 999) <=> ($weights[$right] ?? 999);
|
||||
});
|
||||
|
||||
return $families;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function setHighlights(
|
||||
int $totalItems,
|
||||
int $degradedItemCount,
|
||||
int $metadataOnlyCount,
|
||||
int $assignmentIssueCount,
|
||||
int $orphanedAssignmentCount,
|
||||
int $integrityWarningCount,
|
||||
int $unknownQualityCount,
|
||||
): array {
|
||||
if ($totalItems === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$highlights = [];
|
||||
|
||||
if ($degradedItemCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d degraded item%s',
|
||||
$degradedItemCount,
|
||||
$degradedItemCount === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
if ($metadataOnlyCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d metadata-only',
|
||||
$metadataOnlyCount,
|
||||
);
|
||||
}
|
||||
|
||||
if ($assignmentIssueCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d assignment issue%s',
|
||||
$assignmentIssueCount,
|
||||
$assignmentIssueCount === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
if ($orphanedAssignmentCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d orphaned assignment%s',
|
||||
$orphanedAssignmentCount,
|
||||
$orphanedAssignmentCount === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
if ($integrityWarningCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d integrity warning%s',
|
||||
$integrityWarningCount,
|
||||
$integrityWarningCount === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
if ($unknownQualityCount > 0) {
|
||||
$highlights[] = sprintf(
|
||||
'%d unknown quality',
|
||||
$unknownQualityCount,
|
||||
);
|
||||
}
|
||||
|
||||
return $highlights;
|
||||
}
|
||||
|
||||
private function defaultSetCompactSummary(int $totalItems): string
|
||||
{
|
||||
if ($totalItems === 0) {
|
||||
return 'No items captured';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'No degradations detected across %d item%s',
|
||||
$totalItems,
|
||||
$totalItems === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
private function positiveClaimBoundary(): string
|
||||
{
|
||||
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
|
||||
}
|
||||
}
|
||||
44
app/Support/BackupQuality/BackupQualitySummary.php
Normal file
44
app/Support/BackupQuality/BackupQualitySummary.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\BackupQuality;
|
||||
|
||||
final readonly class BackupQualitySummary
|
||||
{
|
||||
/**
|
||||
* @param list<string> $degradationFamilies
|
||||
* @param list<string> $qualityHighlights
|
||||
*/
|
||||
public function __construct(
|
||||
public string $kind,
|
||||
public string $snapshotMode,
|
||||
public int $totalItems,
|
||||
public int $degradedItemCount,
|
||||
public int $metadataOnlyCount,
|
||||
public int $assignmentIssueCount,
|
||||
public int $orphanedAssignmentCount,
|
||||
public int $integrityWarningCount,
|
||||
public int $unknownQualityCount,
|
||||
public bool $hasAssignmentIssues,
|
||||
public bool $hasOrphanedAssignments,
|
||||
public ?string $assignmentCaptureReason,
|
||||
public ?string $integrityWarning,
|
||||
public array $degradationFamilies,
|
||||
public array $qualityHighlights,
|
||||
public string $compactSummary,
|
||||
public string $summaryMessage,
|
||||
public string $nextAction,
|
||||
public string $positiveClaimBoundary,
|
||||
) {}
|
||||
|
||||
public function hasDegradations(): bool
|
||||
{
|
||||
return $this->degradationFamilies !== [];
|
||||
}
|
||||
|
||||
public function hasIntegrityWarning(): bool
|
||||
{
|
||||
return $this->integrityWarning !== null;
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
|
||||
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
|
||||
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
|
||||
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
|
||||
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
|
||||
@ -46,6 +47,8 @@ final class BadgeCatalog
|
||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
|
||||
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
||||
@ -166,6 +169,32 @@ public static function normalizeProviderConnectionStatus(mixed $value): ?string
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderConsentStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'needs_admin_consent', 'needs_consent', 'consent_required' => 'required',
|
||||
'connected' => 'granted',
|
||||
'error' => 'failed',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderVerificationStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_started', 'never_checked' => 'unknown',
|
||||
'in_progress' => 'pending',
|
||||
'ok' => 'healthy',
|
||||
'warning', 'needs_attention' => 'degraded',
|
||||
'failed' => 'error',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderConnectionHealth(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
@ -18,6 +18,7 @@ enum BadgeDomain: string
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
case OperationRunOutcome = 'operation_run_outcome';
|
||||
case InventoryCoverageState = 'inventory_coverage_state';
|
||||
case BackupSetStatus = 'backup_set_status';
|
||||
case RestoreRunStatus = 'restore_run_status';
|
||||
case RestoreCheckSeverity = 'restore_check_severity';
|
||||
@ -37,6 +38,8 @@ enum BadgeDomain: string
|
||||
case IgnoredAt = 'ignored_at';
|
||||
case RestorePreviewDecision = 'restore_preview_decision';
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
case ProviderConsentStatus = 'provider_connection.consent_status';
|
||||
case ProviderVerificationStatus = 'provider_connection.verification_status';
|
||||
case ProviderConnectionStatus = 'provider_connection.status';
|
||||
case ProviderConnectionHealth = 'provider_connection.health';
|
||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
||||
|
||||
25
app/Support/Badges/Domains/InventoryCoverageStateBadge.php
Normal file
25
app/Support/Badges/Domains/InventoryCoverageStateBadge.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class InventoryCoverageStateBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'succeeded' => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Support/Badges/Domains/ProviderConsentStatusBadge.php
Normal file
24
app/Support/Badges/Domains/ProviderConsentStatusBadge.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ProviderConsentStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeProviderConsentStatus($value);
|
||||
|
||||
return match ($state) {
|
||||
'required' => new BadgeSpec('Required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'revoked' => new BadgeSpec('Revoked', 'danger', 'heroicon-m-no-symbol'),
|
||||
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ProviderVerificationStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeProviderVerificationStatus($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => new BadgeSpec('Pending', 'info', 'heroicon-m-clock'),
|
||||
'healthy' => new BadgeSpec('Healthy', 'success', 'heroicon-m-check-circle'),
|
||||
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-no-symbol'),
|
||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,10 @@ public function spec(mixed $value): BadgeSpec
|
||||
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
|
||||
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
|
||||
'current' => new BadgeSpec('Current checks', 'success', 'heroicon-m-check-circle', 'success'),
|
||||
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
|
||||
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'not_run' => new BadgeSpec('Not run', 'gray', 'heroicon-m-eye-slash', 'gray'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
@ -18,8 +18,13 @@ public function spec(mixed $value): BadgeSpec
|
||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
|
||||
'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
|
||||
'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
|
||||
'dry_run' => new BadgeSpec('Preview only', 'warning', 'heroicon-m-exclamation-triangle', 'warning'),
|
||||
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
|
||||
'current' => new BadgeSpec('Current basis', 'success', 'heroicon-m-check-circle', 'success'),
|
||||
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
|
||||
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
|
||||
'not_generated' => new BadgeSpec('Not generated', 'gray', 'heroicon-m-eye-slash', 'gray'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
|
||||
'not_executed' => new BadgeSpec('Not executed', 'gray', 'heroicon-m-eye', 'gray'),
|
||||
'completed' => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle', 'success'),
|
||||
'completed_with_follow_up' => new BadgeSpec('Follow-up required', 'warning', 'heroicon-m-exclamation-triangle', 'warning'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
@ -68,10 +68,56 @@ public function coveredTypes(): array
|
||||
return array_values(array_unique($covered));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* segment: 'policy'|'foundation',
|
||||
* type: string,
|
||||
* status: string,
|
||||
* item_count?: int,
|
||||
* error_code?: string|null
|
||||
* }>
|
||||
*/
|
||||
public function rows(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->policyTypes as $type => $meta) {
|
||||
$rows[$type] = array_merge($meta, [
|
||||
'segment' => 'policy',
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->foundationTypes as $type => $meta) {
|
||||
$rows[$type] = array_merge($meta, [
|
||||
'segment' => 'foundation',
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
ksort($rows);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* segment: 'policy'|'foundation',
|
||||
* type: string,
|
||||
* status: string,
|
||||
* item_count?: int,
|
||||
* error_code?: string|null
|
||||
* }|null
|
||||
*/
|
||||
public function row(string $type): ?array
|
||||
{
|
||||
return $this->rows()[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
|
||||
*
|
||||
* @param array<string, string> $statusByType
|
||||
* @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
|
||||
* @param list<string> $foundationTypes
|
||||
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
|
||||
*/
|
||||
@ -88,14 +134,12 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedStatus = self::normalizeStatus($status);
|
||||
$row = self::normalizeBuildRow($status);
|
||||
|
||||
if ($normalizedStatus === null) {
|
||||
if ($row === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = ['status' => $normalizedStatus];
|
||||
|
||||
if (array_key_exists($type, $foundationLookup)) {
|
||||
$foundations[$type] = $row;
|
||||
|
||||
@ -114,6 +158,40 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, item_count?: int, error_code?: string|null}|null
|
||||
*/
|
||||
private static function normalizeBuildRow(mixed $value): ?array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$status = self::normalizeStatus($value);
|
||||
|
||||
return $status === null ? null : ['status' => $status];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = self::normalizeStatus($value['status'] ?? null);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = ['status' => $status];
|
||||
|
||||
if (array_key_exists('item_count', $value) && is_int($value['item_count'])) {
|
||||
$row['item_count'] = $value['item_count'];
|
||||
}
|
||||
|
||||
if (array_key_exists('error_code', $value) && (is_string($value['error_code']) || $value['error_code'] === null)) {
|
||||
$row['error_code'] = $value['error_code'];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function normalizeStatus(mixed $status): ?string
|
||||
{
|
||||
if (! is_string($status)) {
|
||||
|
||||
@ -8,39 +8,75 @@
|
||||
|
||||
class InventoryKpiBadges
|
||||
{
|
||||
public static function coverage(int $restorableCount, int $partialCount): string
|
||||
public static function coverageBreakdown(int $failedCount, int $skippedCount, int $unknownCount): string
|
||||
{
|
||||
if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Restorable {{ $restorableCount }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Partial {{ $partialCount }}
|
||||
No follow-up
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
BLADE);
|
||||
}
|
||||
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($failedCount > 0)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Failed {{ $failedCount }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($skippedCount > 0)
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Skipped {{ $skippedCount }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($unknownCount > 0)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Unknown {{ $unknownCount }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
BLADE, [
|
||||
'restorableCount' => $restorableCount,
|
||||
'partialCount' => $partialCount,
|
||||
'failedCount' => $failedCount,
|
||||
'skippedCount' => $skippedCount,
|
||||
'unknownCount' => $unknownCount,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
|
||||
public static function followUpSummary(?TenantCoverageTypeTruth $topPriorityRow, int $observedItemTotal, int $observedTypeCount): string
|
||||
{
|
||||
if (! $topPriorityRow instanceof TenantCoverageTypeTruth) {
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Dependencies {{ $dependenciesCount }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Risk {{ $riskCount }}
|
||||
<x-filament::badge color="success" size="sm">
|
||||
All covered
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
BLADE);
|
||||
}
|
||||
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $topPriorityLabel }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="info" size="sm">
|
||||
Observed {{ $observedItemTotal }}
|
||||
</x-filament::badge>
|
||||
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $observedTypeCount }} supported types currently observed
|
||||
</span>
|
||||
</div>
|
||||
BLADE, [
|
||||
'dependenciesCount' => $dependenciesCount,
|
||||
'riskCount' => $riskCount,
|
||||
'topPriorityLabel' => $topPriorityRow->label,
|
||||
'observedItemTotal' => $observedItemTotal,
|
||||
'observedTypeCount' => $observedTypeCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
136
app/Support/Inventory/TenantCoverageTruth.php
Normal file
136
app/Support/Inventory/TenantCoverageTruth.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class TenantCoverageTruth
|
||||
{
|
||||
/**
|
||||
* @param list<TenantCoverageTypeTruth> $rows
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public ?OperationRun $basisRun,
|
||||
public bool $hasCurrentCoverageResult,
|
||||
public int $supportedTypeCount,
|
||||
public int $succeededTypeCount,
|
||||
public int $failedTypeCount,
|
||||
public int $skippedTypeCount,
|
||||
public int $unknownTypeCount,
|
||||
public int $followUpTypeCount,
|
||||
public int $observedItemTotal,
|
||||
public array $rows,
|
||||
) {
|
||||
if ($this->tenantId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant coverage truth requires a positive tenant id.');
|
||||
}
|
||||
|
||||
if ($this->supportedTypeCount < 0 || $this->observedItemTotal < 0) {
|
||||
throw new InvalidArgumentException('Tenant coverage truth counts must be zero or greater.');
|
||||
}
|
||||
}
|
||||
|
||||
public function basisRunId(): ?int
|
||||
{
|
||||
return $this->basisRun instanceof OperationRun
|
||||
? (int) $this->basisRun->getKey()
|
||||
: null;
|
||||
}
|
||||
|
||||
public function basisRunOutcome(): ?string
|
||||
{
|
||||
return $this->basisRun instanceof OperationRun
|
||||
? (string) $this->basisRun->outcome
|
||||
: null;
|
||||
}
|
||||
|
||||
public function basisCompletedAtLabel(): ?string
|
||||
{
|
||||
if (! $this->basisRun instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = $this->basisRun->completed_at ?? $this->basisRun->started_at ?? $this->basisRun->created_at;
|
||||
|
||||
return $timestamp?->diffForHumans(['short' => true]);
|
||||
}
|
||||
|
||||
public function topPriorityFollowUpRow(): ?TenantCoverageTypeTruth
|
||||
{
|
||||
foreach ($this->rows as $row) {
|
||||
if ($row->followUpRequired) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function observedTypeCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->rows,
|
||||
static fn (TenantCoverageTypeTruth $row): bool => $row->observedItemCount > 0,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TenantCoverageTypeTruth>
|
||||
*/
|
||||
public function followUpRows(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->rows,
|
||||
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* tenantId: int,
|
||||
* basisRun: array{id: int, outcome: string, completedAt: string|null}|null,
|
||||
* hasCurrentCoverageResult: bool,
|
||||
* summary: array{
|
||||
* supportedTypes: int,
|
||||
* succeededTypes: int,
|
||||
* failedTypes: int,
|
||||
* skippedTypes: int,
|
||||
* unknownTypes: int,
|
||||
* followUpTypes: int,
|
||||
* observedItems: int
|
||||
* },
|
||||
* rows: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'tenantId' => $this->tenantId,
|
||||
'basisRun' => $this->basisRun instanceof OperationRun
|
||||
? [
|
||||
'id' => (int) $this->basisRun->getKey(),
|
||||
'outcome' => (string) $this->basisRun->outcome,
|
||||
'completedAt' => $this->basisRun->completed_at?->toIso8601String(),
|
||||
]
|
||||
: null,
|
||||
'hasCurrentCoverageResult' => $this->hasCurrentCoverageResult,
|
||||
'summary' => [
|
||||
'supportedTypes' => $this->supportedTypeCount,
|
||||
'succeededTypes' => $this->succeededTypeCount,
|
||||
'failedTypes' => $this->failedTypeCount,
|
||||
'skippedTypes' => $this->skippedTypeCount,
|
||||
'unknownTypes' => $this->unknownTypeCount,
|
||||
'followUpTypes' => $this->followUpTypeCount,
|
||||
'observedItems' => $this->observedItemTotal,
|
||||
],
|
||||
'rows' => array_map(
|
||||
static fn (TenantCoverageTypeTruth $row): array => $row->toArray(),
|
||||
$this->rows,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
170
app/Support/Inventory/TenantCoverageTruthResolver.php
Normal file
170
app/Support/Inventory/TenantCoverageTruthResolver.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class TenantCoverageTruthResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CoverageCapabilitiesResolver $coverageCapabilities,
|
||||
) {}
|
||||
|
||||
public function resolve(Tenant|int $tenant): TenantCoverageTruth
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant
|
||||
? (int) $tenant->getKey()
|
||||
: (int) $tenant;
|
||||
|
||||
$basisRun = OperationRun::latestCompletedCoverageBearingInventorySyncForTenant($tenantId);
|
||||
$basisCoverage = $basisRun?->inventoryCoverage();
|
||||
|
||||
/** @var array<string, int> $countsByType */
|
||||
$countsByType = InventoryItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||
->groupBy('policy_type')
|
||||
->pluck('aggregate', 'policy_type')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$rows = $this->supportedTypes()
|
||||
->map(function (array $meta) use ($basisCoverage, $countsByType): TenantCoverageTypeTruth {
|
||||
$type = (string) $meta['type'];
|
||||
$segment = (string) $meta['segment'];
|
||||
$basisRow = $basisCoverage?->row($type);
|
||||
$coverageState = is_string($basisRow['status'] ?? null)
|
||||
? (string) $basisRow['status']
|
||||
: TenantCoverageTypeTruth::StateUnknown;
|
||||
$observedItemCount = (int) ($countsByType[$type] ?? 0);
|
||||
$basisItemCount = is_int($basisRow['item_count'] ?? null)
|
||||
? (int) $basisRow['item_count']
|
||||
: null;
|
||||
$basisErrorCode = is_string($basisRow['error_code'] ?? null)
|
||||
? (string) $basisRow['error_code']
|
||||
: null;
|
||||
$followUpRequired = $coverageState !== TenantCoverageTypeTruth::StateSucceeded;
|
||||
|
||||
return new TenantCoverageTypeTruth(
|
||||
key: sprintf('%s:%s', $segment, $type),
|
||||
type: $type,
|
||||
segment: $segment,
|
||||
label: (string) ($meta['label'] ?? $type),
|
||||
category: (string) ($meta['category'] ?? 'Other'),
|
||||
platform: is_string($meta['platform'] ?? null) ? (string) $meta['platform'] : null,
|
||||
coverageState: $coverageState,
|
||||
followUpRequired: $followUpRequired,
|
||||
followUpPriority: self::followUpPriorityForState($coverageState),
|
||||
observedItemCount: $observedItemCount,
|
||||
basisItemCount: $basisItemCount,
|
||||
basisErrorCode: $basisErrorCode,
|
||||
restoreMode: is_string($meta['restore'] ?? null) ? (string) $meta['restore'] : null,
|
||||
riskLevel: is_string($meta['risk'] ?? null) ? (string) $meta['risk'] : null,
|
||||
supportsDependencies: $segment === 'policy' && $this->coverageCapabilities->supportsDependencies($type),
|
||||
followUpGuidance: self::followUpGuidanceForState($coverageState, $basisErrorCode),
|
||||
isBasisPayloadBacked: $basisRow !== null,
|
||||
);
|
||||
})
|
||||
->sort(function (TenantCoverageTypeTruth $left, TenantCoverageTypeTruth $right): int {
|
||||
$priority = $left->followUpPriority <=> $right->followUpPriority;
|
||||
|
||||
if ($priority !== 0) {
|
||||
return $priority;
|
||||
}
|
||||
|
||||
$observed = $right->observedItemCount <=> $left->observedItemCount;
|
||||
|
||||
if ($observed !== 0) {
|
||||
return $observed;
|
||||
}
|
||||
|
||||
return strnatcasecmp($left->label, $right->label);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return new TenantCoverageTruth(
|
||||
tenantId: $tenantId,
|
||||
basisRun: $basisRun,
|
||||
hasCurrentCoverageResult: $basisCoverage instanceof InventoryCoverage,
|
||||
supportedTypeCount: count($rows),
|
||||
succeededTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSucceeded),
|
||||
failedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateFailed),
|
||||
skippedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSkipped),
|
||||
unknownTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateUnknown),
|
||||
followUpTypeCount: count(array_filter(
|
||||
$rows,
|
||||
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
|
||||
)),
|
||||
observedItemTotal: array_sum($countsByType),
|
||||
rows: $rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{type: string, label: string, category: string, platform?: string|null, restore?: string|null, risk?: string|null, segment: 'policy'|'foundation'}>
|
||||
*/
|
||||
private function supportedTypes(): Collection
|
||||
{
|
||||
$supported = collect(InventoryPolicyTypeMeta::supported())
|
||||
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
|
||||
->map(static fn (array $row): array => array_merge($row, ['segment' => 'policy']));
|
||||
|
||||
$foundations = collect(InventoryPolicyTypeMeta::foundations())
|
||||
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
|
||||
->map(static fn (array $row): array => array_merge($row, ['segment' => 'foundation']));
|
||||
|
||||
return $supported
|
||||
->merge($foundations)
|
||||
->values();
|
||||
}
|
||||
|
||||
public static function followUpPriorityForState(string $coverageState): int
|
||||
{
|
||||
return match ($coverageState) {
|
||||
TenantCoverageTypeTruth::StateFailed => 0,
|
||||
TenantCoverageTypeTruth::StateUnknown => 1,
|
||||
TenantCoverageTypeTruth::StateSkipped => 2,
|
||||
default => 3,
|
||||
};
|
||||
}
|
||||
|
||||
public static function followUpGuidanceForState(string $coverageState, ?string $basisErrorCode): string
|
||||
{
|
||||
return match (true) {
|
||||
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
|
||||
'graph_forbidden',
|
||||
'provider_consent_missing',
|
||||
'provider_permission_missing',
|
||||
'provider_permission_denied',
|
||||
], true) => 'Review provider consent or permissions, then rerun inventory sync.',
|
||||
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
|
||||
'graph_throttled',
|
||||
'graph_transient',
|
||||
'rate_limited',
|
||||
'network_unreachable',
|
||||
], true) => 'Retry inventory sync after the provider recovers.',
|
||||
$coverageState === TenantCoverageTypeTruth::StateFailed => 'Review the latest inventory sync details before retrying.',
|
||||
$coverageState === TenantCoverageTypeTruth::StateSkipped => 'Run inventory sync again with the required types selected.',
|
||||
$coverageState === TenantCoverageTypeTruth::StateUnknown => 'No current basis result exists for this type. Run inventory sync to confirm coverage.',
|
||||
default => 'No follow-up is currently required.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<TenantCoverageTypeTruth> $rows
|
||||
*/
|
||||
private function countRowsByState(array $rows, string $state): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$rows,
|
||||
static fn (TenantCoverageTypeTruth $row): bool => $row->coverageState === $state,
|
||||
));
|
||||
}
|
||||
}
|
||||
88
app/Support/Inventory/TenantCoverageTypeTruth.php
Normal file
88
app/Support/Inventory/TenantCoverageTypeTruth.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class TenantCoverageTypeTruth
|
||||
{
|
||||
public const string StateSucceeded = InventoryCoverage::StatusSucceeded;
|
||||
|
||||
public const string StateFailed = InventoryCoverage::StatusFailed;
|
||||
|
||||
public const string StateSkipped = InventoryCoverage::StatusSkipped;
|
||||
|
||||
public const string StateUnknown = 'unknown';
|
||||
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $type,
|
||||
public string $segment,
|
||||
public string $label,
|
||||
public string $category,
|
||||
public ?string $platform,
|
||||
public string $coverageState,
|
||||
public bool $followUpRequired,
|
||||
public int $followUpPriority,
|
||||
public int $observedItemCount,
|
||||
public ?int $basisItemCount,
|
||||
public ?string $basisErrorCode,
|
||||
public ?string $restoreMode,
|
||||
public ?string $riskLevel,
|
||||
public bool $supportsDependencies,
|
||||
public string $followUpGuidance,
|
||||
public bool $isBasisPayloadBacked,
|
||||
) {
|
||||
if ($this->key === '' || $this->type === '' || $this->label === '') {
|
||||
throw new InvalidArgumentException('Coverage truth rows require non-empty identity fields.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* type: string,
|
||||
* segment: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* platform: ?string,
|
||||
* coverage_state: string,
|
||||
* follow_up_required: bool,
|
||||
* follow_up_priority: int,
|
||||
* follow_up_guidance: string,
|
||||
* observed_item_count: int,
|
||||
* basis_item_count: ?int,
|
||||
* basis_error_code: ?string,
|
||||
* restore: ?string,
|
||||
* risk: ?string,
|
||||
* dependencies: bool,
|
||||
* is_basis_payload_backed: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'__key' => $this->key,
|
||||
'key' => $this->key,
|
||||
'type' => $this->type,
|
||||
'segment' => $this->segment,
|
||||
'label' => $this->label,
|
||||
'category' => $this->category,
|
||||
'platform' => $this->platform,
|
||||
'coverage_state' => $this->coverageState,
|
||||
'follow_up_required' => $this->followUpRequired,
|
||||
'follow_up_priority' => $this->followUpPriority,
|
||||
'follow_up_guidance' => $this->followUpGuidance,
|
||||
'observed_item_count' => $this->observedItemCount,
|
||||
'basis_item_count' => $this->basisItemCount,
|
||||
'basis_error_code' => $this->basisErrorCode,
|
||||
'restore' => $this->restoreMode,
|
||||
'risk' => $this->riskLevel,
|
||||
'dependencies' => $this->supportsDependencies,
|
||||
'is_basis_payload_backed' => $this->isBasisPayloadBacked,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -188,13 +188,15 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->where(static function ($query) use ($routeTenant): void {
|
||||
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
|
||||
$query->where('external_id', $routeTenant);
|
||||
|
||||
if (ctype_digit($routeTenant)) {
|
||||
$query->orWhereKey((int) $routeTenant);
|
||||
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
|
||||
@ -79,17 +79,33 @@ public static function index(
|
||||
?Tenant $tenant = null,
|
||||
?CanonicalNavigationContext $context = null,
|
||||
?string $activeTab = null,
|
||||
bool $allTenants = false,
|
||||
?string $problemClass = null,
|
||||
): string {
|
||||
$parameters = $context?->toQuery() ?? [];
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$parameters['tenant_id'] = (int) $tenant->getKey();
|
||||
} elseif ($allTenants) {
|
||||
$parameters['tenant_scope'] = 'all';
|
||||
}
|
||||
|
||||
if (is_string($activeTab) && $activeTab !== '') {
|
||||
$parameters['activeTab'] = $activeTab;
|
||||
}
|
||||
|
||||
if (
|
||||
is_string($problemClass)
|
||||
&& in_array($problemClass, OperationRun::problemClasses(), true)
|
||||
&& $problemClass !== OperationRun::PROBLEM_CLASS_NONE
|
||||
) {
|
||||
$parameters['problemClass'] = $problemClass;
|
||||
|
||||
if (! is_string($activeTab) || $activeTab === '') {
|
||||
$parameters['activeTab'] = $problemClass;
|
||||
}
|
||||
}
|
||||
|
||||
return route('admin.operations.index', $parameters);
|
||||
}
|
||||
|
||||
|
||||
@ -11,9 +11,30 @@ final class ActiveRuns
|
||||
{
|
||||
public static function existForTenant(Tenant $tenant): bool
|
||||
{
|
||||
return self::existForTenantId((int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function existForTenantId(?int $tenantId): bool
|
||||
{
|
||||
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->active()
|
||||
->where('tenant_id', $tenantId)
|
||||
->healthyActive()
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function pollingIntervalForTenant(?Tenant $tenant): ?string
|
||||
{
|
||||
return $tenant instanceof Tenant
|
||||
? self::pollingIntervalForTenantId((int) $tenant->getKey())
|
||||
: null;
|
||||
}
|
||||
|
||||
public static function pollingIntervalForTenantId(?int $tenantId): ?string
|
||||
{
|
||||
return self::existForTenantId($tenantId) ? '10s' : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,6 +223,70 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
|
||||
return $run->freshnessState();
|
||||
}
|
||||
|
||||
public static function problemClass(OperationRun $run): string
|
||||
{
|
||||
return $run->problemClass();
|
||||
}
|
||||
|
||||
public static function problemClassLabel(OperationRun $run): ?string
|
||||
{
|
||||
return match (self::problemClass($run)) {
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Likely stale active run',
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 'Terminal follow-up',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function staleLineageNote(OperationRun $run): ?string
|
||||
{
|
||||
if (! $run->hasStaleLineage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'This terminal run was automatically reconciled after stale lifecycle truth was lost.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* freshnessState:string,
|
||||
* freshnessLabel:?string,
|
||||
* problemClass:string,
|
||||
* problemClassLabel:?string,
|
||||
* isCurrentlyActive:bool,
|
||||
* isReconciled:bool,
|
||||
* staleLineageNote:?string,
|
||||
* primaryNextAction:string,
|
||||
* attentionNote:?string
|
||||
* }
|
||||
*/
|
||||
public static function decisionZoneTruth(OperationRun $run): array
|
||||
{
|
||||
$freshnessState = self::freshnessState($run);
|
||||
|
||||
return [
|
||||
'freshnessState' => $freshnessState->value,
|
||||
'freshnessLabel' => self::lifecycleAttentionSummary($run),
|
||||
'problemClass' => self::problemClass($run),
|
||||
'problemClassLabel' => self::problemClassLabel($run),
|
||||
'isCurrentlyActive' => $run->isCurrentlyActive(),
|
||||
'isReconciled' => $run->isLifecycleReconciled(),
|
||||
'staleLineageNote' => self::staleLineageNote($run),
|
||||
'primaryNextAction' => self::surfaceGuidance($run) ?? 'No action needed.',
|
||||
'attentionNote' => self::decisionAttentionNote($run),
|
||||
];
|
||||
}
|
||||
|
||||
public static function decisionAttentionNote(OperationRun $run): ?string
|
||||
{
|
||||
return match (self::problemClass($run)) {
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Still active: Yes. Automatic reconciliation: No. This run is past its lifecycle window and needs stale-run investigation before retrying.',
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $run->hasStaleLineage()
|
||||
? 'Still active: No. Automatic reconciliation: Yes. This terminal failure preserves stale-run lineage so operators can recover why the run stopped.'
|
||||
: 'Still active: No. Automatic reconciliation: No. This run is terminal and still needs follow-up.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeExplanation(
|
||||
@ -247,7 +311,9 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
||||
return match (self::freshnessState($run)) {
|
||||
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
||||
default => null,
|
||||
default => self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
? 'Terminal follow-up'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
77
app/Support/RestoreSafety/ChecksIntegrityState.php
Normal file
77
app/Support/RestoreSafety/ChecksIntegrityState.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ChecksIntegrityState
|
||||
{
|
||||
public const string STATE_NOT_RUN = 'not_run';
|
||||
|
||||
public const string STATE_CURRENT = 'current';
|
||||
|
||||
public const string STATE_STALE = 'stale';
|
||||
|
||||
public const string STATE_INVALIDATED = 'invalidated';
|
||||
|
||||
public const string FRESHNESS_POLICY = 'invalidate_after_mutation';
|
||||
|
||||
/**
|
||||
* @param list<string> $invalidationReasons
|
||||
*/
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public string $freshnessPolicy,
|
||||
public ?string $fingerprint,
|
||||
public ?string $ranAt,
|
||||
public int $blockingCount,
|
||||
public int $warningCount,
|
||||
public array $invalidationReasons,
|
||||
public bool $rerunRequired,
|
||||
public string $displaySummary,
|
||||
) {
|
||||
if (! in_array($this->state, [
|
||||
self::STATE_NOT_RUN,
|
||||
self::STATE_CURRENT,
|
||||
self::STATE_STALE,
|
||||
self::STATE_INVALIDATED,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported checks integrity state.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
return $this->state === self::STATE_CURRENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* state: string,
|
||||
* freshness_policy: string,
|
||||
* fingerprint: ?string,
|
||||
* ran_at: ?string,
|
||||
* blocking_count: int,
|
||||
* warning_count: int,
|
||||
* invalidation_reasons: list<string>,
|
||||
* rerun_required: bool,
|
||||
* display_summary: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'freshness_policy' => $this->freshnessPolicy,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'ran_at' => $this->ranAt,
|
||||
'blocking_count' => $this->blockingCount,
|
||||
'warning_count' => $this->warningCount,
|
||||
'invalidation_reasons' => $this->invalidationReasons,
|
||||
'rerun_required' => $this->rerunRequired,
|
||||
'display_summary' => $this->displaySummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Support/RestoreSafety/ExecutionReadinessState.php
Normal file
39
app/Support/RestoreSafety/ExecutionReadinessState.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
final readonly class ExecutionReadinessState
|
||||
{
|
||||
/**
|
||||
* @param list<string> $blockingReasons
|
||||
*/
|
||||
public function __construct(
|
||||
public bool $allowed,
|
||||
public array $blockingReasons,
|
||||
public string $mutationScope,
|
||||
public string $requiredCapability,
|
||||
public string $displaySummary,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* allowed: bool,
|
||||
* blocking_reasons: list<string>,
|
||||
* mutation_scope: string,
|
||||
* required_capability: string,
|
||||
* display_summary: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'allowed' => $this->allowed,
|
||||
'blocking_reasons' => $this->blockingReasons,
|
||||
'mutation_scope' => $this->mutationScope,
|
||||
'required_capability' => $this->requiredCapability,
|
||||
'display_summary' => $this->displaySummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Support/RestoreSafety/PreviewIntegrityState.php
Normal file
71
app/Support/RestoreSafety/PreviewIntegrityState.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class PreviewIntegrityState
|
||||
{
|
||||
public const string STATE_NOT_GENERATED = 'not_generated';
|
||||
|
||||
public const string STATE_CURRENT = 'current';
|
||||
|
||||
public const string STATE_STALE = 'stale';
|
||||
|
||||
public const string STATE_INVALIDATED = 'invalidated';
|
||||
|
||||
public const string FRESHNESS_POLICY = 'invalidate_after_mutation';
|
||||
|
||||
/**
|
||||
* @param list<string> $invalidationReasons
|
||||
*/
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public string $freshnessPolicy,
|
||||
public ?string $fingerprint,
|
||||
public ?string $generatedAt,
|
||||
public array $invalidationReasons,
|
||||
public bool $rerunRequired,
|
||||
public string $displaySummary,
|
||||
) {
|
||||
if (! in_array($this->state, [
|
||||
self::STATE_NOT_GENERATED,
|
||||
self::STATE_CURRENT,
|
||||
self::STATE_STALE,
|
||||
self::STATE_INVALIDATED,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported preview integrity state.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
return $this->state === self::STATE_CURRENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* state: string,
|
||||
* freshness_policy: string,
|
||||
* fingerprint: ?string,
|
||||
* generated_at: ?string,
|
||||
* invalidation_reasons: list<string>,
|
||||
* rerun_required: bool,
|
||||
* display_summary: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'freshness_policy' => $this->freshnessPolicy,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'generated_at' => $this->generatedAt,
|
||||
'invalidation_reasons' => $this->invalidationReasons,
|
||||
'rerun_required' => $this->rerunRequired,
|
||||
'display_summary' => $this->displaySummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Support/RestoreSafety/RestoreExecutionSafetySnapshot.php
Normal file
48
app/Support/RestoreSafety/RestoreExecutionSafetySnapshot.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
final readonly class RestoreExecutionSafetySnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public string $evaluatedAt,
|
||||
public string $scopeFingerprint,
|
||||
public string $previewState,
|
||||
public string $checksState,
|
||||
public string $safetyState,
|
||||
public int $blockingCount,
|
||||
public int $warningCount,
|
||||
public ?string $primaryIssueCode,
|
||||
public string $followUpBoundary,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* evaluated_at: string,
|
||||
* scope_fingerprint: string,
|
||||
* preview_state: string,
|
||||
* checks_state: string,
|
||||
* safety_state: string,
|
||||
* blocking_count: int,
|
||||
* warning_count: int,
|
||||
* primary_issue_code: ?string,
|
||||
* follow_up_boundary: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'evaluated_at' => $this->evaluatedAt,
|
||||
'scope_fingerprint' => $this->scopeFingerprint,
|
||||
'preview_state' => $this->previewState,
|
||||
'checks_state' => $this->checksState,
|
||||
'safety_state' => $this->safetyState,
|
||||
'blocking_count' => $this->blockingCount,
|
||||
'warning_count' => $this->warningCount,
|
||||
'primary_issue_code' => $this->primaryIssueCode,
|
||||
'follow_up_boundary' => $this->followUpBoundary,
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Support/RestoreSafety/RestoreResultAttention.php
Normal file
64
app/Support/RestoreSafety/RestoreResultAttention.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class RestoreResultAttention
|
||||
{
|
||||
public const string STATE_NOT_EXECUTED = 'not_executed';
|
||||
|
||||
public const string STATE_COMPLETED = 'completed';
|
||||
|
||||
public const string STATE_PARTIAL = 'partial';
|
||||
|
||||
public const string STATE_FAILED = 'failed';
|
||||
|
||||
public const string STATE_COMPLETED_WITH_FOLLOW_UP = 'completed_with_follow_up';
|
||||
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public bool $followUpRequired,
|
||||
public string $primaryCauseFamily,
|
||||
public string $summary,
|
||||
public string $primaryNextAction,
|
||||
public string $recoveryClaimBoundary,
|
||||
public string $tone,
|
||||
) {
|
||||
if (! in_array($this->state, [
|
||||
self::STATE_NOT_EXECUTED,
|
||||
self::STATE_COMPLETED,
|
||||
self::STATE_PARTIAL,
|
||||
self::STATE_FAILED,
|
||||
self::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported restore result attention state.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* state: string,
|
||||
* follow_up_required: bool,
|
||||
* primary_cause_family: string,
|
||||
* summary: string,
|
||||
* primary_next_action: string,
|
||||
* recovery_claim_boundary: string,
|
||||
* tone: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'follow_up_required' => $this->followUpRequired,
|
||||
'primary_cause_family' => $this->primaryCauseFamily,
|
||||
'summary' => $this->summary,
|
||||
'primary_next_action' => $this->primaryNextAction,
|
||||
'recovery_claim_boundary' => $this->recoveryClaimBoundary,
|
||||
'tone' => $this->tone,
|
||||
];
|
||||
}
|
||||
}
|
||||
93
app/Support/RestoreSafety/RestoreSafetyAssessment.php
Normal file
93
app/Support/RestoreSafety/RestoreSafetyAssessment.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class RestoreSafetyAssessment
|
||||
{
|
||||
public const string STATE_BLOCKED = 'blocked';
|
||||
|
||||
public const string STATE_RISKY = 'risky';
|
||||
|
||||
public const string STATE_READY_WITH_CAUTION = 'ready_with_caution';
|
||||
|
||||
public const string STATE_READY = 'ready';
|
||||
|
||||
public function __construct(
|
||||
public string $state,
|
||||
public ExecutionReadinessState $executionReadiness,
|
||||
public PreviewIntegrityState $previewIntegrity,
|
||||
public ChecksIntegrityState $checksIntegrity,
|
||||
public bool $positiveClaimSuppressed,
|
||||
public ?string $primaryIssueCode,
|
||||
public string $primaryNextAction,
|
||||
public string $summary,
|
||||
) {
|
||||
if (! in_array($this->state, [
|
||||
self::STATE_BLOCKED,
|
||||
self::STATE_RISKY,
|
||||
self::STATE_READY_WITH_CAUTION,
|
||||
self::STATE_READY,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported restore safety assessment state.');
|
||||
}
|
||||
}
|
||||
|
||||
public function canSignalReady(): bool
|
||||
{
|
||||
return in_array($this->state, [self::STATE_READY, self::STATE_READY_WITH_CAUTION], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* state: string,
|
||||
* execution_readiness: array{
|
||||
* allowed: bool,
|
||||
* blocking_reasons: list<string>,
|
||||
* mutation_scope: string,
|
||||
* required_capability: string,
|
||||
* display_summary: string
|
||||
* },
|
||||
* preview_integrity: array{
|
||||
* state: string,
|
||||
* freshness_policy: string,
|
||||
* fingerprint: ?string,
|
||||
* generated_at: ?string,
|
||||
* invalidation_reasons: list<string>,
|
||||
* rerun_required: bool,
|
||||
* display_summary: string
|
||||
* },
|
||||
* checks_integrity: array{
|
||||
* state: string,
|
||||
* freshness_policy: string,
|
||||
* fingerprint: ?string,
|
||||
* ran_at: ?string,
|
||||
* blocking_count: int,
|
||||
* warning_count: int,
|
||||
* invalidation_reasons: list<string>,
|
||||
* rerun_required: bool,
|
||||
* display_summary: string
|
||||
* },
|
||||
* positive_claim_suppressed: bool,
|
||||
* primary_issue_code: ?string,
|
||||
* primary_next_action: string,
|
||||
* summary: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'execution_readiness' => $this->executionReadiness->toArray(),
|
||||
'preview_integrity' => $this->previewIntegrity->toArray(),
|
||||
'checks_integrity' => $this->checksIntegrity->toArray(),
|
||||
'positive_claim_suppressed' => $this->positiveClaimSuppressed,
|
||||
'primary_issue_code' => $this->primaryIssueCode,
|
||||
'primary_next_action' => $this->primaryNextAction,
|
||||
'summary' => $this->summary,
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Support/RestoreSafety/RestoreSafetyCopy.php
Normal file
86
app/Support/RestoreSafety/RestoreSafetyCopy.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class RestoreSafetyCopy
|
||||
{
|
||||
public static function safetyStateLabel(?string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
RestoreSafetyAssessment::STATE_BLOCKED => 'Blocked',
|
||||
RestoreSafetyAssessment::STATE_RISKY => 'Risky',
|
||||
RestoreSafetyAssessment::STATE_READY_WITH_CAUTION => 'Ready with caution',
|
||||
RestoreSafetyAssessment::STATE_READY => 'Ready',
|
||||
default => self::headline($state, 'Unknown state'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function primaryNextAction(?string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'resolve_blockers' => 'Resolve the technical blockers before real execution.',
|
||||
'generate_preview' => 'Generate a preview for the current scope.',
|
||||
'regenerate_preview' => 'Regenerate the preview for the current scope.',
|
||||
'rerun_checks' => 'Run the safety checks again for the current scope.',
|
||||
'review_warnings' => 'Review the warnings before real execution.',
|
||||
'execute' => 'Queue the real restore execution.',
|
||||
'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.',
|
||||
'review_failures' => 'Review failed items and provider errors before retrying.',
|
||||
'review_partial_items' => 'Review partial items and incomplete assignments before retrying.',
|
||||
'review_skipped_items' => 'Review skipped or non-applied items before closing the run.',
|
||||
'review_result' => 'Review the completed restore details.',
|
||||
'adjust_scope' => 'Adjust the restore scope, then refresh preview and checks.',
|
||||
'review_scope' => 'Review the current scope and safety evidence.',
|
||||
default => self::sentence($action, 'Review the current scope and safety evidence.'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function primaryCauseFamily(?string $family): string
|
||||
{
|
||||
return match ($family) {
|
||||
'none' => 'No dominant cause recorded',
|
||||
'execution_failure' => 'Execution failure',
|
||||
'write_gate_or_rbac' => 'RBAC or write gate',
|
||||
'provider_operability' => 'Provider operability',
|
||||
'missing_dependency_or_mapping' => 'Missing dependency or mapping',
|
||||
'payload_quality' => 'Payload quality',
|
||||
'scope_mismatch' => 'Scope mismatch',
|
||||
'item_level_failure' => 'Item-level failure',
|
||||
default => self::headline($family, 'Unknown cause'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function recoveryBoundary(?string $boundary): string
|
||||
{
|
||||
return match ($boundary) {
|
||||
'preview_only_no_execution_proven' => 'No execution was performed from this record.',
|
||||
'execution_failed_no_recovery_claim' => 'Tenant recovery is not proven.',
|
||||
'run_completed_not_recovery_proven' => 'Tenant-wide recovery is not proven.',
|
||||
default => 'Tenant-wide recovery is not proven.',
|
||||
};
|
||||
}
|
||||
|
||||
private static function headline(?string $value, string $fallback): string
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return Str::headline(trim($value));
|
||||
}
|
||||
|
||||
private static function sentence(?string $value, string $fallback): string
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$sentence = Str::headline(trim($value));
|
||||
|
||||
return str_ends_with($sentence, '.') ? $sentence : $sentence.'.';
|
||||
}
|
||||
}
|
||||
619
app/Support/RestoreSafety/RestoreSafetyResolver.php
Normal file
619
app/Support/RestoreSafety/RestoreSafetyResolver.php
Normal file
@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final readonly class RestoreSafetyResolver
|
||||
{
|
||||
public function __construct(
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private WriteGateInterface $writeGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function scopeFingerprintFromData(array $data): RestoreScopeFingerprint
|
||||
{
|
||||
return RestoreScopeFingerprint::fromInputs(
|
||||
$data['backup_set_id'] ?? null,
|
||||
$data['scope_mode'] ?? null,
|
||||
$data['backup_item_ids'] ?? [],
|
||||
$data['group_mapping'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{
|
||||
* backup_set_id: ?int,
|
||||
* scope_mode: string,
|
||||
* selected_item_ids: list<int>,
|
||||
* group_mapping: array<string, string>,
|
||||
* group_mapping_fingerprint: string,
|
||||
* fingerprint: string,
|
||||
* captured_at: string
|
||||
* }
|
||||
*/
|
||||
public function scopeBasisFromData(array $data): array
|
||||
{
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
|
||||
return $scope->toArray() + [
|
||||
'captured_at' => now('UTC')->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{
|
||||
* fingerprint: string,
|
||||
* ran_at: string,
|
||||
* blocking_count: int,
|
||||
* warning_count: int,
|
||||
* result_codes: list<string>
|
||||
* }|null
|
||||
*/
|
||||
public function checksBasisFromData(array $data): ?array
|
||||
{
|
||||
$summary = $data['check_summary'] ?? null;
|
||||
$ranAt = $data['checks_ran_at'] ?? null;
|
||||
|
||||
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
|
||||
return is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
|
||||
}
|
||||
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
$results = is_array($data['check_results'] ?? null) ? $data['check_results'] : [];
|
||||
|
||||
return [
|
||||
'fingerprint' => $scope->fingerprint,
|
||||
'ran_at' => $ranAt,
|
||||
'blocking_count' => (int) ($summary['blocking'] ?? 0),
|
||||
'warning_count' => (int) ($summary['warning'] ?? 0),
|
||||
'result_codes' => array_values(array_filter(array_map(static function (mixed $result): ?string {
|
||||
$code = is_array($result) ? ($result['code'] ?? null) : null;
|
||||
|
||||
return is_string($code) && $code !== '' ? $code : null;
|
||||
}, $results))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{
|
||||
* fingerprint: string,
|
||||
* generated_at: string,
|
||||
* summary: array<string, mixed>
|
||||
* }|null
|
||||
*/
|
||||
public function previewBasisFromData(array $data): ?array
|
||||
{
|
||||
$summary = $data['preview_summary'] ?? null;
|
||||
$ranAt = $data['preview_ran_at'] ?? null;
|
||||
|
||||
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
|
||||
return is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
|
||||
}
|
||||
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
|
||||
return [
|
||||
'fingerprint' => $scope->fingerprint,
|
||||
'generated_at' => $ranAt,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function previewIntegrityFromData(array $data): PreviewIntegrityState
|
||||
{
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
$basis = is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
|
||||
$generatedAt = is_string($data['preview_ran_at'] ?? null) ? $data['preview_ran_at'] : null;
|
||||
$hasPreviewEvidence = (is_array($data['preview_summary'] ?? null) && $data['preview_summary'] !== [])
|
||||
|| ($basis !== null && $basis !== [])
|
||||
|| (is_string($generatedAt) && $generatedAt !== '');
|
||||
|
||||
if (! $hasPreviewEvidence) {
|
||||
return new PreviewIntegrityState(
|
||||
state: PreviewIntegrityState::STATE_NOT_GENERATED,
|
||||
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: null,
|
||||
generatedAt: null,
|
||||
invalidationReasons: [],
|
||||
rerunRequired: true,
|
||||
displaySummary: 'Generate a preview for the current scope before claiming calm execution readiness.',
|
||||
);
|
||||
}
|
||||
|
||||
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
|
||||
$reasons = $this->invalidationReasonsForBasis(
|
||||
currentScope: $scope,
|
||||
basis: $basis,
|
||||
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
|
||||
);
|
||||
|
||||
if ($reasons !== []) {
|
||||
return new PreviewIntegrityState(
|
||||
state: PreviewIntegrityState::STATE_INVALIDATED,
|
||||
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
|
||||
invalidationReasons: $reasons,
|
||||
rerunRequired: true,
|
||||
displaySummary: 'The last preview no longer matches the current restore scope. Regenerate it before real execution.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($basisFingerprint === null || ! is_string($basis['generated_at'] ?? null)) {
|
||||
return new PreviewIntegrityState(
|
||||
state: PreviewIntegrityState::STATE_STALE,
|
||||
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
|
||||
invalidationReasons: [],
|
||||
rerunRequired: true,
|
||||
displaySummary: 'Preview evidence exists, but it cannot prove it still belongs to the current scope.',
|
||||
);
|
||||
}
|
||||
|
||||
return new PreviewIntegrityState(
|
||||
state: PreviewIntegrityState::STATE_CURRENT,
|
||||
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
generatedAt: $basis['generated_at'],
|
||||
invalidationReasons: [],
|
||||
rerunRequired: false,
|
||||
displaySummary: 'Preview evidence is current for the selected restore scope.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function checksIntegrityFromData(array $data): ChecksIntegrityState
|
||||
{
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
$basis = is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
|
||||
$summary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
|
||||
$ranAt = is_string($data['checks_ran_at'] ?? null) ? $data['checks_ran_at'] : null;
|
||||
$blockingCount = (int) ($summary['blocking'] ?? ($basis['blocking_count'] ?? 0));
|
||||
$warningCount = (int) ($summary['warning'] ?? ($basis['warning_count'] ?? 0));
|
||||
|
||||
$hasCheckEvidence = $summary !== []
|
||||
|| ($basis !== null && $basis !== [])
|
||||
|| (is_string($ranAt) && $ranAt !== '');
|
||||
|
||||
if (! $hasCheckEvidence) {
|
||||
return new ChecksIntegrityState(
|
||||
state: ChecksIntegrityState::STATE_NOT_RUN,
|
||||
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: null,
|
||||
ranAt: null,
|
||||
blockingCount: 0,
|
||||
warningCount: 0,
|
||||
invalidationReasons: [],
|
||||
rerunRequired: true,
|
||||
displaySummary: 'Run safety checks for the current scope before offering real execution calmly.',
|
||||
);
|
||||
}
|
||||
|
||||
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
|
||||
$reasons = $this->invalidationReasonsForBasis(
|
||||
currentScope: $scope,
|
||||
basis: $basis,
|
||||
explicitReasons: $data['check_invalidation_reasons'] ?? null,
|
||||
);
|
||||
|
||||
if ($reasons !== []) {
|
||||
return new ChecksIntegrityState(
|
||||
state: ChecksIntegrityState::STATE_INVALIDATED,
|
||||
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
|
||||
blockingCount: $blockingCount,
|
||||
warningCount: $warningCount,
|
||||
invalidationReasons: $reasons,
|
||||
rerunRequired: true,
|
||||
displaySummary: 'The last checks no longer match the current restore scope. Run them again before real execution.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($basisFingerprint === null || ! is_string($basis['ran_at'] ?? null)) {
|
||||
return new ChecksIntegrityState(
|
||||
state: ChecksIntegrityState::STATE_STALE,
|
||||
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
|
||||
blockingCount: $blockingCount,
|
||||
warningCount: $warningCount,
|
||||
invalidationReasons: [],
|
||||
rerunRequired: true,
|
||||
displaySummary: 'Checks evidence exists, but it cannot prove it still belongs to the current scope.',
|
||||
);
|
||||
}
|
||||
|
||||
return new ChecksIntegrityState(
|
||||
state: ChecksIntegrityState::STATE_CURRENT,
|
||||
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
|
||||
fingerprint: $basisFingerprint,
|
||||
ranAt: $basis['ran_at'],
|
||||
blockingCount: $blockingCount,
|
||||
warningCount: $warningCount,
|
||||
invalidationReasons: [],
|
||||
rerunRequired: false,
|
||||
displaySummary: 'Checks evidence is current for the selected restore scope.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function executionReadiness(Tenant $tenant, User $user, array $data, bool $dryRun = false): ExecutionReadinessState
|
||||
{
|
||||
$blockingReasons = [];
|
||||
|
||||
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
$blockingReasons[] = 'missing_capability';
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
try {
|
||||
$this->writeGate->evaluate($tenant, 'restore.execute');
|
||||
} catch (ProviderAccessHardeningRequired $exception) {
|
||||
$blockingReasons[] = $exception->reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
$checkSummary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
|
||||
$blockingCount = (int) ($checkSummary['blocking'] ?? 0);
|
||||
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blockingCount > 0));
|
||||
|
||||
if ($hasBlockers) {
|
||||
$blockingReasons[] = 'risk_blocker';
|
||||
}
|
||||
|
||||
$blockingReasons = array_values(array_unique($blockingReasons));
|
||||
$allowed = $blockingReasons === [];
|
||||
|
||||
$displaySummary = $allowed
|
||||
? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
|
||||
: 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';
|
||||
|
||||
return new ExecutionReadinessState(
|
||||
allowed: $allowed,
|
||||
blockingReasons: $blockingReasons,
|
||||
mutationScope: $dryRun ? 'simulation_only' : 'microsoft_tenant',
|
||||
requiredCapability: Capabilities::TENANT_MANAGE,
|
||||
displaySummary: $displaySummary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function safetyAssessment(Tenant $tenant, User $user, array $data): RestoreSafetyAssessment
|
||||
{
|
||||
$previewIntegrity = $this->previewIntegrityFromData($data);
|
||||
$checksIntegrity = $this->checksIntegrityFromData($data);
|
||||
$executionReadiness = $this->executionReadiness($tenant, $user, $data, false);
|
||||
|
||||
if (! $executionReadiness->allowed) {
|
||||
return new RestoreSafetyAssessment(
|
||||
state: RestoreSafetyAssessment::STATE_BLOCKED,
|
||||
executionReadiness: $executionReadiness,
|
||||
previewIntegrity: $previewIntegrity,
|
||||
checksIntegrity: $checksIntegrity,
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
|
||||
primaryNextAction: 'resolve_blockers',
|
||||
summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $previewIntegrity->isCurrent()) {
|
||||
return new RestoreSafetyAssessment(
|
||||
state: RestoreSafetyAssessment::STATE_RISKY,
|
||||
executionReadiness: $executionReadiness,
|
||||
previewIntegrity: $previewIntegrity,
|
||||
checksIntegrity: $checksIntegrity,
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $previewIntegrity->state,
|
||||
primaryNextAction: 'regenerate_preview',
|
||||
summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $checksIntegrity->isCurrent()) {
|
||||
return new RestoreSafetyAssessment(
|
||||
state: RestoreSafetyAssessment::STATE_RISKY,
|
||||
executionReadiness: $executionReadiness,
|
||||
previewIntegrity: $previewIntegrity,
|
||||
checksIntegrity: $checksIntegrity,
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: $checksIntegrity->state,
|
||||
primaryNextAction: 'rerun_checks',
|
||||
summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($checksIntegrity->warningCount > 0) {
|
||||
return new RestoreSafetyAssessment(
|
||||
state: RestoreSafetyAssessment::STATE_READY_WITH_CAUTION,
|
||||
executionReadiness: $executionReadiness,
|
||||
previewIntegrity: $previewIntegrity,
|
||||
checksIntegrity: $checksIntegrity,
|
||||
positiveClaimSuppressed: true,
|
||||
primaryIssueCode: 'warnings_present',
|
||||
primaryNextAction: 'review_warnings',
|
||||
summary: 'Current preview and checks exist, but warnings remain. The restore can start, yet calm safety claims stay suppressed.',
|
||||
);
|
||||
}
|
||||
|
||||
return new RestoreSafetyAssessment(
|
||||
state: RestoreSafetyAssessment::STATE_READY,
|
||||
executionReadiness: $executionReadiness,
|
||||
previewIntegrity: $previewIntegrity,
|
||||
checksIntegrity: $checksIntegrity,
|
||||
positiveClaimSuppressed: false,
|
||||
primaryIssueCode: null,
|
||||
primaryNextAction: 'execute',
|
||||
summary: 'Current preview and checks support real execution for the selected scope.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function executionSafetySnapshot(Tenant $tenant, User $user, array $data): RestoreExecutionSafetySnapshot
|
||||
{
|
||||
$scope = $this->scopeFingerprintFromData($data);
|
||||
$assessment = $this->safetyAssessment($tenant, $user, $data);
|
||||
|
||||
return new RestoreExecutionSafetySnapshot(
|
||||
evaluatedAt: now('UTC')->toIso8601String(),
|
||||
scopeFingerprint: $scope->fingerprint,
|
||||
previewState: $assessment->previewIntegrity->state,
|
||||
checksState: $assessment->checksIntegrity->state,
|
||||
safetyState: $assessment->state,
|
||||
blockingCount: $assessment->checksIntegrity->blockingCount,
|
||||
warningCount: $assessment->checksIntegrity->warningCount,
|
||||
primaryIssueCode: $assessment->primaryIssueCode,
|
||||
followUpBoundary: 'run_completed_not_recovery_proven',
|
||||
);
|
||||
}
|
||||
|
||||
public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAttention
|
||||
{
|
||||
$status = strtolower((string) $restoreRun->status);
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
|
||||
$foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : [];
|
||||
$operationOutcome = strtolower((string) ($restoreRun->operationRun?->outcome ?? ''));
|
||||
|
||||
$itemStatuses = array_values(array_filter(array_map(static function (mixed $item): ?string {
|
||||
$status = is_array($item) ? ($item['status'] ?? null) : null;
|
||||
|
||||
return is_string($status) && $status !== '' ? strtolower($status) : null;
|
||||
}, $items)));
|
||||
|
||||
$failedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'failed'));
|
||||
$partialItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => in_array($itemStatus, ['partial', 'manual_required'], true)));
|
||||
$skippedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'skipped'));
|
||||
$failedAssignments = $restoreRun->getFailedAssignmentsCount();
|
||||
$skippedAssignments = $restoreRun->getSkippedAssignmentsCount();
|
||||
$foundationSkips = count(array_filter($foundations, static function (mixed $entry): bool {
|
||||
return is_array($entry) && in_array(($entry['decision'] ?? null), ['failed', 'skipped'], true);
|
||||
}));
|
||||
|
||||
if ($restoreRun->is_dry_run || in_array($status, ['draft', 'scoped', 'checked', 'previewed'], true)) {
|
||||
return new RestoreResultAttention(
|
||||
state: RestoreResultAttention::STATE_NOT_EXECUTED,
|
||||
followUpRequired: false,
|
||||
primaryCauseFamily: 'none',
|
||||
summary: 'This record proves preview truth, not tenant recovery.',
|
||||
primaryNextAction: 'review_preview',
|
||||
recoveryClaimBoundary: 'preview_only_no_execution_proven',
|
||||
tone: 'gray',
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 'failed' || $operationOutcome === 'failed') {
|
||||
return new RestoreResultAttention(
|
||||
state: RestoreResultAttention::STATE_FAILED,
|
||||
followUpRequired: true,
|
||||
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
|
||||
summary: 'The restore did not complete successfully. Follow-up is still required.',
|
||||
primaryNextAction: 'review_failures',
|
||||
recoveryClaimBoundary: 'execution_failed_no_recovery_claim',
|
||||
tone: 'danger',
|
||||
);
|
||||
}
|
||||
|
||||
if ($failedItems > 0 || $partialItems > 0 || $failedAssignments > 0 || in_array($operationOutcome, ['partially_succeeded', 'blocked'], true)) {
|
||||
return new RestoreResultAttention(
|
||||
state: RestoreResultAttention::STATE_PARTIAL,
|
||||
followUpRequired: true,
|
||||
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
|
||||
summary: 'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
primaryNextAction: 'review_partial_items',
|
||||
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
|
||||
tone: 'warning',
|
||||
);
|
||||
}
|
||||
|
||||
if ($skippedItems > 0 || $skippedAssignments > 0 || $foundationSkips > 0 || (int) (($restoreRun->metadata ?? [])['non_applied'] ?? 0) > 0) {
|
||||
return new RestoreResultAttention(
|
||||
state: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
followUpRequired: true,
|
||||
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
|
||||
summary: 'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
primaryNextAction: 'review_skipped_items',
|
||||
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
|
||||
tone: 'warning',
|
||||
);
|
||||
}
|
||||
|
||||
return new RestoreResultAttention(
|
||||
state: RestoreResultAttention::STATE_COMPLETED,
|
||||
followUpRequired: false,
|
||||
primaryCauseFamily: 'none',
|
||||
summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
|
||||
primaryNextAction: 'review_result',
|
||||
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
|
||||
tone: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $basis
|
||||
* @return list<string>
|
||||
*/
|
||||
public function invalidationReasonsForBasis(
|
||||
RestoreScopeFingerprint $currentScope,
|
||||
?array $basis,
|
||||
mixed $explicitReasons = null,
|
||||
): array {
|
||||
$reasons = $this->normalizeReasons($explicitReasons);
|
||||
|
||||
if ($basis === null) {
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
if ($reasons !== []) {
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
|
||||
|
||||
if ($basisFingerprint !== null && $currentScope->matches($basisFingerprint)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$basisBackupSetId = is_numeric($basis['backup_set_id'] ?? null) ? (int) $basis['backup_set_id'] : null;
|
||||
$basisScopeMode = $basis['scope_mode'] ?? null;
|
||||
$basisSelectedItemIds = is_array($basis['selected_item_ids'] ?? null) ? $basis['selected_item_ids'] : [];
|
||||
$basisGroupMappingFingerprint = is_string($basis['group_mapping_fingerprint'] ?? null)
|
||||
? $basis['group_mapping_fingerprint']
|
||||
: null;
|
||||
|
||||
$derivedReasons = [];
|
||||
|
||||
if ($basisBackupSetId !== null && $basisBackupSetId !== $currentScope->backupSetId) {
|
||||
$derivedReasons[] = 'backup_set_changed';
|
||||
}
|
||||
|
||||
if (is_string($basisScopeMode) && $basisScopeMode !== $currentScope->scopeMode) {
|
||||
$derivedReasons[] = 'scope_mode_changed';
|
||||
}
|
||||
|
||||
if ($this->normalizeIds($basisSelectedItemIds) !== $currentScope->selectedItemIds) {
|
||||
$derivedReasons[] = 'selected_items_changed';
|
||||
}
|
||||
|
||||
if ($basisGroupMappingFingerprint !== null && $basisGroupMappingFingerprint !== $currentScope->groupMappingFingerprint) {
|
||||
$derivedReasons[] = 'group_mapping_changed';
|
||||
}
|
||||
|
||||
if ($derivedReasons === [] && $basisFingerprint !== null && ! $currentScope->matches($basisFingerprint)) {
|
||||
$derivedReasons[] = 'scope_mismatch';
|
||||
}
|
||||
|
||||
return $derivedReasons;
|
||||
}
|
||||
|
||||
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
|
||||
{
|
||||
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
|
||||
$reasonCode = strtolower((string) ($operationContext['reason_code'] ?? ''));
|
||||
|
||||
if ($reasonCode !== '' && (str_contains($reasonCode, 'capability') || str_contains($reasonCode, 'rbac') || str_contains($reasonCode, 'write'))) {
|
||||
return 'write_gate_or_rbac';
|
||||
}
|
||||
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reason = strtolower((string) ($item['reason'] ?? ''));
|
||||
$graphMessage = strtolower((string) ($item['graph_error_message'] ?? ''));
|
||||
|
||||
if (str_contains($reason, 'mapping') || str_contains($reason, 'group') || str_contains($graphMessage, 'mapping')) {
|
||||
return 'missing_dependency_or_mapping';
|
||||
}
|
||||
|
||||
if (str_contains($reason, 'metadata only') || str_contains($reason, 'manual')) {
|
||||
return 'payload_quality';
|
||||
}
|
||||
|
||||
if ($graphMessage !== '' || filled($item['graph_error_code'] ?? null)) {
|
||||
return 'item_level_failure';
|
||||
}
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeReasons(mixed $reasons): array
|
||||
{
|
||||
if (! is_array($reasons)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = array_values(array_filter(array_map(static function (mixed $reason): ?string {
|
||||
if (! is_string($reason)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = trim($reason);
|
||||
|
||||
return $reason === '' ? null : $reason;
|
||||
}, $reasons)));
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id) && $id > 0) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($id) && ctype_digit($id) && (int) $id > 0) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
199
app/Support/RestoreSafety/RestoreScopeFingerprint.php
Normal file
199
app/Support/RestoreSafety/RestoreScopeFingerprint.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\RestoreSafety;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
|
||||
final readonly class RestoreScopeFingerprint
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $backupSetId,
|
||||
public string $scopeMode,
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
public array $selectedItemIds,
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $groupMapping,
|
||||
public string $groupMappingFingerprint,
|
||||
public string $fingerprint,
|
||||
) {
|
||||
if (! in_array($this->scopeMode, ['all', 'selected'], true)) {
|
||||
throw new InvalidArgumentException('Restore scope mode must be all or selected.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromInputs(
|
||||
mixed $backupSetId,
|
||||
mixed $scopeMode,
|
||||
mixed $selectedItemIds,
|
||||
mixed $groupMapping,
|
||||
): self {
|
||||
$normalizedBackupSetId = is_numeric($backupSetId) ? max(1, (int) $backupSetId) : null;
|
||||
$normalizedScopeMode = $scopeMode === 'selected' ? 'selected' : 'all';
|
||||
$normalizedSelectedItemIds = self::normalizeItemIds(
|
||||
$normalizedScopeMode === 'selected' ? $selectedItemIds : []
|
||||
);
|
||||
$normalizedGroupMapping = self::normalizeGroupMapping($groupMapping);
|
||||
$groupMappingFingerprint = self::hashPayload($normalizedGroupMapping);
|
||||
|
||||
return new self(
|
||||
backupSetId: $normalizedBackupSetId,
|
||||
scopeMode: $normalizedScopeMode,
|
||||
selectedItemIds: $normalizedSelectedItemIds,
|
||||
groupMapping: $normalizedGroupMapping,
|
||||
groupMappingFingerprint: $groupMappingFingerprint,
|
||||
fingerprint: self::hashPayload([
|
||||
'backup_set_id' => $normalizedBackupSetId,
|
||||
'scope_mode' => $normalizedScopeMode,
|
||||
'selected_item_ids' => $normalizedSelectedItemIds,
|
||||
'group_mapping_fingerprint' => $groupMappingFingerprint,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromArray(mixed $payload): ?self
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
! array_key_exists('backup_set_id', $payload)
|
||||
&& ! array_key_exists('scope_mode', $payload)
|
||||
&& ! array_key_exists('fingerprint', $payload)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromInputs(
|
||||
$payload['backup_set_id'] ?? null,
|
||||
$payload['scope_mode'] ?? null,
|
||||
$payload['selected_item_ids'] ?? [],
|
||||
$payload['group_mapping'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
public function matches(?string $fingerprint): bool
|
||||
{
|
||||
return is_string($fingerprint)
|
||||
&& $fingerprint !== ''
|
||||
&& hash_equals($this->fingerprint, $fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* backup_set_id: ?int,
|
||||
* scope_mode: string,
|
||||
* selected_item_ids: list<int>,
|
||||
* group_mapping: array<string, string>,
|
||||
* group_mapping_fingerprint: string,
|
||||
* fingerprint: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'backup_set_id' => $this->backupSetId,
|
||||
'scope_mode' => $this->scopeMode,
|
||||
'selected_item_ids' => $this->selectedItemIds,
|
||||
'group_mapping' => $this->groupMapping,
|
||||
'group_mapping_fingerprint' => $this->groupMappingFingerprint,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function normalizeItemIds(mixed $selectedItemIds): array
|
||||
{
|
||||
if (! is_array($selectedItemIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($selectedItemIds as $itemId) {
|
||||
if (is_int($itemId) && $itemId > 0) {
|
||||
$normalized[] = $itemId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($itemId) && ctype_digit($itemId) && (int) $itemId > 0) {
|
||||
$normalized[] = (int) $itemId;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function normalizeGroupMapping(mixed $groupMapping): array
|
||||
{
|
||||
if ($groupMapping instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||
$groupMapping = $groupMapping->toArray();
|
||||
}
|
||||
|
||||
if ($groupMapping instanceof \stdClass) {
|
||||
$groupMapping = (array) $groupMapping;
|
||||
}
|
||||
|
||||
if (! is_array($groupMapping)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($groupMapping as $sourceGroupId => $targetGroupId) {
|
||||
if (! is_string($sourceGroupId) || trim($sourceGroupId) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($targetGroupId instanceof \BackedEnum) {
|
||||
$targetGroupId = $targetGroupId->value;
|
||||
}
|
||||
|
||||
if (! is_string($targetGroupId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetGroupId = trim($targetGroupId);
|
||||
|
||||
if ($targetGroupId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($sourceGroupId)] = strtoupper($targetGroupId) === 'SKIP'
|
||||
? 'SKIP'
|
||||
: $targetGroupId;
|
||||
}
|
||||
|
||||
ksort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $payload
|
||||
*/
|
||||
private static function hashPayload(array $payload): string
|
||||
{
|
||||
try {
|
||||
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
} catch (JsonException $exception) {
|
||||
throw new InvalidArgumentException('Restore scope payload could not be fingerprinted.', previous: $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,7 @@
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
@ -359,7 +360,7 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
||||
relatedArtifactUrl: $snapshot->tenant !== null
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
? $this->panelSafeTenantArtifactUrl(fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant))
|
||||
: null,
|
||||
includePublicationDimension: false,
|
||||
countDescriptors: [
|
||||
@ -500,9 +501,13 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
||||
: null;
|
||||
|
||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
);
|
||||
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
|
||||
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant);
|
||||
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->makeEnvelope(
|
||||
@ -538,7 +543,9 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
||||
relatedArtifactUrl: $review->tenant !== null
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
? $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
@ -675,9 +682,13 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
||||
$nextActionUrl = null;
|
||||
|
||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant)
|
||||
);
|
||||
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
|
||||
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant);
|
||||
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant)
|
||||
);
|
||||
} elseif ($pack->operation_run_id !== null) {
|
||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||
}
|
||||
@ -715,7 +726,9 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
||||
relatedArtifactUrl: $pack->tenant !== null
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
? $this->panelSafeTenantArtifactUrl(
|
||||
fn (): string => ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
@ -1084,6 +1097,13 @@ classification: $classification,
|
||||
);
|
||||
}
|
||||
|
||||
private function panelSafeTenantArtifactUrl(callable $resolver): ?string
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'system'
|
||||
? null
|
||||
: $resolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, CountDescriptor>
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-28 (added request-scoped performance foundation candidates for derived state, governance aggregates, and workspace access context)
|
||||
**Last reviewed**: 2026-04-07 (added UI Discipline Trilogy: Record Page Header Discipline, Monitoring Surface Action Hierarchy, Governance Friction & Vocabulary Hardening)
|
||||
|
||||
---
|
||||
|
||||
@ -1480,6 +1480,91 @@ ### Detail Page Hierarchy & Progressive Disclosure (UI/UX Audit)
|
||||
- **Status**: Directly covered by Spec 133 (View Page Template Standard for Enterprise Detail Screens). Spec 133 defines the shared enterprise detail-page composition standard including summary-first header, main-and-supporting layout, dedicated related-context section, secondary technical detail separation, optional section support, and degraded-state resilience. Spec.md, plan.md, research.md, data-model.md, and tasks.md (all tasks complete) exist for 4 initial target pages (BaselineSnapshot, BackupSet, EntraGroup, OperationRun). If additional pages require alignment beyond the initial 4 targets, that is a Spec 133 follow-up scope extension, not a new candidate.
|
||||
- **Reference specs**: 133
|
||||
|
||||
### Record Page Header Discipline & Contextual Navigation
|
||||
- **Type**: hardening
|
||||
- **Source**: Constitution compliance audit 2026-04 — systematic review of Record/Detail page header-action usage across all View/Detail surfaces
|
||||
- **Problem**: Many Record/Detail pages violate the Constitution by using header actions as a catch-all for navigation, secondary actions, and governance actions. The 5-second-scan rule is broken because the primary action is not clearly prioritized, related navigation sits in the wrong place (equal-weight header buttons instead of inline context), and danger/governance actions are not friction-separated. This is the largest systemic Constitution gap on current View/Detail surfaces.
|
||||
- **Why it matters**: As long as this pattern drift persists, the UI remains noisy and inconsistent despite strong foundations. Every new View page risks inheriting the same anti-pattern. Fixing this is the single largest visible lever to close the Constitution gap across the product.
|
||||
- **Proposed direction**:
|
||||
- Define a binding Constitution rule for Record/Detail page header actions: max one visible primary header action, no navigation in headers, related links inline at context, danger/governance actions separated, rare secondary actions in Action Group
|
||||
- Define a standard pattern for header actions on Record/Detail pages
|
||||
- Define a standard pattern for related-context navigation in Infolists (inline, operator-proximate)
|
||||
- Move navigation out of headers into field/status/relation context
|
||||
- Roll out the pattern to all affected View/Detail pages
|
||||
- **Affected surfaces**: Finding Exception, Finding, Tenant Review, Baseline Profile, Evidence Snapshot, Tenant, Provider Connection, and potentially Backup Set, Baseline Snapshot, and other View pages
|
||||
- **Non-goals**: Queue/Workbench surface restructuring (separate candidate), Monitoring header architecture (separate candidate), general visual redesign, deep layout polish of all pages
|
||||
- **Acceptance points**:
|
||||
- Record page Constitution rule is documented
|
||||
- Affected View pages have no navigation buttons as equal-weight header CTAs
|
||||
- Each View page has a clearly prioritized primary action
|
||||
- Danger actions are separated and friction-gated
|
||||
- Related navigation is inline and operator-proximate
|
||||
- **Dependencies**: Spec 133 (View Page Template Standard — provides the detail-page layout foundation this candidate builds on for header-action discipline)
|
||||
- **Related specs / candidates**: Monitoring Surface Action Hierarchy & Workbench Semantics (adjacent but distinct — queue/workbench surfaces need their own rules), Governance Friction & Operator Vocabulary Hardening (complements this with friction/reason-capture/vocabulary hardening)
|
||||
- **Strategic importance**: This is the central lever to eliminate the largest visible Constitution drift in the product. Recommended as the first of three coordinated UI-discipline specs.
|
||||
- **Priority**: high
|
||||
|
||||
### Monitoring Surface Action Hierarchy & Workbench Semantics
|
||||
- **Type**: hardening
|
||||
- **Source**: Constitution compliance audit 2026-04 — Queue/Monitoring/Workbench surfaces mix global surface controls, selection-aware object actions, context navigation, utility actions, and object workflow in the same header/action area
|
||||
- **Problem**: Queue and Monitoring surfaces mix global surface controls, selection-aware object actions, context navigation, filter/utility actions, and scope/back/context signals in the same equal-weight header area. This is a different problem than Record page header noise — it is an information architecture/workbench semantics question that needs its own rules rather than forcing the Record page header pattern onto surfaces with fundamentally different interaction models.
|
||||
- **Why it matters**: After Record page header cleanup, these surfaces would remain the next large inconsistent block. Applying Record page rules to Workbench/Queue surfaces would be a category error — they need their own action hierarchy that respects surface-level controls, selection-aware workflows, and scope context.
|
||||
- **Proposed direction**:
|
||||
- Define a Constitution/pattern rule for Queue/Workbench/Monitoring surfaces with clear action hierarchy layers: global surface actions, selection-aware object actions, context navigation, filter/utility actions, scope/back/context signals
|
||||
- Semantically clean up OperateHub/Monitoring headers so these layers are not presented as equal-weight header items
|
||||
- Extract scope and back-context from header-action noise
|
||||
- Correctly place selection-based workflow actions
|
||||
- Move navigation out of global headers
|
||||
- Make workbench/detail-pane/selection flows cleaner
|
||||
- **Affected surfaces**: Finding Exceptions Queue, Tenantless Operation Viewer, Operations, and potentially Alerts, Audit Log, and other Monitoring pages with OperateHub/scope/selection patterns
|
||||
- **Non-goals**: Solving all Record/View page problems (separate candidate), reinventing the sidebar/navigation, building new large Monitoring features
|
||||
- **Acceptance points**:
|
||||
- Queue/Monitoring Constitution rule is defined
|
||||
- Global and object-level actions are clearly separated
|
||||
- Selection-aware governance actions are not in the same header bucket as utility/navigation
|
||||
- Scope/back context is cleanly placed
|
||||
- Monitoring surfaces are noticeably calmer and faster to scan
|
||||
- **Dependencies**: Record Page Header Discipline (recommended to ship first — establishes the Record page header contract that this candidate explicitly does not reuse for Workbench surfaces)
|
||||
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (adjacent — Record pages vs Workbench surfaces), Governance Friction & Operator Vocabulary Hardening (complements with friction/vocabulary hardening)
|
||||
- **Strategic importance**: Prevents applying a wrong solution (Record page header rules) to a fundamentally different surface class. Recommended as the second of three coordinated UI-discipline specs.
|
||||
- **Priority**: high
|
||||
|
||||
### Governance Friction & Operator Vocabulary Hardening
|
||||
- **Type**: hardening
|
||||
- **Source**: Constitution compliance audit 2026-04 — residual gaps in friction, reason capture, and UI vocabulary consistency across governance-impacting actions
|
||||
- **Problem**: Smaller but important gaps exist in governance friction, reason capture, and UI vocabulary. These are not large enough for the Header/Workbench architecture specs but are critical for enterprise trust, auditability, and consistent operator language. Governance-changing actions lack consistent friction rules, reason capture is missing where governance truth or review/acceptance decisions are affected, danger/confirmation standards vary, and UI vocabulary has naming outliers that diverge from the Constitution.
|
||||
- **Why it matters**: This is the right finishing step after the two architecture-focused specs. Without it, the product would have clean action architecture but inconsistent governance friction and operator language — undermining the enterprise-trust story. It turns a "better UI" into a credible governance-of-record surface.
|
||||
- **Proposed direction**:
|
||||
- Close confirmation/reason-capture gaps across governance-impacting actions
|
||||
- Define a friction heuristic: when only confirm, when optional reason, when mandatory reason
|
||||
- Unify danger semantics across all destructive actions
|
||||
- Fix individual naming/label outliers that diverge from Constitution vocabulary
|
||||
- Harden action naming and operator-facing wording for remaining inconsistencies
|
||||
- **Affected surfaces**: Exception approval/reject flows, evidence/review publish/expire/revoke/renew patterns, individual Resources with inconsistent labels, shared helpers / review heuristics / guardrails where applicable
|
||||
- **Non-goals**: Large IA refactor, re-touching all Monitoring/Record patterns, cosmetic text changes without semantic relevance
|
||||
- **Acceptance points**:
|
||||
- Governance friction rules are clearly documented
|
||||
- Relevant actions have consistent confirmation/reason semantics
|
||||
- Danger semantics are unified
|
||||
- Named UI outliers are corrected
|
||||
- Operator-facing wording follows the Constitution more closely
|
||||
- **Dependencies**: Record Page Header Discipline (recommended first), Monitoring Surface Action Hierarchy (recommended second) — this spec is designed as the targeted finishing step
|
||||
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation, Monitoring Surface Action Hierarchy & Workbench Semantics, Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation)
|
||||
- **Strategic importance**: The targeted closer that turns structural UI improvements into a credible governance-of-record surface. Recommended as the third and final spec in the coordinated UI-discipline trilogy.
|
||||
- **Priority**: high
|
||||
|
||||
> **UI Discipline Trilogy — Sequencing Note**
|
||||
>
|
||||
> These three candidates form a coordinated trilogy that addresses the largest remaining Constitution drift in the product UI:
|
||||
>
|
||||
> 1. **Record Page Header Discipline & Contextual Navigation** — largest visible lever; establishes the binding header-action contract for all Record/Detail pages
|
||||
> 2. **Monitoring Surface Action Hierarchy & Workbench Semantics** — separates Workbench/Queue surfaces from Record page rules; defines the action hierarchy for Monitoring surfaces
|
||||
> 3. **Governance Friction & Operator Vocabulary Hardening** — targeted finishing step for friction, reason capture, and vocabulary consistency
|
||||
>
|
||||
> **Why this order:** Record pages are the most numerous and most directly visible gap. Monitoring surfaces need their own rules (not a Record page derivative). Governance friction is the smallest scope and benefits from the architectural cleanup of the first two specs.
|
||||
>
|
||||
> **Why three specs instead of one:** Each has different affected surfaces, different interaction models, and different implementation patterns. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while converging on one coherent UI discipline.
|
||||
|
||||
---
|
||||
|
||||
## Planned
|
||||
|
||||
@ -7,11 +7,20 @@
|
||||
$summary = $summary ?? [];
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
$blocking = (int) ($summary['blocking'] ?? 0);
|
||||
$warning = (int) ($summary['warning'] ?? 0);
|
||||
$checksIntegrity = $checksIntegrity ?? [];
|
||||
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
|
||||
|
||||
$executionReadiness = $executionReadiness ?? [];
|
||||
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
|
||||
|
||||
$safetyAssessment = $safetyAssessment ?? [];
|
||||
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
|
||||
|
||||
$blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0));
|
||||
$warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0));
|
||||
$safe = (int) ($summary['safe'] ?? 0);
|
||||
|
||||
$ranAt = $ranAt ?? null;
|
||||
$ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
|
||||
$ranAtLabel = null;
|
||||
|
||||
if (is_string($ranAt) && $ranAt !== '') {
|
||||
@ -26,6 +35,12 @@
|
||||
return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
|
||||
};
|
||||
|
||||
$integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run');
|
||||
$integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.';
|
||||
$nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks';
|
||||
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks');
|
||||
$startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.';
|
||||
$startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning';
|
||||
$limitedList = static function (array $items, int $limit = 5): array {
|
||||
if (count($items) <= $limit) {
|
||||
return $items;
|
||||
@ -39,8 +54,41 @@
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Safety checks"
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Checks tell you whether the current scope can be defended, not just whether it can start.'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$startabilityTone" size="sm">
|
||||
{{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }}
|
||||
</x-filament::badge>
|
||||
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Ready with caution
|
||||
</x-filament::badge>
|
||||
@elseif (($safetyAssessment['state'] ?? null) === 'ready')
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Ready
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="font-medium">What the current checks prove</div>
|
||||
<div class="mt-1">{{ $integritySummary }}</div>
|
||||
<div class="mt-2 text-xs text-slate-600 dark:text-slate-300">
|
||||
Technical startability: {{ $startabilitySummary }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Primary next step
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{{ $nextActionLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
|
||||
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
|
||||
@ -52,12 +100,19 @@
|
||||
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (($checksIntegrity['invalidation_reasons'] ?? []) !== [])
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $checksIntegrity['invalidation_reasons'])) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($results === [])
|
||||
<x-filament::section>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No checks have been run yet.
|
||||
No checks have been recorded for this scope yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
@ -69,9 +124,9 @@
|
||||
$message = is_array($result) ? ($result['message'] ?? null) : null;
|
||||
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
|
||||
$meta = is_array($meta) ? $meta : [];
|
||||
|
||||
$unmappedGroups = $meta['unmapped'] ?? [];
|
||||
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
|
||||
$spec = $severitySpec($severity);
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
@ -87,10 +142,6 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$spec = $severitySpec($severity);
|
||||
@endphp
|
||||
|
||||
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||
{{ $spec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@ -7,7 +7,16 @@
|
||||
$summary = $summary ?? [];
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
$ranAt = $ranAt ?? null;
|
||||
$previewIntegrity = $previewIntegrity ?? [];
|
||||
$previewIntegrity = is_array($previewIntegrity) ? $previewIntegrity : [];
|
||||
|
||||
$checksIntegrity = $checksIntegrity ?? [];
|
||||
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
|
||||
|
||||
$safetyAssessment = $safetyAssessment ?? [];
|
||||
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
|
||||
|
||||
$ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null);
|
||||
$ranAtLabel = null;
|
||||
|
||||
if (is_string($ranAt) && $ranAt !== '') {
|
||||
@ -18,12 +27,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
|
||||
$previewIntegrity['state'] ?? 'not_generated'
|
||||
);
|
||||
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
|
||||
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
|
||||
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
|
||||
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
|
||||
|
||||
$integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.';
|
||||
$nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview';
|
||||
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'generate_preview');
|
||||
$limitedKeys = static function (array $items, int $limit = 8): array {
|
||||
$keys = array_keys($items);
|
||||
|
||||
@ -39,8 +55,36 @@
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Preview"
|
||||
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
|
||||
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
@if (($checksIntegrity['state'] ?? null) === 'current')
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Checks current
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Calm readiness suppressed
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="font-medium">What the preview proves</div>
|
||||
<div class="mt-1">{{ $integritySummary }}</div>
|
||||
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Primary next step
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
{{ $nextActionLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
|
||||
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
|
||||
@ -57,12 +101,19 @@
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($diffs === [])
|
||||
<x-filament::section>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No preview generated yet.
|
||||
No preview diff is recorded for this scope yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
@ -76,16 +127,13 @@
|
||||
$action = $entry['action'] ?? 'update';
|
||||
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
|
||||
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
|
||||
|
||||
$added = (int) ($diffSummary['added'] ?? 0);
|
||||
$removed = (int) ($diffSummary['removed'] ?? 0);
|
||||
$changed = (int) ($diffSummary['changed'] ?? 0);
|
||||
|
||||
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
|
||||
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
|
||||
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
|
||||
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
|
||||
|
||||
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
|
||||
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
|
||||
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
|
||||
$rows = array_values(array_filter($rows ?? [], 'is_array'));
|
||||
$summary = is_array($summary ?? null) ? $summary : [];
|
||||
$followUpRows = array_values(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false)));
|
||||
$topFollowUp = $followUpRows[0] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Types in run
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($summary['totalTypes'] ?? count($rows)) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Succeeded: {{ (int) ($summary['succeededTypes'] ?? 0) }}. Failed: {{ (int) ($summary['failedTypes'] ?? 0) }}. Skipped: {{ (int) ($summary['skippedTypes'] ?? 0) }}.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Need follow-up
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($summary['followUpTypes'] ?? count($followUpRows)) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Execution outcome stays separate from the per-type results below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Observed items
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($summary['observedItems'] ?? 0) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Item counts show what this run observed for the listed types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Run outcome
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<x-filament::badge :color="$runOutcomeColor ?? 'gray'" :icon="$runOutcomeIcon ?? null">
|
||||
{{ $runOutcomeLabel ?? 'Unknown' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Coverage truth below explains which types created the follow-up.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($topFollowUp !== null)
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
Highest-priority follow-up: {{ $topFollowUp['label'] ?? ($topFollowUp['type'] ?? 'Unknown type') }}. {{ $topFollowUp['followUpGuidance'] ?? 'Review the latest inventory sync details before retrying.' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $row['type'] ?? null);
|
||||
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
|
||||
$stateSpec = BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, $row['coverageState'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$typeSpec->color" :icon="$typeSpec->icon">
|
||||
{{ $typeSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon">
|
||||
{{ $stateSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge :color="$categorySpec->color" :icon="$categorySpec->icon">
|
||||
{{ $categorySpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'gray' : 'info' }}">
|
||||
{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'Foundation' : 'Policy' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $row['label'] ?? ($row['type'] ?? 'Unknown type') }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $row['type'] ?? 'unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 px-3 py-2 text-right dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Observed items
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($row['itemCount'] ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $row['followUpGuidance'] ?? 'No follow-up is currently required.' }}
|
||||
</div>
|
||||
|
||||
@if (filled($row['errorCode'] ?? null))
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Reason code: {{ $row['errorCode'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@ -2,17 +2,24 @@
|
||||
$state = $getState();
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action';
|
||||
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'missing';
|
||||
$ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#';
|
||||
$needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false);
|
||||
|
||||
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
|
||||
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
|
||||
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
|
||||
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
||||
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
|
||||
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
|
||||
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
||||
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
||||
|
||||
$isMissing = $connectionState === 'needs_action';
|
||||
$isMissing = $connectionState === 'missing';
|
||||
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
|
||||
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
|
||||
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
|
||||
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||
@ -20,7 +27,9 @@
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
||||
@if ($isMissing)
|
||||
<div class="mt-1 text-sm text-amber-700">Needs action: no default Microsoft provider connection is configured.</div>
|
||||
<div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
|
||||
@elseif ($needsDefaultConnection)
|
||||
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
|
||||
@else
|
||||
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
||||
@endif
|
||||
@ -32,18 +41,32 @@
|
||||
</div>
|
||||
|
||||
@unless ($isMissing)
|
||||
@if ($needsDefaultConnection && $displayName)
|
||||
<div class="text-sm text-gray-700">
|
||||
Current connection: <span class="font-medium">{{ $displayName }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
||||
<dd>{{ $provider ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt>
|
||||
<dd>{{ $status ?? 'n/a' }}</dd>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$consentSpec->color" :icon="$consentSpec->icon" size="sm">
|
||||
{{ $consentSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt>
|
||||
<dd>{{ $healthStatus ?? 'n/a' }}</dd>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
|
||||
{{ $verificationSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
|
||||
@ -51,10 +74,32 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
|
||||
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
|
||||
{{ $legacyStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
|
||||
{{ $legacyHealthSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($lastErrorReason)
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
Last error reason: {{ $lastErrorReason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,28 @@
|
||||
@php
|
||||
$preview = $getState() ?? [];
|
||||
$state = $getState() ?? [];
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$preview = is_array($state['preview'] ?? null) ? $state['preview'] : $state;
|
||||
$previewIntegrity = is_array($state['previewIntegrity'] ?? null) ? $state['previewIntegrity'] : [];
|
||||
$checksIntegrity = is_array($state['checksIntegrity'] ?? null) ? $state['checksIntegrity'] : [];
|
||||
$executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : [];
|
||||
$scopeBasis = is_array($state['scopeBasis'] ?? null) ? $state['scopeBasis'] : [];
|
||||
|
||||
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
|
||||
$previewIntegrity['state'] ?? 'not_generated'
|
||||
);
|
||||
|
||||
$checksSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestoreCheckSeverity,
|
||||
$checksIntegrity['state'] ?? 'not_run'
|
||||
);
|
||||
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
|
||||
is_string($executionSafetySnapshot['follow_up_boundary'] ?? null)
|
||||
? $executionSafetySnapshot['follow_up_boundary']
|
||||
: 'preview_only_no_execution_proven'
|
||||
);
|
||||
|
||||
$actionPresentation = static function (array $item): array {
|
||||
$action = is_string($item['action'] ?? null) ? $item['action'] : null;
|
||||
|
||||
@ -9,6 +32,7 @@
|
||||
default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'],
|
||||
};
|
||||
};
|
||||
|
||||
$foundationItems = collect($preview)->filter(function ($item) {
|
||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||
});
|
||||
@ -21,13 +45,54 @@
|
||||
<p class="text-sm text-gray-600">No preview has been generated yet.</p>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
|
||||
{{ $integritySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$checksSpec->color" :icon="$checksSpec->icon" size="sm">
|
||||
{{ $checksSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What the preview proves</div>
|
||||
<div class="mt-1">{{ $previewIntegrity['display_summary'] ?? 'Preview basis is unavailable.' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this record does not prove</div>
|
||||
<div class="mt-1">{{ $recoveryBoundary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (($scopeBasis['fingerprint'] ?? null) !== null)
|
||||
<div class="mt-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
Scope mode: {{ $scopeBasis['scope_mode'] ?? 'all' }}
|
||||
@if (($scopeBasis['selected_item_ids'] ?? []) !== [])
|
||||
• selected items: {{ count($scopeBasis['selected_item_ids']) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($foundationItems->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
||||
@foreach ($foundationItems as $item)
|
||||
@php
|
||||
$decision = $item['decision'] ?? 'mapped_existing';
|
||||
$decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
|
||||
$foundationIsPreviewOnly = ($item['reason'] ?? null) === 'preview_only'
|
||||
|| ($item['restore_mode'] ?? null) === 'preview-only'
|
||||
|| $decision === 'dry_run';
|
||||
$decisionSpec = $foundationIsPreviewOnly
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, 'preview_only')
|
||||
: \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
|
||||
$foundationReason = $item['reason'] ?? null;
|
||||
|
||||
if ($foundationReason === 'preview_only') {
|
||||
$foundationReason = 'Preview only. This foundation type is not applied during execution.';
|
||||
}
|
||||
@endphp
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||
@ -44,9 +109,9 @@
|
||||
Target: {{ $item['targetName'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($item['reason']))
|
||||
@if (! empty($foundationReason))
|
||||
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
|
||||
{{ $item['reason'] }}
|
||||
{{ $foundationReason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
@php
|
||||
$state = $getState() ?? [];
|
||||
$state = is_array($state) ? $state : [];
|
||||
$resultAttention = is_array($state['resultAttention'] ?? null) ? $state['resultAttention'] : [];
|
||||
$executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : [];
|
||||
$state = is_array($state['results'] ?? null) ? $state['results'] : $state;
|
||||
$isFoundationEntry = function ($item) {
|
||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||
};
|
||||
@ -39,21 +43,85 @@
|
||||
<p class="text-sm text-gray-600">No restore results have been recorded yet.</p>
|
||||
@else
|
||||
@php
|
||||
$needsAttention = $policyItems->contains(function ($item) {
|
||||
$needsAttention = (bool) ($resultAttention['follow_up_required'] ?? false)
|
||||
|| $policyItems->contains(function ($item) {
|
||||
$status = $item['status'] ?? null;
|
||||
|
||||
return in_array($status, ['partial', 'manual_required'], true);
|
||||
});
|
||||
$attentionSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::RestoreResultStatus,
|
||||
$resultAttention['state'] ?? ($needsAttention ? 'completed_with_follow_up' : 'completed')
|
||||
);
|
||||
$executionBasisLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::safetyStateLabel(
|
||||
is_string($executionSafetySnapshot['safety_state'] ?? null) ? $executionSafetySnapshot['safety_state'] : null
|
||||
);
|
||||
$primaryNextAction = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(
|
||||
is_string($resultAttention['primary_next_action'] ?? null) ? $resultAttention['primary_next_action'] : 'review_result'
|
||||
);
|
||||
$primaryCauseFamily = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryCauseFamily(
|
||||
is_string($resultAttention['primary_cause_family'] ?? null) ? $resultAttention['primary_cause_family'] : 'none'
|
||||
);
|
||||
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
|
||||
is_string($resultAttention['recovery_claim_boundary'] ?? null)
|
||||
? $resultAttention['recovery_claim_boundary']
|
||||
: 'run_completed_not_recovery_proven'
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$attentionSpec->color" :icon="$attentionSpec->icon" size="sm">
|
||||
{{ $attentionSpec->label }}
|
||||
</x-filament::badge>
|
||||
@if (($executionSafetySnapshot['safety_state'] ?? null) !== null)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Execution basis: {{ $executionBasisLabel }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this run proves</div>
|
||||
<div class="mt-1">{{ $resultAttention['summary'] ?? 'Restore result truth is unavailable.' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primary next step</div>
|
||||
<div class="mt-1">{{ $primaryNextAction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Main follow-up driver</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $primaryCauseFamily }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this record does not prove</div>
|
||||
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $recoveryBoundary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($foundationItems->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
||||
@foreach ($foundationItems as $item)
|
||||
@php
|
||||
$decision = $item['decision'] ?? 'mapped_existing';
|
||||
$decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
|
||||
$foundationIsPreviewOnly = ($item['reason'] ?? null) === 'preview_only'
|
||||
|| ($item['restore_mode'] ?? null) === 'preview-only'
|
||||
|| $decision === 'dry_run';
|
||||
$decisionSpec = $foundationIsPreviewOnly
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, 'preview_only')
|
||||
: \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
|
||||
$foundationReason = $item['reason'] ?? null;
|
||||
|
||||
if ($foundationReason === 'preview_only') {
|
||||
$foundationReason = 'Preview only. This foundation type is not applied during execution.';
|
||||
}
|
||||
@endphp
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||
@ -70,9 +138,9 @@
|
||||
Target: {{ $item['targetName'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($item['reason']))
|
||||
@if (! empty($foundationReason))
|
||||
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
{{ $item['reason'] }}
|
||||
{{ $foundationReason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -82,7 +150,7 @@
|
||||
|
||||
@if ($needsAttention)
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
Some items still need follow-up. Review the per-item details below.
|
||||
{{ $resultAttention['summary'] ?? 'Some items still need follow-up. Review the per-item details below.' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@ -1,16 +1,121 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$summary = $this->coverageSummary();
|
||||
$basis = $this->basisRunSummary();
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Searchable support matrix
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Tenant coverage truth
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace.
|
||||
This report shows which supported inventory types are currently covered for the active tenant, which ones still need follow-up, and what the statement is based on.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Covered types
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ $summary['succeededTypes'] ?? 0 }} / {{ $summary['supportedTypes'] ?? 0 }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Current supported types with a successful basis result.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Need follow-up
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ $summary['followUpTypes'] ?? 0 }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
@if (filled($summary['topFollowUpLabel'] ?? null))
|
||||
Highest-priority type: {{ $summary['topFollowUpLabel'] }}.
|
||||
@else
|
||||
No follow-up types are currently highlighted.
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Observed items
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ $summary['observedItems'] ?? 0 }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $summary['observedTypes'] ?? 0 }} supported types currently have observed inventory rows.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($summary['topFollowUpGuidance'] ?? null))
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{{ $summary['topFollowUpGuidance'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Coverage basis
|
||||
</div>
|
||||
|
||||
<div class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $basis['title'] ?? 'No current coverage basis' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($basis['badgeLabel'] ?? null))
|
||||
<x-filament::badge :color="$basis['badgeColor'] ?? 'gray'" size="sm">
|
||||
{{ $basis['badgeLabel'] }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan.
|
||||
{{ $basis['body'] ?? 'No current coverage basis is available.' }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if (filled($basis['runUrl'] ?? null))
|
||||
<x-filament::link :href="$basis['runUrl']" size="sm">
|
||||
Open basis run
|
||||
</x-filament::link>
|
||||
@endif
|
||||
|
||||
@if (filled($basis['historyUrl'] ?? null))
|
||||
<x-filament::link :href="$basis['historyUrl']" size="sm">
|
||||
Inventory sync history
|
||||
</x-filament::link>
|
||||
@endif
|
||||
|
||||
@if (filled($basis['inventoryItemsUrl'] ?? null))
|
||||
<x-filament::link :href="$basis['inventoryItemsUrl']" size="sm">
|
||||
Open inventory items
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<x-filament-panels::page>
|
||||
@php($selectedException = $this->selectedFindingException())
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
@ -11,129 +13,13 @@
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
|
||||
@php
|
||||
$selectedException = $this->selectedFindingException();
|
||||
@endphp
|
||||
|
||||
@if ($selectedException)
|
||||
<x-filament::section
|
||||
:heading="'Finding exception #'.$selectedException->getKey()"
|
||||
:description="$selectedException->requested_at?->toDayDateTimeString()"
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingExceptionStatus)($selectedException->status) }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
||||
</div>
|
||||
@php
|
||||
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
|
||||
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
|
||||
? 'text-warning-700 dark:text-warning-300'
|
||||
: 'text-danger-700 dark:text-danger-300';
|
||||
@endphp
|
||||
@if (filled($governanceWarning))
|
||||
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
|
||||
{{ $governanceWarning }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scope
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->tenant?->name ?? 'Unknown tenant' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Finding #{{ $selectedException->finding_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Review timing
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Review due {{ $selectedException->review_due_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Expires {{ $selectedException->expires_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Request
|
||||
</div>
|
||||
<dl class="mt-3 space-y-3">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Requested by
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->requester?->name ?? 'Unknown requester' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Owner
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->owner?->name ?? 'Unassigned' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Reason
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->request_reason }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Decision history
|
||||
</div>
|
||||
|
||||
@if ($selectedException->decisions->isEmpty())
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
No decisions have been recorded yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-3 space-y-3">
|
||||
@foreach ($selectedException->decisions as $decision)
|
||||
<div class="rounded-xl border border-gray-200 px-3 py-3 dark:border-gray-800">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ ucfirst(str_replace('_', ' ', $decision->decision_type)) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $decision->actor?->name ?? 'Unknown actor' }} · {{ $decision->decided_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
@if (filled($decision->reason))
|
||||
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $decision->reason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||
<x-filament::section>
|
||||
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||
'selectedException' => $selectedException,
|
||||
])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<x-filament-panels::page>
|
||||
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
@ -15,10 +17,16 @@
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'blocked'"
|
||||
wire:click="$set('activeTab', 'blocked')"
|
||||
:active="$this->activeTab === $staleAttentionTab"
|
||||
wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
|
||||
>
|
||||
Needs follow-up
|
||||
Likely stale
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === $terminalFollowUpTab"
|
||||
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
|
||||
>
|
||||
Terminal follow-up
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
@ -42,8 +50,8 @@
|
||||
|
||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window.
|
||||
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) have already been automatically reconciled.
|
||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
|
||||
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) already carry reconciled stale lineage and belong in terminal follow-up.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
<div data-testid="finding-exception-slide-over" class="grid gap-4">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingExceptionStatus)($selectedException->status) }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
||||
</div>
|
||||
@php
|
||||
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
|
||||
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
|
||||
? 'text-warning-700 dark:text-warning-300'
|
||||
: 'text-danger-700 dark:text-danger-300';
|
||||
@endphp
|
||||
@if (filled($governanceWarning))
|
||||
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
|
||||
{{ $governanceWarning }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scope
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->tenant?->name ?? 'Unknown tenant' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Finding #{{ $selectedException->finding_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Review timing
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Review due {{ $selectedException->review_due_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Expires {{ $selectedException->expires_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Request
|
||||
</div>
|
||||
<dl class="mt-3 space-y-3">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Requested by
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->requester?->name ?? 'Unknown requester' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Owner
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->owner?->name ?? 'Unassigned' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Reason
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedException->request_reason }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Decision history
|
||||
</div>
|
||||
|
||||
@if ($selectedException->decisions->isEmpty())
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
No decisions have been recorded yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-3 space-y-3">
|
||||
@foreach ($selectedException->decisions as $decision)
|
||||
<div class="rounded-xl border border-gray-200 px-3 py-3 dark:border-gray-800">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ ucfirst(str_replace('_', ' ', $decision->decision_type)) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $decision->actor?->name ?? 'Unknown actor' }} · {{ $decision->decided_at?->toDayDateTimeString() ?? '—' }}
|
||||
</div>
|
||||
@if (filled($decision->reason))
|
||||
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $decision->reason }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,3 @@
|
||||
<div class="rounded-2xl border border-dashed border-gray-300 bg-white/80 p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-300">
|
||||
Exception details are unavailable for this inspection state.
|
||||
</div>
|
||||
@ -2,6 +2,7 @@
|
||||
$contextBanner = $this->canonicalContextBanner();
|
||||
$blockedBanner = $this->blockedExecutionBanner();
|
||||
$lifecycleBanner = $this->lifecycleBanner();
|
||||
$restoreContinuationBanner = $this->restoreContinuationBanner();
|
||||
$pollInterval = $this->pollInterval();
|
||||
@endphp
|
||||
|
||||
@ -49,6 +50,27 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($restoreContinuationBanner !== null)
|
||||
@php
|
||||
$restoreContinuationClasses = match ($restoreContinuationBanner['tone']) {
|
||||
'amber' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||
default => 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $restoreContinuationClasses }}">
|
||||
<p class="font-semibold">{{ $restoreContinuationBanner['title'] }}</p>
|
||||
<p class="mt-1">{{ $restoreContinuationBanner['body'] }}</p>
|
||||
@if ($restoreContinuationBanner['url'] !== null && $restoreContinuationBanner['link_label'] !== null)
|
||||
<p class="mt-3">
|
||||
<a href="{{ $restoreContinuationBanner['url'] }}" class="font-semibold underline underline-offset-2">
|
||||
{{ $restoreContinuationBanner['link_label'] }}
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->redactionIntegrityNote())
|
||||
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $this->redactionIntegrityNote() }}
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
</h1>
|
||||
|
||||
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
This home stays workspace-scoped even when you were previously working in a tenant. Tenant drill-down remains explicit so the overview never silently narrows itself.
|
||||
This home stays workspace-scoped even when you were previously working in a tenant. Governance risk is ranked ahead of execution noise, and calm wording only appears when the checked workspace domains are genuinely quiet.
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@ -82,6 +82,18 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
||||
@endif
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
|
||||
Governance risk counts affected tenants
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
|
||||
Activity counts execution load only
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||
Recent operations stay diagnostic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
|
||||
'metrics' => $overview['summary_metrics'] ?? [],
|
||||
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))
|
||||
|
||||
@ -4,20 +4,26 @@
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
[
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
],
|
||||
);
|
||||
|
||||
$outcomeSpec = (string) $run->status === 'completed'
|
||||
? \App\Support\Badges\BadgeRenderer::spec(
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||
(string) $run->outcome,
|
||||
)
|
||||
: null;
|
||||
[
|
||||
'outcome' => (string) $run->outcome,
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
],
|
||||
);
|
||||
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasSummary = count($summaryCounts) > 0;
|
||||
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
@ -104,6 +110,47 @@
|
||||
</dl>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Current lifecycle truth
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Still active</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ ($decisionTruth['isCurrentlyActive'] ?? false) ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Automatic reconciliation</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ ($decisionTruth['isReconciled'] ?? false) ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Problem class</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $decisionTruth['problemClassLabel'] ?? 'None' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($decisionTruth['attentionNote'] ?? null))
|
||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $decisionTruth['attentionNote'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($decisionTruth['staleLineageNote'] ?? null))
|
||||
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
{{ $decisionTruth['staleLineageNote'] }}
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
@if ($integrityNote)
|
||||
<x-filament::section>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
|
||||
@ -17,12 +17,20 @@
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
[
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
],
|
||||
);
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||
(string) $run->outcome,
|
||||
[
|
||||
'outcome' => (string) $run->outcome,
|
||||
'status' => (string) $run->status,
|
||||
'freshness_state' => $run->freshnessState()->value,
|
||||
],
|
||||
);
|
||||
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
@ -38,6 +46,11 @@
|
||||
<x-filament::badge :color="$outcomeSpec->color" size="sm">
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@if ($lifecycleAttention)
|
||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
|
||||
{{ $lifecycleAttention }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<x-filament::section
|
||||
heading="Verification report"
|
||||
description="Latest verification state for this tenant (DB-only rendering)."
|
||||
description="Latest stored verification result for this tenant. Consent and connection configuration are summarized separately above."
|
||||
>
|
||||
<div class="space-y-4">
|
||||
@if ($run === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
No verification operation has been started yet.
|
||||
No provider verification check has been recorded yet.
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@ -22,25 +22,66 @@
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($items as $item)
|
||||
<a
|
||||
href="{{ $item['url'] }}"
|
||||
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $item['title'] }}
|
||||
</div>
|
||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $item['body'] }}
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
$destination = $item['destination'] ?? null;
|
||||
$actionUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
|
||||
? ($destination['url'] ?? null)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-200">
|
||||
{{ $item['tenant_label'] }}
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
|
||||
{{ str_replace('_', ' ', $item['urgency']) }}
|
||||
</span>
|
||||
|
||||
<x-filament::badge :color="$item['badge_color']" size="sm">
|
||||
{{ $item['badge'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $item['title'] }}
|
||||
</div>
|
||||
|
||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $item['body'] }}
|
||||
</div>
|
||||
|
||||
@if (filled($item['supporting_message'] ?? null))
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $item['supporting_message'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
@if (is_string($actionUrl) && $actionUrl !== '')
|
||||
<x-filament::link :href="$actionUrl" size="sm">
|
||||
{{ $destination['label'] ?? 'Open' }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ $destination['label'] ?? 'Unavailable' }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if (filled($item['helper_text'] ?? null))
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $item['helper_text'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
<x-filament::section heading="Recent operations">
|
||||
<p class="mb-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Diagnostic recency across your visible workspace slice. This does not define governance health on its own.
|
||||
</p>
|
||||
|
||||
@if ($operations === [])
|
||||
<div class="flex h-full flex-col justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
@ -22,10 +26,13 @@
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($operations as $operation)
|
||||
<a
|
||||
href="{{ $operation['url'] }}"
|
||||
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
|
||||
>
|
||||
@php
|
||||
$destination = $operation['destination'] ?? null;
|
||||
$actionUrl = is_array($destination) ? ($destination['url'] ?? null) : ($operation['url'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@ -47,6 +54,11 @@ class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:b
|
||||
<x-filament::badge :color="$operation['outcome_color']" size="sm">
|
||||
{{ $operation['outcome_label'] }}
|
||||
</x-filament::badge>
|
||||
@if (filled($operation['lifecycle_label'] ?? null))
|
||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
|
||||
{{ $operation['lifecycle_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($operation['guidance'] ?? null))
|
||||
@ -60,7 +72,16 @@ class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:b
|
||||
{{ $operation['started_at'] }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (is_string($actionUrl) && $actionUrl !== '')
|
||||
<div>
|
||||
<x-filament::link :href="$actionUrl" size="sm">
|
||||
{{ $destination['label'] ?? 'Open operation' }}
|
||||
</x-filament::link>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
x-data="opsUxProgressWidgetPoller()"
|
||||
x-init="init()"
|
||||
wire:key="ops-ux-progress-widget"
|
||||
@if (! $disabled && $hasActiveRuns)
|
||||
wire:poll.10s="refreshRuns"
|
||||
@endif
|
||||
>
|
||||
@if($runs->isNotEmpty())
|
||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Spec 175 - Workspace Governance Attention Foundation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-04
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed against the written spec on 2026-04-04.
|
||||
- No clarification markers were needed because the supplied feature description already defined scope, priorities, constraints, and desired outcomes precisely.
|
||||
- The spec stays intentionally narrow: it hardens existing workspace surfaces using already-available tenant truth and explicitly rejects new persistence, scores, or matrix-style redesign in this slice.
|
||||
@ -0,0 +1,541 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Workspace Governance Attention Internal Surface Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for governance-aware workspace overview semantics
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 175. It documents how
|
||||
the existing workspace overview must derive governance-aware summary metrics,
|
||||
attention items, calmness claims, and drill-through destinations from visible
|
||||
tenant truth. The rendered routes still return HTML. The structured schemas
|
||||
below describe the internal page and widget models that must be derivable
|
||||
before rendering. This does not add a public HTTP API.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-overview-consumers:
|
||||
- surface: workspace.overview.summary_stats
|
||||
summarySource:
|
||||
- workspace_overview_builder
|
||||
- tenant_governance_aggregate
|
||||
- operation_run_activity
|
||||
- alert_delivery_activity
|
||||
guardScope:
|
||||
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||
- app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
|
||||
expectedContract:
|
||||
- governance_metrics_count_visible_tenants_not_raw_issue_totals
|
||||
- governance_metrics_are_distinct_from_activity_metrics
|
||||
- surface: workspace.overview.needs_attention
|
||||
summarySource:
|
||||
- workspace_overview_builder
|
||||
- tenant_governance_aggregate
|
||||
- existing_evidence_review_truth
|
||||
- operation_run_activity
|
||||
- alert_delivery_activity
|
||||
guardScope:
|
||||
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||
- app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
|
||||
- resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php
|
||||
expectedContract:
|
||||
- each_item_identifies_the_visible_tenant_when_tenant_bound
|
||||
- all_attention_items_are_tenant_bound
|
||||
- governance_issues_rank_above_activity_only_items
|
||||
- compare_attention_only_uses_stale_or_compare_specific_action_required_signals
|
||||
- each_item_has_one_matching_destination_or_a_safe_disabled_state
|
||||
- surface: workspace.overview.calmness
|
||||
summarySource:
|
||||
- workspace_overview_builder
|
||||
- tenant_governance_aggregate
|
||||
- operation_run_activity
|
||||
- alert_delivery_activity
|
||||
guardScope:
|
||||
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||
- resources/views/filament/pages/workspace-overview.blade.php
|
||||
expectedContract:
|
||||
- operations_quiet_alone_is_not_enough_for_calmness
|
||||
- zero_tenant_and_low_permission_states_do_not_masquerade_as_healthy_calm
|
||||
- zero_tenant_recovery_uses_switch_workspace_and_low_permission_recovery_uses_operations_index
|
||||
- surface: workspace.overview.recent_operations
|
||||
summarySource:
|
||||
- workspace_overview_builder
|
||||
- operation_run_recency_query
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php
|
||||
- resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php
|
||||
expectedContract:
|
||||
- surface_role_is_diagnostic_recency_not_primary_governance_summary
|
||||
- recent_operations_remain_filtered_to_visible_tenant_scope
|
||||
- recent_operations_are_bounded_to_five
|
||||
paths:
|
||||
/admin:
|
||||
get:
|
||||
summary: Render the governance-aware workspace overview bundle
|
||||
operationId: viewWorkspaceGovernanceAttentionOverview
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace overview rendered with governance-aware summary, attention, calmness, and recency semantics
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.workspace-governance-attention+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceGovernanceOverviewBundle'
|
||||
'302':
|
||||
description: No workspace context is active yet, so the request is redirected to `/admin/choose-workspace`
|
||||
'404':
|
||||
description: Workspace is outside entitlement scope
|
||||
/admin/choose-tenant:
|
||||
get:
|
||||
summary: Explicit tenant-entry destination used by workspace drill-down when the operator wants to pick a tenant deliberately
|
||||
operationId: openChooseTenantFromWorkspaceOverview
|
||||
responses:
|
||||
'200':
|
||||
description: Choose-tenant page opened inside the authenticated admin panel and may bootstrap workspace context from the selected tenant if no workspace is currently active
|
||||
/admin/choose-workspace:
|
||||
get:
|
||||
summary: Default workspace-switch recovery destination for zero-tenant or wrong-workspace states
|
||||
operationId: openChooseWorkspaceFromWorkspaceOverview
|
||||
responses:
|
||||
'200':
|
||||
description: Choose-workspace page opened so the operator can recover to another entitled workspace context even when no workspace is currently active
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant dashboard fallback or broad tenant drill-through from workspace attention
|
||||
operationId: openTenantDashboardFromWorkspaceAttention
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant dashboard opened for the visible tenant named by the workspace item when the dashboard is the allowed fallback or primary tenant landing
|
||||
'404':
|
||||
description: Tenant is outside entitlement scope
|
||||
/admin/t/{tenant}/findings:
|
||||
get:
|
||||
summary: Tenant findings destination used by workspace governance and findings attention
|
||||
operationId: openTenantFindingsFromWorkspaceAttention
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: tab
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingsTab'
|
||||
- name: high_severity
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
- name: governance_validity
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceValidityFilter'
|
||||
description: Reproduces directly filterable governance-validity subsets such as `missing_support` or `expiring`; aggregate lapsed-governance attention falls back to the tenant dashboard when the full invalid-governance family cannot be reproduced without narrowing.
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant findings list opened with a subset matching the originating workspace attention item
|
||||
'403':
|
||||
description: Actor is in scope but lacks findings inspection capability
|
||||
'404':
|
||||
description: Tenant is outside entitlement scope
|
||||
/admin/t/{tenant}/baseline-compare-landing:
|
||||
get:
|
||||
summary: Tenant baseline compare landing used by workspace compare attention
|
||||
operationId: openTenantBaselineCompareFromWorkspaceAttention
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant baseline compare landing opened with the same compare posture family summarized by the workspace item
|
||||
'403':
|
||||
description: Actor is in scope but lacks the current tenant-view capability required by the compare landing
|
||||
'404':
|
||||
description: Tenant is outside entitlement scope
|
||||
/admin/t/{tenant}/evidence:
|
||||
get:
|
||||
summary: Tenant evidence destination used only when existing evidence truth is the most precise workspace next jump
|
||||
operationId: openTenantEvidenceFromWorkspaceAttention
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant evidence surface opened for the visible tenant named by the workspace item
|
||||
'403':
|
||||
description: Actor is in scope but lacks evidence inspection capability
|
||||
'404':
|
||||
description: Tenant is outside entitlement scope
|
||||
/admin/t/{tenant}/reviews:
|
||||
get:
|
||||
summary: Tenant reviews destination used only when existing review truth is the most precise workspace next jump
|
||||
operationId: openTenantReviewsFromWorkspaceAttention
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant reviews surface opened for the visible tenant named by the workspace item
|
||||
'403':
|
||||
description: Actor is in scope but lacks review inspection capability
|
||||
'404':
|
||||
description: Tenant is outside entitlement scope
|
||||
/admin/operations:
|
||||
get:
|
||||
summary: Canonical operations index used by workspace activity and operations-follow-up signals
|
||||
operationId: openOperationsFromWorkspaceOverview
|
||||
parameters:
|
||||
- name: tenant_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
description: Optional tenant filter when the workspace item points to one tenant's operations follow-up.
|
||||
- name: activeTab
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationsTab'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical operations index opened with any tenant and tab continuity required by the workspace signal; this remains the workspace-member-safe fallback for low-permission workspace states
|
||||
'404':
|
||||
description: Requested tenant context is outside entitlement scope
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Canonical operation detail opened from workspace recent operations or operations attention
|
||||
operationId: openOperationDetailFromWorkspaceOverview
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type:
|
||||
- integer
|
||||
- string
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical operation detail opened for the visible run
|
||||
'403':
|
||||
description: Actor is in scope but lacks operation detail capability
|
||||
'404':
|
||||
description: Operation run is outside entitlement scope
|
||||
/admin/alerts:
|
||||
get:
|
||||
summary: Canonical alerts overview used for alert-only follow-up from the workspace home
|
||||
operationId: openAlertsOverviewFromWorkspaceOverview
|
||||
responses:
|
||||
'200':
|
||||
description: Alerts overview opened for the current workspace, including delivery follow-up and alert-health context
|
||||
'403':
|
||||
description: Actor is in scope but lacks alerts inspection capability
|
||||
'404':
|
||||
description: Workspace is outside entitlement scope
|
||||
components:
|
||||
schemas:
|
||||
MetricCategory:
|
||||
type: string
|
||||
enum:
|
||||
- scope
|
||||
- governance_risk
|
||||
- activity
|
||||
- alerts
|
||||
AttentionFamily:
|
||||
type: string
|
||||
enum:
|
||||
- governance
|
||||
- findings
|
||||
- compare
|
||||
- evidence
|
||||
- review
|
||||
- operations
|
||||
- alerts
|
||||
AttentionUrgency:
|
||||
type: string
|
||||
enum:
|
||||
- critical
|
||||
- high
|
||||
- medium
|
||||
- supporting
|
||||
DestinationKind:
|
||||
type: string
|
||||
enum:
|
||||
- choose_tenant
|
||||
- tenant_dashboard
|
||||
- tenant_findings
|
||||
- baseline_compare_landing
|
||||
- tenant_evidence
|
||||
- tenant_reviews
|
||||
- operations_index
|
||||
- operation_detail
|
||||
- alerts_overview
|
||||
- switch_workspace
|
||||
- none
|
||||
CheckedDomain:
|
||||
type: string
|
||||
enum:
|
||||
- governance
|
||||
- findings
|
||||
- compare
|
||||
- evidence
|
||||
- review
|
||||
- operations
|
||||
- alerts
|
||||
- tenant_access
|
||||
FindingsTab:
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- needs_action
|
||||
- overdue
|
||||
- risk_accepted
|
||||
- resolved
|
||||
GovernanceValidityFilter:
|
||||
type: string
|
||||
enum:
|
||||
- missing_support
|
||||
- expiring
|
||||
- valid
|
||||
OperationsTab:
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- active
|
||||
- blocked
|
||||
- failed
|
||||
- partial
|
||||
- succeeded
|
||||
DrillthroughTarget:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- disabled
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/DestinationKind'
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
tenantRouteKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
label:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
disabled:
|
||||
type: boolean
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
filters:
|
||||
type:
|
||||
- object
|
||||
- 'null'
|
||||
additionalProperties: true
|
||||
WorkspaceSummaryMetric:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- value
|
||||
- category
|
||||
- description
|
||||
- color
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
type: integer
|
||||
minimum: 0
|
||||
category:
|
||||
$ref: '#/components/schemas/MetricCategory'
|
||||
description:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
destination:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DrillthroughTarget'
|
||||
- type: 'null'
|
||||
WorkspaceAttentionItem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- tenantId
|
||||
- tenantLabel
|
||||
- family
|
||||
- urgency
|
||||
- title
|
||||
- body
|
||||
- badge
|
||||
- badgeColor
|
||||
anyOf:
|
||||
- required:
|
||||
- destination
|
||||
properties:
|
||||
destination:
|
||||
$ref: '#/components/schemas/DrillthroughTarget'
|
||||
- required:
|
||||
- actionDisabled
|
||||
- helperText
|
||||
properties:
|
||||
actionDisabled:
|
||||
const: true
|
||||
helperText:
|
||||
type: string
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
tenantId:
|
||||
type: integer
|
||||
tenantLabel:
|
||||
type: string
|
||||
family:
|
||||
$ref: '#/components/schemas/AttentionFamily'
|
||||
urgency:
|
||||
$ref: '#/components/schemas/AttentionUrgency'
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
badge:
|
||||
type: string
|
||||
badgeColor:
|
||||
type: string
|
||||
destination:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DrillthroughTarget'
|
||||
- type: 'null'
|
||||
actionDisabled:
|
||||
type:
|
||||
- boolean
|
||||
- 'null'
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
WorkspaceRecentOperation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- statusLabel
|
||||
- statusColor
|
||||
- outcomeLabel
|
||||
- outcomeColor
|
||||
- startedAt
|
||||
- destination
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
tenantLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
statusLabel:
|
||||
type: string
|
||||
statusColor:
|
||||
type: string
|
||||
outcomeLabel:
|
||||
type: string
|
||||
outcomeColor:
|
||||
type: string
|
||||
guidance:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
startedAt:
|
||||
type: string
|
||||
destination:
|
||||
$ref: '#/components/schemas/DrillthroughTarget'
|
||||
WorkspaceCalmnessState:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- isCalm
|
||||
- checkedDomains
|
||||
- title
|
||||
- body
|
||||
- nextAction
|
||||
properties:
|
||||
isCalm:
|
||||
type: boolean
|
||||
checkedDomains:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CheckedDomain'
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
nextAction:
|
||||
description: Defaults to `switch_workspace` for zero-tenant recovery and `operations_index` for low-permission workspace-state recovery unless a more specific allowed action exists.
|
||||
$ref: '#/components/schemas/DrillthroughTarget'
|
||||
WorkspaceGovernanceOverviewBundle:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspaceId
|
||||
- workspaceName
|
||||
- summaryMetrics
|
||||
- attentionItems
|
||||
- recentOperations
|
||||
- calmness
|
||||
properties:
|
||||
workspaceId:
|
||||
type: integer
|
||||
workspaceName:
|
||||
type: string
|
||||
summaryMetrics:
|
||||
type: array
|
||||
maxItems: 8
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkspaceSummaryMetric'
|
||||
attentionItems:
|
||||
type: array
|
||||
maxItems: 5
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkspaceAttentionItem'
|
||||
recentOperations:
|
||||
type: array
|
||||
maxItems: 5
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkspaceRecentOperation'
|
||||
calmness:
|
||||
$ref: '#/components/schemas/WorkspaceCalmnessState'
|
||||
313
specs/175-workspace-governance-attention/data-model.md
Normal file
313
specs/175-workspace-governance-attention/data-model.md
Normal file
@ -0,0 +1,313 @@
|
||||
# Phase 1 Data Model: Workspace Governance Attention Foundation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add a table, persisted workspace summary, or new cross-domain runtime subsystem. It aligns the existing workspace overview surface with already-available tenant truth so the workspace home can answer which visible tenants need governance attention, why they need it, and where the operator should jump next.
|
||||
|
||||
## Persistent Source Truths
|
||||
|
||||
### Workspace
|
||||
|
||||
**Purpose**: Scope boundary for the workspace home and all workspace-safe aggregates.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `name`
|
||||
- `slug`
|
||||
|
||||
**Validation rules**:
|
||||
- Workspace overview aggregation must always resolve for one explicit workspace.
|
||||
- Non-members must receive deny-as-not-found behavior before any workspace truth is rendered.
|
||||
|
||||
### Tenant
|
||||
|
||||
**Purpose**: Scope boundary and identity anchor for every governance-aware workspace attention item.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `name`
|
||||
- `status`
|
||||
|
||||
**Validation rules**:
|
||||
- Only active tenants inside the current workspace and inside the current user's entitled tenant slice may contribute to workspace attention or governance-risk metrics.
|
||||
- Every workspace attention item must identify one visible tenant explicitly.
|
||||
|
||||
### Finding
|
||||
|
||||
**Purpose**: Source of overdue findings, high-severity active findings, and other governance workflow pressure promoted into workspace attention.
|
||||
|
||||
**Key fields**:
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `finding_type`
|
||||
- `status`
|
||||
- `severity`
|
||||
- `due_at`
|
||||
- `scope_key`
|
||||
|
||||
**Validation rules**:
|
||||
- Canonical open and active semantics remain sourced from existing finding query helpers.
|
||||
- Workspace promotion must not invent a second finding status universe.
|
||||
|
||||
### FindingException / Governance Validity
|
||||
|
||||
**Purpose**: Source of lapsed governance and expiring governance truth for risk-accepted findings.
|
||||
|
||||
**Key fields**:
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `finding_id`
|
||||
- `status`
|
||||
- `current_validity_state`
|
||||
- `review_due_at`
|
||||
- `expires_at`
|
||||
|
||||
**Validation rules**:
|
||||
- Lapsed and expiring governance remain derived from existing validity-state rules.
|
||||
- Workspace promotion must not replace existing governance-validity semantics with a new workspace-specific status family.
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Source of workspace activity, operation failures, and canonical operation drill-through.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `created_at`
|
||||
- `completed_at`
|
||||
|
||||
**Validation rules**:
|
||||
- Active operations and failed or warning operations remain activity truths, not governance truths.
|
||||
- Workspace attention may still include operations follow-up, but those items must remain semantically distinct from governance items.
|
||||
|
||||
### AlertDelivery
|
||||
|
||||
**Purpose**: Source of workspace alert-delivery failures that remain supporting attention but no longer define workspace calmness by themselves.
|
||||
|
||||
**Key fields**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `status`
|
||||
- `created_at`
|
||||
|
||||
**Validation rules**:
|
||||
- Alert-delivery failures remain lower-priority supporting signals once governance-critical tenant states exist.
|
||||
|
||||
### EvidenceSnapshot and TenantReview
|
||||
|
||||
**Purpose**: Existing tenant-level evidence and review truth that may serve as a precise workspace drill-through target when already-available truth makes them the best next jump.
|
||||
|
||||
**Key fields**:
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `status` or completeness fields on the owning model
|
||||
- associated detail identifiers needed by tenant evidence or review resources
|
||||
|
||||
**Validation rules**:
|
||||
- This slice does not add a new workspace evidence or review aggregate.
|
||||
- Evidence or review destinations may only be used when existing tenant truth already makes them the most precise action target.
|
||||
|
||||
## Existing Runtime Source Objects
|
||||
|
||||
### TenantGovernanceAggregate
|
||||
|
||||
**Purpose**: Existing derived tenant summary that already combines compare posture with overdue, expiring, lapsed, and high-severity counts.
|
||||
|
||||
**Key consumed fields**:
|
||||
- `tenantId`
|
||||
- `workspaceId`
|
||||
- `compareState`
|
||||
- `stateFamily`
|
||||
- `tone`
|
||||
- `headline`
|
||||
- `supportingMessage`
|
||||
- `overdueOpenFindingsCount`
|
||||
- `expiringGovernanceCount`
|
||||
- `lapsedGovernanceCount`
|
||||
- `highSeverityActiveFindingsCount`
|
||||
- `nextActionLabel`
|
||||
- `nextActionTarget`
|
||||
- `positiveClaimAllowed`
|
||||
- `summaryAssessment`
|
||||
|
||||
**Validation rules**:
|
||||
- Workspace promotion should consume this existing summary contract before considering lower-level recomputation.
|
||||
- Any workspace calmness or ranking rule based on compare or governance must remain consistent with this aggregate.
|
||||
|
||||
### BaselineCompareStats
|
||||
|
||||
**Purpose**: Existing compare-backed statistics object underlying the tenant governance aggregate.
|
||||
|
||||
**Key consumed fields**:
|
||||
- `overdueOpenFindingsCount`
|
||||
- `expiringGovernanceCount`
|
||||
- `lapsedGovernanceCount`
|
||||
- `highSeverityActiveFindingsCount`
|
||||
- compare posture state and evidence-gap fields needed by `summaryAssessment`
|
||||
|
||||
**Validation rules**:
|
||||
- Workspace code should not fork a second compare-summary model while this object already provides the necessary facts.
|
||||
|
||||
### BaselineCompareSummaryAssessment
|
||||
|
||||
**Purpose**: Existing compare posture contract that maps compare stats into a state family, tone, headline, and next-action intent.
|
||||
|
||||
**Key consumed fields**:
|
||||
- `stateFamily`
|
||||
- `tone`
|
||||
- `headline`
|
||||
- `supportingMessage`
|
||||
- `reasonCode`
|
||||
- `nextActionTarget()` semantics as reflected into the aggregate
|
||||
|
||||
**Validation rules**:
|
||||
- Workspace compare attention and calmness suppression should reuse this assessment path rather than inventing a workspace-only tone system.
|
||||
|
||||
### WorkspaceOverviewBuilder Payload
|
||||
|
||||
**Purpose**: Existing page-level payload that already carries summary metrics, attention items, recent operations, quick actions, and empty states for `/admin`.
|
||||
|
||||
**Validation rules**:
|
||||
- Governance hardening extends this payload shape rather than replacing the page with a new surface framework.
|
||||
- Existing visible-tenant filtering remains the workspace aggregation guardrail.
|
||||
|
||||
## Derived Workspace View Contracts
|
||||
|
||||
### Workspace Summary Metric
|
||||
|
||||
**Purpose**: Compact workspace stat that answers one scope, governance-risk, or activity question and optionally opens one matching destination.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `key` | string | yes | Stable metric identity such as `accessible_tenants`, `governance_attention_tenants`, `overdue_findings_tenants`, or `active_operations` |
|
||||
| `label` | string | yes | Operator-facing label that must accurately describe the counted universe |
|
||||
| `value` | integer | yes | Metric value |
|
||||
| `category` | enum | yes | `scope`, `governance_risk`, `activity`, or `alerts` |
|
||||
| `description` | string | yes | Short explanation of what the metric means |
|
||||
| `color` | string | yes | Existing tone family used by the widget |
|
||||
| `destination` | object nullable | no | Shared drill-through contract when the metric is actionable |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Governance-risk metrics count affected visible tenants, not raw issue totals.
|
||||
- Activity metrics remain activity-only and must not imply governance health.
|
||||
- The stat strip must make the difference between `governance_risk` and `activity` categories obvious.
|
||||
|
||||
### Workspace Attention Item
|
||||
|
||||
**Purpose**: One prioritized workspace triage item that names a visible tenant problem and one next jump.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `key` | string | yes | Stable attention identity such as `tenant_overdue_findings`, `tenant_lapsed_governance`, `tenant_compare_attention`, or `tenant_failed_operation` |
|
||||
| `tenantId` | integer | yes | Visible tenant identifier |
|
||||
| `tenantLabel` | string | yes | Tenant name shown to the operator |
|
||||
| `family` | enum | yes | `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, or `alerts` |
|
||||
| `urgency` | enum | yes | `critical`, `high`, `medium`, or `supporting` |
|
||||
| `title` | string | yes | Primary operator-facing summary |
|
||||
| `body` | string | yes | Short explanation of why this needs attention |
|
||||
| `badge` | string | yes | Existing family label shown in the UI |
|
||||
| `badgeColor` | string | yes | Existing tone family used for the item |
|
||||
| `supportingMessage` | string nullable | no | Secondary explanatory text when needed |
|
||||
| `destination` | object nullable | no | Shared drill-through contract |
|
||||
| `actionDisabled` | boolean nullable | no | Whether the visible next step is intentionally disabled |
|
||||
| `helperText` | string nullable | no | Explanation when the visible next step is disabled |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Every workspace attention item is tenant-bound and must include both `tenantId` and `tenantLabel`.
|
||||
- Governance, findings, compare, evidence or review, and operations items must remain semantically distinct.
|
||||
- Compare attention promotes `BaselineCompareSummaryAssessment::STATE_STALE` directly and only treats `BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven.
|
||||
- `BaselineCompareSummaryAssessment::STATE_CAUTION` stays below the workspace-attention threshold unless another governance signal independently warrants promotion.
|
||||
- Workspace-wide operations or alert totals remain in summary metrics or recent operations until they can be attributed to one visible tenant.
|
||||
- Each item may expose one primary destination only.
|
||||
- Every item must either expose a non-null destination or set `actionDisabled=true` with explanatory `helperText`; null destination without an explicit disabled state is invalid.
|
||||
- If the most precise destination is not available to the current in-scope user, the item must either use an allowed fallback or expose a disabled explanatory state instead of a clickable dead end.
|
||||
|
||||
### Workspace Recent Operation
|
||||
|
||||
**Purpose**: One bounded recent-operations entry shown on the workspace overview as diagnostic recency, not governance posture.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `id` | integer | yes | Operation run identifier |
|
||||
| `title` | string | yes | Operator-facing operation label |
|
||||
| `tenantLabel` | string nullable | no | Tenant name when the run is tenant-bound |
|
||||
| `statusLabel` | string | yes | Human-readable run status |
|
||||
| `statusColor` | string | yes | Existing tone family for the run status |
|
||||
| `outcomeLabel` | string | yes | Human-readable run outcome |
|
||||
| `outcomeColor` | string | yes | Existing tone family for the run outcome |
|
||||
| `guidance` | string nullable | no | Short follow-up guidance when helpful |
|
||||
| `startedAt` | string | yes | Render-ready recency label |
|
||||
| `destination` | object | yes | Canonical operation-detail drill-through target |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- The recent-operations collection is bounded to the five most recent visible runs.
|
||||
- Recent operations remain diagnostic context and do not define calmness on their own.
|
||||
- `tenantLabel` may be null only when the run is genuinely workspace-wide rather than tenant-bound.
|
||||
|
||||
### Workspace Drill-Through Target
|
||||
|
||||
**Purpose**: Shared navigation contract used by summary metrics and attention items.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `kind` | enum | yes | `choose_tenant`, `tenant_dashboard`, `tenant_findings`, `baseline_compare_landing`, `tenant_evidence`, `tenant_reviews`, `operations_index`, `operation_detail`, `alerts_overview`, `switch_workspace`, or `none` |
|
||||
| `url` | string nullable | no | Destination URL when the target is actionable |
|
||||
| `tenantRouteKey` | string nullable | no | Tenant route scope when the destination is tenant-bound |
|
||||
| `filters` | object nullable | no | Query or state needed to reproduce the same subset on the destination |
|
||||
| `label` | string nullable | no | Primary action label |
|
||||
| `disabled` | boolean | yes | Whether the target is intentionally non-clickable |
|
||||
| `helperText` | string nullable | no | Explanation shown when the target is disabled |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- `kind=none` may only be used for intentionally passive reassurance states.
|
||||
- `tenant_findings` must carry the filter state needed to reproduce overdue, high-severity, expiring-governance, or other directly filterable findings subsets when applicable.
|
||||
- Aggregate lapsed-governance attention uses `tenant_dashboard` when the current tenant findings filters would otherwise narrow the full invalid-governance family to a smaller subset such as `missing_support`.
|
||||
- `operations_index` may carry tenant and tab filters but must remain the canonical admin operations route and the workspace-member-safe fallback for low-permission workspace states.
|
||||
- `alerts_overview` targets the existing alerts overview at `/admin/alerts`, which remains the canonical alert-delivery follow-up surface for this slice.
|
||||
- `switch_workspace` targets `/admin/choose-workspace` and is the default zero-tenant recovery action.
|
||||
|
||||
### Workspace Calmness State
|
||||
|
||||
**Purpose**: The derived claim that the workspace is currently calm enough for an empty or reassurance state.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `isCalm` | boolean | yes | Whether the workspace may currently make a calmness claim |
|
||||
| `checkedDomains` | array<enum> | yes | Domains actually checked before the claim was made: `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, `alerts`, `tenant_access` |
|
||||
| `title` | string | yes | Empty-state or reassurance title |
|
||||
| `body` | string | yes | Supporting explanation constrained to the checked domains |
|
||||
| `nextAction` | object | yes | One bounded next action |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- `isCalm=true` is invalid whenever any visible tenant has governance-critical conditions inside the checked domains.
|
||||
- Zero-tenant states and low-permission states must not masquerade as healthy calmness states.
|
||||
- Zero-tenant states default `nextAction.kind` to `switch_workspace`, while low-permission states default `nextAction.kind` to `operations_index` unless a more specific allowed in-scope recovery action exists.
|
||||
- Calm wording must not imply portfolio health beyond the `checkedDomains` list.
|
||||
|
||||
## Ranking Rules
|
||||
|
||||
1. Governance-critical tenant conditions outrank activity-only and alert-only items.
|
||||
2. A single tenant may contribute multiple raw issues, but workspace attention should surface a bounded prioritized subset.
|
||||
3. The stat strip answers portfolio counts by tenant, while the attention list answers which tenant to open next.
|
||||
4. Recent operations remain supporting recency context and do not participate in calmness unless explicitly modeled as an operations-follow-up issue.
|
||||
332
specs/175-workspace-governance-attention/plan.md
Normal file
332
specs/175-workspace-governance-attention/plan.md
Normal file
@ -0,0 +1,332 @@
|
||||
# Implementation Plan: Workspace Governance Attention Foundation
|
||||
|
||||
**Branch**: `175-workspace-governance-attention` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
|
||||
|
||||
**Note**: This plan follows the existing TenantPilot workspace and tenant-truth architecture. It hardens the current workspace overview instead of introducing a new workspace posture subsystem.
|
||||
|
||||
## Summary
|
||||
|
||||
Promote existing tenant governance truth into the existing workspace overview so `/admin` becomes a trustworthy multi-tenant governance attention surface instead of an operations-first calm surface. The implementation will keep `WorkspaceOverviewBuilder` as the orchestration point, reuse `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, existing findings and compare destinations, and optionally already-available evidence or review surfaces where they provide a more precise next jump. The first slice will harden summary metrics, attention ranking, tenant identification, compare breadth across stale, failed, and degraded states, and calmness semantics; the second slice will preserve activity versus governance separation and protect the new contract with focused workspace overview regression tests.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
|
||||
**Storage**: PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced
|
||||
**Testing**: Pest 4 feature and Livewire-style widget tests through Laravel Sail using existing workspace overview tests plus new governance attention and drill-through coverage
|
||||
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Keep `/admin` DB-only at render time, keep workspace attention bounded, reuse request-scoped derived-state caching for tenant governance aggregates, and avoid uncontrolled polling or unbounded cross-tenant queries
|
||||
**Constraints**: No new table, no new workspace posture score, no full portfolio matrix, no new panel/provider, no cross-tenant leakage, no dead-end drill-throughs for visible states, no ad-hoc status taxonomy, and no broad workspace IA redesign
|
||||
**Scale/Scope**: One workspace landing page, three existing workspace widgets, one builder, one accessible-tenant slice per workspace, six central governance signal families, and focused regression coverage for workspace calmness, ranking, drill-through continuity, and RBAC-safe omission or fallback behavior
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | The feature adds no new inventory or snapshot truth. It only changes read-time workspace aggregation over existing records. |
|
||||
| Read/write separation | PASS | PASS | The slice is read-only workspace overview hardening. No new write path, preview flow, or destructive action is introduced. |
|
||||
| Graph contract path | N/A | N/A | No Graph call or `config/graph_contracts.php` change is required. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing workspace membership and tenant capability checks remain authoritative for aggregation and drill-through behavior. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | `/admin` remains workspace-scoped, tenant destinations remain tenant-scoped, non-members stay `404`, and members missing downstream capability must not receive dead-end links. |
|
||||
| Workspace and tenant isolation | PASS | PASS | Aggregation stays limited to visible tenants in the active workspace and uses existing `scopeToAuthorizedTenants()` style filtering. |
|
||||
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type, feedback surface, or lifecycle change is added. Existing operations destinations remain canonical. |
|
||||
| Data minimization | PASS | PASS | The plan adds no new persistence and no broader route exposure. Only already-visible tenant truth is promoted into the workspace home. |
|
||||
| Proportionality / no premature abstraction | PASS | PASS | The plan reuses `WorkspaceOverviewBuilder` and `TenantGovernanceAggregateResolver` instead of adding a new workspace posture framework or aggregate layer. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new table, enum, status family, or persisted summary artifact is planned. |
|
||||
| UI semantics / few layers | PASS | PASS | The feature aligns existing widget semantics rather than introducing a new presenter or badge taxonomy. |
|
||||
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone domains remain the source for operations and compare posture meaning. New workspace items reuse those semantics rather than inventing new colors or labels. |
|
||||
| Filament-native UI / Action Surface Contract | PASS | PASS | `WorkspaceOverview` and its widgets remain navigation and inspection surfaces only. No destructive or redundant action model is added. |
|
||||
| Filament UX-001 | PASS | PASS | No create or edit page is touched. The design keeps governance attention above recent operations and uses bounded empty-state wording. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 + Livewire v4 stack. |
|
||||
| Provider registration location | PASS | PASS | No panel/provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No global-searchable resource behavior is changed in this slice. |
|
||||
| Destructive action safety | PASS | PASS | The feature introduces no destructive action. |
|
||||
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment change is required. |
|
||||
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds tests around business consequences: false calmness, tenant identification, ordering, continuity, and RBAC-safe navigation behavior. |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse `WorkspaceOverviewBuilder` as the single orchestration point instead of creating a new workspace posture service.
|
||||
- Reuse `TenantGovernanceAggregateResolver` and `BaselineCompareStats` per visible tenant as the primary source for lapsed governance, overdue findings, expiring governance, high-severity active findings, and stale, failed, or materially degraded compare posture.
|
||||
- Count governance risk at the tenant level for workspace summary metrics so the workspace answer is “how many tenants need attention,” not “how many raw issues exist.”
|
||||
- Rank governance-critical tenant states above activity-only or alert-delivery-only items, while keeping operations and alerts available as lower-priority supporting attention.
|
||||
- Make every workspace attention item tenant-identifiable and map it to exactly one matching destination, with an RBAC-safe fallback or disabled/non-clickable explanatory state when the exact destination is not allowed.
|
||||
- Default zero-tenant recovery to the existing choose-workspace route and keep alert-only follow-up on the existing alerts overview route.
|
||||
- Keep `WorkspaceRecentOperations` as a diagnostic activity surface, not a governance surface.
|
||||
- Promote evidence or review truth only when an existing tenant-level evidence or review surface already carries a clearer next jump than the tenant dashboard fallback.
|
||||
- Lean on request-scoped derived-state caching to keep per-tenant aggregate resolution viable for normal workspace sizes without new persistence.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/`:
|
||||
|
||||
- `data-model.md`: persistent source truths and derived workspace attention contracts for this slice
|
||||
- `contracts/workspace-governance-attention.openapi.yaml`: internal surface contract for workspace governance-aware overview semantics and drill-through continuity
|
||||
- `quickstart.md`: focused implementation and verification workflow
|
||||
|
||||
Design highlights:
|
||||
|
||||
- `WorkspaceOverviewBuilder` remains the builder boundary and becomes responsible for deriving governance-aware summary metrics, attention candidates, and calmness claims from visible tenants only.
|
||||
- `TenantGovernanceAggregateResolver` is the primary source for governance-related tenant promotion, mirroring the mature tenant dashboard semantics rather than reinterpreting the same truth a second time.
|
||||
- Workspace summary metrics are split into scope, governance-risk, and activity categories so the stat strip can no longer blur portfolio activity with portfolio risk.
|
||||
- Workspace attention items become structured tenant-bound records carrying tenant label, problem family, urgency, and one primary drill-through target.
|
||||
- `WorkspaceNeedsAttention` remains bounded and prioritized, favoring the highest-value tenant signal per item instead of dumping every raw issue into the workspace surface.
|
||||
- Compare-driven workspace attention must preserve stale, failed, and materially degraded posture families rather than collapsing them into a single degraded-only bucket.
|
||||
- Compare attention promotes `STATE_STALE` directly and only treats `STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven; `STATE_CAUTION` remains below the workspace-attention threshold unless another governance signal independently warrants promotion.
|
||||
- Existing evidence and review surfaces stay optional targets in this slice: they are only used when already-available truth makes them the most precise next jump.
|
||||
- Zero-tenant recovery defaults to `ChooseWorkspace`, and alert-only follow-up reuses the existing alerts overview at `/admin/alerts`.
|
||||
- `WorkspaceRecentOperations` remains a recency surface and is intentionally prevented from defining calmness on its own.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
Run after artifact generation:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/175-workspace-governance-attention/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── workspace-governance-attention.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── WorkspaceOverview.php
|
||||
│ │ ├── ChooseTenant.php
|
||||
│ │ ├── TenantDashboard.php
|
||||
│ │ └── BaselineCompareLanding.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── FindingResource.php
|
||||
│ │ ├── EvidenceSnapshotResource.php
|
||||
│ │ └── TenantReviewResource.php
|
||||
│ └── Widgets/
|
||||
│ ├── Dashboard/
|
||||
│ │ └── NeedsAttention.php
|
||||
│ └── Workspace/
|
||||
│ ├── WorkspaceSummaryStats.php
|
||||
│ ├── WorkspaceNeedsAttention.php
|
||||
│ └── WorkspaceRecentOperations.php
|
||||
├── Models/
|
||||
│ ├── Finding.php
|
||||
│ ├── AlertDelivery.php
|
||||
│ ├── OperationRun.php
|
||||
│ ├── EvidenceSnapshot.php
|
||||
│ └── TenantReview.php
|
||||
├── Services/
|
||||
│ └── Auth/
|
||||
│ ├── WorkspaceCapabilityResolver.php
|
||||
│ └── CapabilityResolver.php
|
||||
└── Support/
|
||||
├── Auth/
|
||||
│ └── Capabilities.php
|
||||
├── Baselines/
|
||||
│ ├── BaselineCompareStats.php
|
||||
│ ├── BaselineCompareSummaryAssessment.php
|
||||
│ ├── BaselineCompareSummaryAssessor.php
|
||||
│ ├── TenantGovernanceAggregate.php
|
||||
│ └── TenantGovernanceAggregateResolver.php
|
||||
├── OperationRunLinks.php
|
||||
└── Workspaces/
|
||||
└── WorkspaceOverviewBuilder.php
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
├── pages/
|
||||
│ └── workspace-overview.blade.php
|
||||
└── widgets/
|
||||
└── workspace/
|
||||
├── workspace-needs-attention.blade.php
|
||||
└── workspace-recent-operations.blade.php
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
└── Filament/
|
||||
├── WorkspaceOverviewAccessTest.php
|
||||
├── WorkspaceOverviewAuthorizationTest.php
|
||||
├── WorkspaceOverviewContentTest.php
|
||||
├── WorkspaceOverviewEmptyStatesTest.php
|
||||
├── WorkspaceOverviewLandingTest.php
|
||||
├── WorkspaceOverviewNavigationTest.php
|
||||
├── WorkspaceOverviewOperationsTest.php
|
||||
├── WorkspaceOverviewPermissionVisibilityTest.php
|
||||
├── WorkspaceOverviewGovernanceAttentionTest.php
|
||||
├── WorkspaceOverviewDbOnlyTest.php
|
||||
├── WorkspaceOverviewDrilldownContinuityTest.php
|
||||
└── WorkspaceOverviewSummaryMetricsTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Extend the current workspace overview builder, workspace widgets, tenant truth helpers, and existing destination resources or pages instead of creating a new workspace domain layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations are planned. No exceptions are currently justified.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
> No new enum/status family, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
|
||||
|
||||
- **Current operator problem**: The workspace home can look calm even when visible tenants already carry governance-critical problems.
|
||||
- **Existing structure is insufficient because**: Workspace aggregation currently stops at operations and alerts and fails to propagate already-mature tenant governance truth.
|
||||
- **Narrowest correct implementation**: Extend the existing workspace overview builder and widgets to consume existing tenant aggregates and destination contracts.
|
||||
- **Ownership cost created**: Modest additional workspace-level aggregation logic and focused regression coverage.
|
||||
- **Alternative intentionally rejected**: A new workspace posture subsystem, new persistence, or a matrix-style redesign.
|
||||
- **Release truth**: Current-release truth.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Reuse Existing Tenant Truth In The Workspace Builder
|
||||
|
||||
**Goal**: Make `WorkspaceOverviewBuilder` governance-aware without creating a parallel truth layer.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a visible-tenant governance promotion pass that resolves `TenantGovernanceAggregate` for accessible tenants and builds a bounded set of governance candidates from overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and optionally already-available evidence or review attention. |
|
||||
| A.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and existing tenant truth classes | Reuse existing aggregate outputs as-is; only add plan-level consumption, not a second interpretation layer. |
|
||||
| A.3 | `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` plus existing overview tests | Prove that visible governance-critical tenants now surface on the workspace home even when operations are quiet. |
|
||||
|
||||
### Phase B — Separate Governance Metrics From Activity Metrics
|
||||
|
||||
**Goal**: Make the stat strip clearly answer risk versus activity instead of flattening them into one line of numbers.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Replace or augment the current `needs_attention` stat with one or more tenant-level governance-risk metrics such as tenants needing governance attention, tenants with overdue findings, tenants with lapsed governance, or tenants with stale, failed, or materially degraded compare posture. |
|
||||
| B.2 | `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `resources/views/filament/pages/workspace-overview.blade.php` | Preserve the existing stat strip but ensure risk metrics and activity metrics are semantically and visually distinguishable through grouping, wording, and destination meaning. |
|
||||
| B.3 | `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and existing content tests | Prove that governance metrics count affected visible tenants, activity metrics remain activity-only, and the two meanings are not mixed. |
|
||||
|
||||
### Phase C — Make Workspace Attention Tenant-Addressable And Actionable
|
||||
|
||||
**Goal**: Turn workspace attention into a real start surface with tenant identity, reason, priority, and next jump.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Expand the attention item payload to include tenant label, tenant route key or id, problem family, urgency, and one primary target kind. Keep every attention item tenant-bound, leave workspace-wide operations or alert totals in metrics or recency surfaces, and rank governance issues above activity-only items while keeping the list bounded. |
|
||||
| C.2 | `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Render tenant identity, family, urgency, and one explicit primary action or a disabled explanatory state when the exact destination is not allowed. |
|
||||
| C.3 | `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` and `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` | Prove that each central attention family leads to the correct tenant dashboard, findings, compare, evidence, review, or operation destination and that dead-end links are not exposed. |
|
||||
|
||||
### Phase D — Fix Workspace Calmness And Empty-State Semantics
|
||||
|
||||
**Goal**: Stop the workspace home from claiming calmness when only operations are quiet.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Change empty-state and calmness logic so calm claims are suppressed whenever visible tenant governance or compare issues exist, even if operations and alerts are healthy. Keep zero-tenant states distinct from healthy states and default their recovery action to the existing choose-workspace route. |
|
||||
| D.2 | `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/pages/workspace-overview.blade.php`, and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Tighten copy so the workspace can say “nothing urgent” only for the domains actually checked and only when no visible governance-critical tenant state exists. |
|
||||
| D.3 | `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and new governance attention coverage | Prove false calmness is suppressed and low-permission or zero-tenant scenarios remain clearly distinct from healthy calm. |
|
||||
|
||||
### Phase E — Preserve Operations As Diagnostic Recency, Not Portfolio Posture
|
||||
|
||||
**Goal**: Keep `WorkspaceRecentOperations` useful without letting it dominate workspace risk semantics.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` and `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php` | Preserve existing recency behavior and row-open model, but ensure surrounding copy and page hierarchy keep it clearly subordinate to governance attention and summary metrics. |
|
||||
| E.2 | `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` and content tests | Prove operations remain filtered to the visible tenant slice, remain non-polling by default, and no longer define calmness on their own. |
|
||||
|
||||
### Phase F — Tighten RBAC-Safe Destination Selection
|
||||
|
||||
**Goal**: Ensure visible truth never creates a broken or misleading navigation path.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| F.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Services/Auth/WorkspaceCapabilityResolver.php`, `app/Services/Auth/CapabilityResolver.php`, and tenant resource URL helpers | For each attention family, choose the most precise destination the current in-scope user may actually open; otherwise fall back to an allowed tenant dashboard or disabled explanatory state. Aggregate lapsed-governance attention stays tenant-bound but falls back to the tenant dashboard when the current findings filters would narrow the invalid-governance family. Alert-only follow-up reuses `/admin/alerts`, and zero-tenant recovery reuses `/admin/choose-workspace`. |
|
||||
| F.2 | `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `WorkspaceOverviewAuthorizationTest.php`, and new drilldown tests | Prove non-members still receive `404`, hidden tenants do not affect visible output, and members missing downstream capability do not receive clickable dead-end links. |
|
||||
|
||||
### Phase G — Verification And Formatting
|
||||
|
||||
**Goal**: Lock the new workspace truth and performance contract in place.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| G.1 | Workspace overview focused test pack | Add or extend tests for governance promotion, stale, failed, and materially degraded compare breadth, ordering, tenant identity, summary metric separation, calmness suppression, zero-tenant next-step recovery, low-permission operations fallback, drill-through continuity, DB-only query-bounded rendering, and permission-safe fallbacks. |
|
||||
| G.2 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack that covers the builder, workspace view rendering, and navigation behavior. |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — Keep `WorkspaceOverviewBuilder` as the single orchestration boundary
|
||||
|
||||
The repo already has one place that constructs the workspace overview payload. Extending that builder is narrower than inventing a separate workspace governance service or presenter layer.
|
||||
|
||||
### D-002 — Promote tenant truth by tenant, not by raw issue count
|
||||
|
||||
The workspace operator needs to know which tenants need attention first. A tenant-level risk count is a better workspace answer than raw issue totals because it preserves the MSP triage shape and avoids making one noisy tenant dominate the stat strip.
|
||||
|
||||
### D-003 — Reuse `TenantGovernanceAggregate` before touching lower-level logic
|
||||
|
||||
The tenant dashboard already uses a mature governance aggregate. Workspace attention should consume that same truth path rather than rebuilding overdue, lapsed, or compare logic with new ad-hoc queries.
|
||||
|
||||
### D-004 — Keep workspace attention bounded and prioritized
|
||||
|
||||
The spec hardens the workspace entry point, not a portfolio matrix. The workspace home should surface the top visible reasons to act, not every raw tenant issue. One high-value item per visible problem family is preferable to a noisy stream.
|
||||
|
||||
### D-005 — Treat evidence and review as opportunistic precision targets
|
||||
|
||||
Evidence and reviews are in scope only when they already represent the best existing tenant-level next jump. They are not a reason to create a new workspace evidence or review aggregate in this slice.
|
||||
|
||||
### D-006 — Calmness is a checked-domain claim, not a decorative empty state
|
||||
|
||||
The workspace may only look calm when both governance and activity signals in visible scope are calm. Operations-only quietness is never enough.
|
||||
|
||||
### D-007 — Disabled or fallback navigation is preferable to dead-end clicks
|
||||
|
||||
If the current in-scope user can see that a tenant has a problem but cannot open the most precise destination, the UI must not offer a clickable link that fails later. The surface must either choose an allowed fallback or render a disabled explanatory state.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Workspace attention becomes noisy by promoting too many tenant signals | High | Medium | Bound the list, rank governance-critical families first, and cap per-page attention items. |
|
||||
| Per-tenant aggregate resolution introduces avoidable N+1 behavior | High | Medium | Reuse `TenantGovernanceAggregateResolver` request-scoped caching and keep the visible tenant slice bounded. |
|
||||
| Workspace and tenant truth diverge because workspace logic starts reinterpreting the same facts | High | Medium | Consume `TenantGovernanceAggregate` and existing destinations instead of rebuilding semantics with ad-hoc query logic. |
|
||||
| Capability gaps create misleading or broken drill-throughs | High | Medium | Implement explicit destination selection with fallback or disabled states and cover it with focused tests. |
|
||||
| Calmness copy still overstates health after the change | Medium | Medium | Add explicit calmness-suppression tests covering quiet-ops but risky-governance scenarios. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend existing workspace overview tests to preserve landing, navigation, authorization, permission visibility, and operations-slice behavior.
|
||||
- Add focused governance attention coverage for overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and quiet-ops-but-risky-governance scenarios.
|
||||
- Add summary-metric tests proving governance-risk metrics count affected visible tenants rather than raw issue counts and remain distinct from activity metrics.
|
||||
- Add drill-through continuity tests covering tenant dashboard fallback, findings filters, baseline compare landing, evidence or review targets where applicable, and canonical operation detail or index routes.
|
||||
- Add permission-sensitive tests ensuring non-members remain `404`, invisible tenants do not affect visible output, members missing a downstream capability get a safe fallback or disabled state rather than a clickable dead end, and zero-tenant members receive the choose-workspace recovery action instead of healthy calm messaging.
|
||||
- Add DB-only and query-bounding verification so render-time aggregation stays inside the plan's performance constraints and benefits from existing request-scoped caching.
|
||||
- Keep the verification pack Sail-first and run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: preserved because the design stays inside existing Filament v5 widgets and pages.
|
||||
- Provider registration location: unchanged; no panel/provider registration work is needed beyond the existing `bootstrap/providers.php` setup.
|
||||
- Global-searchable resources: unchanged; no resource search behavior is altered.
|
||||
- Destructive actions: unchanged; the feature adds no destructive action and therefore no new confirmation flow.
|
||||
- Asset strategy: unchanged; no new asset bundle or deploy-time asset step is introduced.
|
||||
- Testing plan: focused Pest coverage will be added or extended for workspace overview rendering, governance promotion, calmness suppression, summary metric separation, drill-through continuity, and RBAC-safe behavior.
|
||||
121
specs/175-workspace-governance-attention/quickstart.md
Normal file
121
specs/175-workspace-governance-attention/quickstart.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Quickstart: Workspace Governance Attention Foundation
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that `/admin` no longer appears calm when visible tenants carry governance-critical conditions, that workspace summary metrics distinguish risk from activity, and that workspace attention items identify the correct tenant and open the correct next surface.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail.
|
||||
2. Ensure you have one workspace with multiple visible tenants and current workspace session context.
|
||||
3. Prepare seeded tenant scenarios for:
|
||||
- no governance-critical conditions and no unusual activity
|
||||
- overdue findings with otherwise quiet operations
|
||||
- lapsed governance
|
||||
- expiring governance
|
||||
- high-severity active findings
|
||||
- stale, failed, or materially degraded compare posture
|
||||
- activity-only workspace noise with otherwise healthy governance posture
|
||||
- optional existing evidence or review attention if those truth surfaces are already available
|
||||
4. Prepare one workspace member who can see the workspace home but lacks at least one downstream tenant destination capability so disabled or fallback attention behavior can be verified.
|
||||
5. Prepare one workspace member who belongs to the workspace but has zero accessible tenants so the choose-workspace recovery path can be verified.
|
||||
|
||||
## Implementation Validation Order
|
||||
|
||||
### 1. Run the existing workspace overview baseline pack
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewLandingTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- The existing workspace home still renders, remains workspace-scoped, and preserves current access and operations behavior.
|
||||
|
||||
### 2. Run focused governance-attention coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Visible tenants with overdue findings, lapsed governance, expiring governance, high-severity active findings, or stale, failed, or materially degraded compare posture now promote governance attention into the workspace home.
|
||||
- Governance-risk metrics count affected tenants and remain distinct from operations or alerts volume.
|
||||
|
||||
### 3. Run drill-through continuity and RBAC-safe navigation coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Workspace attention items carry tenant context and open the correct findings, compare, evidence, review, tenant dashboard, or operations destination.
|
||||
- Members missing a downstream capability do not receive clickable dead-end links.
|
||||
|
||||
### 4. Re-run workspace empty-state and calmness coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php --filter=calm
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- The workspace no longer renders a calm or “nothing urgent” state when visible governance-critical tenant conditions exist.
|
||||
- Zero-tenant and low-permission states remain clearly distinct from healthy calmness, zero-tenant recovery defaults to `Switch workspace`, and permission-limited recovery defaults to `Open operations` unless a more specific allowed action exists.
|
||||
|
||||
### 5. Format touched files
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- All changed implementation files conform to project formatting rules.
|
||||
|
||||
### 6. Run the final focused verification pack
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewLandingTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- The formatted implementation preserves landing, authorization, content, empty-state, operations, governance-attention, stat-separation, and drill-through contracts for the workspace home.
|
||||
|
||||
## Manual Smoke Check
|
||||
|
||||
1. Open `/admin` for a workspace where one visible tenant has overdue findings but operations are quiet.
|
||||
2. Confirm the workspace home does not read as calm and identifies the tenant explicitly.
|
||||
3. Open `/admin` for a workspace where a visible tenant has lapsed governance or stale, failed, or materially degraded compare posture and confirm that tenant is prioritized above activity-only items.
|
||||
4. Click a governance attention item and confirm the destination reproduces the same tenant problem family.
|
||||
5. Verify that the summary strip clearly distinguishes tenant risk from active operations.
|
||||
6. Switch to a healthy workspace and confirm calm wording only appears when both governance and activity domains are truly calm within visible scope.
|
||||
7. Sign in as the zero-tenant workspace member and confirm the workspace does not look healthy and instead offers `Switch workspace` as the next action.
|
||||
8. Sign in as the permission-limited workspace member and confirm visible attention does not expose a clickable dead-end link and the page still presents `Open operations` as the valid workspace-safe next action.
|
||||
|
||||
## Non-Goals For This Slice
|
||||
|
||||
- No database migration.
|
||||
- No new Graph contract or provider workflow.
|
||||
- No full portfolio matrix or posture score.
|
||||
- No new workspace evidence or review aggregate.
|
||||
- No conversion of recent operations into a primary governance queue.
|
||||
105
specs/175-workspace-governance-attention/research.md
Normal file
105
specs/175-workspace-governance-attention/research.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Phase 0 Research: Workspace Governance Attention Foundation
|
||||
|
||||
## Decision: Reuse `WorkspaceOverviewBuilder` as the single orchestration point for workspace governance attention
|
||||
|
||||
**Rationale**: The current workspace home already builds one typed payload through `WorkspaceOverviewBuilder`, and it already applies visible-tenant scoping plus workspace capability checks before computing metrics, attention, and recency. The missing behavior is not a missing builder seam; it is that the builder currently stops at operations and alerts. Extending that existing orchestration point is narrower than creating a second workspace posture service.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new workspace posture builder or presenter layer: rejected because it would duplicate ownership and violate the repo's bias toward few layers.
|
||||
- Push governance promotion into widget-local queries: rejected because it would spread truth ownership across multiple workspace widgets.
|
||||
|
||||
## Decision: Reuse `TenantGovernanceAggregateResolver` and `BaselineCompareStats` per visible tenant instead of inventing workspace-local governance logic
|
||||
|
||||
**Rationale**: The tenant dashboard already uses `TenantGovernanceAggregate`, `BaselineCompareStats`, and `BaselineCompareSummaryAssessor` to answer exactly the governance questions Spec 175 wants to surface at workspace level: overdue findings, lapsed governance, expiring governance, high-severity active findings, and stale, failed, or materially degraded compare posture. Workspace attention should promote that existing tenant truth, not reinterpret it with a weaker query path.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a workspace-only aggregation query for overdue, lapsed, and compare states: rejected because it would split truth ownership away from the existing tenant governance path.
|
||||
- Create a persisted workspace governance summary: rejected because the spec explicitly forbids new persistence and the problem is request-time semantics, not lifecycle truth.
|
||||
|
||||
## Decision: Count governance risk by affected tenant, not by raw issue total, in workspace summary metrics
|
||||
|
||||
**Rationale**: The workspace question is “which tenants need attention” rather than “how many findings exist.” Counting visible tenants with overdue findings, lapsed governance, or stale, failed, or materially degraded compare posture gives the operator a portfolio answer and avoids one noisy tenant dominating the workspace strip.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Show raw totals for overdue findings or lapsed exceptions across the whole workspace: rejected because it weakens triage value and makes the summary less tenant-actionable.
|
||||
- Keep the existing single `needs attention` count derived from operations and alerts only: rejected because it preserves the false-calm problem.
|
||||
|
||||
## Decision: Rank governance-critical tenant states above activity-only and alert-delivery-only items
|
||||
|
||||
**Rationale**: The spec explicitly requires governance-critical states such as lapsed governance, overdue findings, high-severity active findings, and stale, failed, or materially degraded compare posture to outrank active operations and alert-delivery failures. Workspace attention should still include operations or alerts, but only after governance-critical tenant problems have had a chance to surface.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep current recency-first ordering with governance added lower down: rejected because it still lets busy but healthier workspaces look more urgent than risky but quiet ones.
|
||||
- Remove operations and alerts entirely: rejected because they remain useful as supporting attention once governance-critical signals are represented.
|
||||
|
||||
## Decision: Make every workspace attention item tenant-identifiable with one primary destination
|
||||
|
||||
**Rationale**: The current workspace attention items are generic operations or alert items. Spec 175 requires tenant identity, problem family, relevance, and next jump. A bounded item contract with tenant label plus one primary destination is the narrowest way to satisfy this without turning the workspace home into a matrix or a second diagnostics page.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Continue using generic titles like “operations are still running”: rejected because it does not answer which tenant to open first.
|
||||
- Add multiple buttons per item: rejected because it makes the attention surface noisier and less decisive.
|
||||
|
||||
## Decision: Use RBAC-safe fallback or disabled explanatory states instead of dead-end drill-throughs
|
||||
|
||||
**Rationale**: Some workspace members may be able to see that a tenant has a problem but may not hold the most precise downstream capability, such as findings inspection. The constitution permits visible disabled states for in-scope members. The safest behavior is to choose an allowed fallback such as the tenant dashboard or to render a disabled explanatory state instead of a clickable link that ends in `403`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide the tenant problem entirely when the precise destination is not allowed: rejected because it can make the workspace appear calmer than reality.
|
||||
- Always link to the precise destination and rely on downstream authorization failure: rejected because the spec forbids dead-end drill-throughs.
|
||||
|
||||
## Decision: Default zero-tenant recovery to the existing choose-workspace route
|
||||
|
||||
**Rationale**: The current admin workspace flow already uses `/admin/choose-workspace` as the canonical recovery surface when no usable workspace or tenant context is available. Reusing that existing route keeps the workspace overview honest for zero-tenant members without inventing a new recovery surface or asking them to infer the next step.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Treat zero-tenant state as a healthy calm empty state: rejected because it hides a scope problem as a health signal.
|
||||
- Default zero-tenant recovery to a workspace-management surface: rejected because many members can switch workspace but cannot manage workspace memberships.
|
||||
|
||||
## Decision: Route alert-only follow-up to the existing alerts overview at `/admin/alerts`
|
||||
|
||||
**Rationale**: The app already has an admin alerts overview that serves as the canonical entry point for alert health, rules, and delivery follow-up. Using that route for workspace alert-only attention is narrower and more stable than inventing a new alert-delivery deep-link contract inside this slice.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Send alert-only attention directly to an alert-deliveries list route: rejected because the current operator surface is the alerts overview at `/admin/alerts`.
|
||||
- Drop alert-only follow-up entirely: rejected because alert delivery remains a valid supporting attention family once governance-critical items are represented.
|
||||
|
||||
## Decision: Keep evidence and review promotion opportunistic, not foundational, in this slice
|
||||
|
||||
**Rationale**: The codebase already has tenant evidence and review resources, but the current workspace overview has no evidence or review aggregation. Spec 175 allows evidence or review issues where already available. The narrow implementation is to use those surfaces only when existing tenant truth already provides a clear problem family and a clear target, not to build a new workspace evidence or review subsystem.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Exclude evidence and review entirely: rejected because the spec explicitly leaves room for them where they already provide value.
|
||||
- Build a dedicated workspace evidence or review aggregate now: rejected because it exceeds the foundation scope of the feature.
|
||||
|
||||
## Decision: Keep `WorkspaceRecentOperations` as diagnostic recency, not portfolio posture
|
||||
|
||||
**Rationale**: The current recent-operations widget already serves a clear supporting role and is covered by existing tests that verify visible-tenant filtering and the absence of polling. The spec does not require changing its query model; it requires preventing that surface from being mistaken for the workspace's main truth surface.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Expand recent operations into the primary attention owner: rejected because it would keep the workspace entry point operations-centered.
|
||||
- Remove recent operations from the overview: rejected because it still provides useful context when subordinated to governance attention.
|
||||
|
||||
## Decision: Treat workspace calmness as a checked-domain claim instead of an operations-only empty state
|
||||
|
||||
**Rationale**: The current `WorkspaceNeedsAttention` empty state says “Nothing urgent in your current scope” and explains that recent operations and alert deliveries look healthy. That wording is only valid if the page is consciously limiting its claim to those domains. After governance promotion, calm messaging must be based on both governance and activity signals in visible scope, or else be narrowed explicitly.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep the current empty-state wording and rely on new governance items to suppress it sometimes: rejected because the wording still over-claims portfolio health.
|
||||
- Remove calm messaging entirely: rejected because a truthful all-clear remains useful when the covered domains are genuinely calm.
|
||||
|
||||
## Decision: Lean on request-scoped derived-state caching instead of new persistence for performance
|
||||
|
||||
**Rationale**: `TenantGovernanceAggregateResolver` already uses request-scoped derived-state caching. That makes per-visible-tenant governance resolution viable for the workspace overview without introducing a new summary table or materialized view. This keeps the implementation within the spec's no-new-persistence constraint.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a cached database aggregate or background precompute job: rejected because it introduces durability and lifecycle cost the feature does not need.
|
||||
- Resolve all tenant governance truth from raw ad-hoc queries without using the resolver: rejected because it ignores an existing request-scope caching seam and increases drift risk.
|
||||
|
||||
## Decision: Protect the feature with focused workspace overview regression tests instead of manual review alone
|
||||
|
||||
**Rationale**: The highest-risk failures are semantic: false calmness, wrong tenant prioritization, missing tenant labels, or broken destinations. Existing workspace overview tests already cover access, landing, navigation, permission visibility, empty states, content, and operations slice behavior. Extending that suite with governance attention and drill-through coverage is the narrowest way to keep this contract stable.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rely on manual browser review only: rejected because semantic regressions are subtle and easy to reintroduce.
|
||||
- Build a full browser-based suite first: rejected because focused Pest coverage is already the repo's normal enforcement layer for Filament truth contracts.
|
||||
238
specs/175-workspace-governance-attention/spec.md
Normal file
238
specs/175-workspace-governance-attention/spec.md
Normal file
@ -0,0 +1,238 @@
|
||||
# Feature Specification: Spec 175 - Workspace Governance Attention Foundation
|
||||
|
||||
**Feature Branch**: `175-workspace-governance-attention`
|
||||
**Created**: 2026-04-04
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 175 — Workspace Governance Attention Foundation"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin` as the workspace-level overview where `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and `WorkspaceRecentOperations` establish the first portfolio triage impression
|
||||
- `/admin/choose-workspace` as the default workspace-switch recovery surface for zero-tenant or wrong-workspace states
|
||||
- `/admin/choose-tenant` as the deliberate tenant-entry surface when the operator needs to move from workspace triage into one tenant
|
||||
- `/admin/t/{tenant}` as the tenant dashboard destination for tenant-wide recovery when a workspace attention item needs a broad tenant landing point
|
||||
- `/admin/t/{tenant}/findings` and tenant finding detail routes as the primary findings and governance drill-through destinations
|
||||
- `/admin/t/{tenant}/baseline-compare-landing` as the compare-posture destination for stale, failed, or materially degraded compare states
|
||||
- `/admin/alerts` as the canonical alert overview and delivery follow-up destination for workspace alert issues
|
||||
- `/admin/operations` and canonical operation detail routes as the operations and execution follow-up destinations
|
||||
- `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/reviews` only when an attention item is backed by already-visible evidence or review truth that is more precise than a generic tenant landing
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: workspace context, workspace home composition, workspace-scoped recent operations, and alert-delivery history already summarized on the workspace home
|
||||
- Tenant-owned but workspace-filtered: visible tenant governance state, findings workflow state, evidence and review truth, and tenant-scoped compare posture that may be promoted into workspace attention
|
||||
- This feature introduces no new workspace summary record; workspace attention, calmness, and governance stats remain derived over existing workspace and tenant truth
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required to render `/admin` and all workspace-level overview aggregates
|
||||
- Only tenants that are visible within the current operator's entitled workspace scope may contribute to workspace counts, calmness suppression, or attention items
|
||||
- Alert-related destinations continue to require the existing alert visibility permission, while tenant drill-through destinations continue to require the current tenant-level inspection capability for that surface; baseline compare currently rides on general `tenant.view` rather than a dedicated compare-specific capability
|
||||
- Non-members or out-of-scope actors remain deny-as-not-found, and the workspace home must not leak hidden tenant posture through counts, wording, or drill-through affordances
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: The workspace home remains workspace-scoped even if a tenant was selected earlier in the session. The overview itself must not silently collapse into one tenant. Any drill-through launched from workspace attention or governance stats must preserve the originating tenant and problem family through tenant context or an equivalent pre-applied destination filter.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace counts, calmness claims, priority order, and drill-through destinations must be computed only from visible tenants and visible truth surfaces. Inaccessible tenants must not contribute to `needs attention`, governance-risk stats, or empty-state suppression. If a precise destination is not authorized for the current in-scope user, the workspace surface must use an allowed fallback or remove the clickable affordance instead of hinting at hidden data.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace overview page | Workspace landing page | The page itself is the canonical workspace entry and hosts the embedded triage surfaces | forbidden | quick actions section only | none | `/admin` | none | Active workspace identity stays visible and embedded items must keep tenant labels explicit | Overview | Whether the workspace currently has governance attention and how that differs from raw activity | Singleton landing surface |
|
||||
| Workspace `Needs Attention` | Embedded triage summary | Each attention item opens one matching working surface for the named tenant problem | required | none | none | `/admin` | Existing tenant dashboard, findings, baseline compare, evidence, review, or operation detail destination depending on item type | Tenant name, problem family, and urgency must be visible on the item before navigation | Attention / Attention item | The most important visible tenant problems and where to go next | Multi-destination triage surface |
|
||||
| Workspace summary stats | Embedded status summary / drill-in surface | Each stat opens one matching destination or remains intentionally passive when no actionable set exists | forbidden | none | none | `/admin` | Matching destination for the metric, such as choose tenant, findings, compare, or operations | Workspace identity and a clear split between governance risk and activity volume | Governance attention and Operations | Portfolio risk counts are separated from portfolio activity counts | Mixed metric summary surface |
|
||||
| Workspace recent operations | Embedded diagnostic recency surface | Each operation row or card opens the canonical operation detail | required | none | none | `/admin/operations` | Existing canonical operation detail route | Workspace context plus visible tenant label on each operation keeps the recency list anchored to one tenant when relevant | Operations / Operation | Recent execution context without pretending to be the main portfolio-risk summary | Diagnostic recency surface |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace overview page | Workspace operator | Workspace landing page | Which tenants need attention first, and is this workspace actually calm or only operationally quiet? | Workspace identity, governance-aware summary stats, prioritized attention, and recent activity as supporting context | Deep run metadata, raw compare internals, and low-level workflow detail remain on downstream pages | governance attention, compare posture, findings pressure, evidence or review trust where already available, operations activity | none | Choose tenant, open an attention item destination, open operations | none |
|
||||
| Workspace `Needs Attention` | Workspace operator | Embedded triage summary | Which tenant needs attention now, why, and where should I jump? | Tenant label, problem family, urgency, and one clear next destination | Low-level evidence payloads, verbose status histories, and raw query context remain secondary | governance issue, findings issue, compare issue, evidence or review issue, operations issue | none | Open the matching tenant dashboard, findings list, compare landing, evidence or review surface, or operation detail | none |
|
||||
| Workspace summary stats | Workspace operator | Embedded status summary / drill-in surface | How much of this workspace is active, and how much of it is governance risk? | Small set of trustworthy counts with explicit governance versus activity meaning | Full tenant-by-tenant breakdown and historical trends remain outside the stat strip | governance attention volume, activity volume | none | Open the matching aggregate destination or tenant entry point | none |
|
||||
| Workspace recent operations | Workspace operator | Embedded diagnostic recency surface | What has run recently if I need execution context? | Recent operations with tenant label, status, outcome, and recency | Full operation timeline, payload diagnostics, and longer history remain in operations surfaces | recency, execution status, execution outcome | none | Open the selected operation detail | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No. Existing tenant governance, findings, compare, evidence, review, operations, and alert truth remain authoritative.
|
||||
- **New persisted entity/table/artifact?**: No. This feature explicitly avoids a new workspace posture record or aggregate table.
|
||||
- **New abstraction?**: No. The narrow change is to tighten workspace aggregation and presentation semantics on existing surfaces.
|
||||
- **New enum/state/reason family?**: No. Existing governance, compare, findings, and operations state families remain authoritative.
|
||||
- **New cross-domain UI framework/taxonomy?**: No. The feature aligns existing workspace and tenant surfaces without creating a new posture framework or score system.
|
||||
- **Current operator problem**: The workspace home can look calm while visible tenants already have governance-critical conditions, which makes the portfolio entry point weaker than the tenant truth operators already rely on.
|
||||
- **Existing structure is insufficient because**: The current workspace overview summarizes operations and alert activity well enough, but it does not promote tenant governance truth strongly enough to stop false calmness or answer which tenant to open first.
|
||||
- **Narrowest correct implementation**: Reuse existing tenant governance, findings, compare, and already-available evidence or review truth to harden workspace stats, attention, and empty-state semantics on the current workspace home.
|
||||
- **Ownership cost**: The repo takes on stricter workspace aggregation rules, a modest amount of copy tightening, and regression coverage for priority order, calmness suppression, and drill-through continuity.
|
||||
- **Alternative intentionally rejected**: A full portfolio matrix, a workspace posture score, a major workspace redesign, or a new persisted posture model was rejected because the immediate gap is truth propagation on the existing workspace entry point.
|
||||
- **Release truth**: Current-release truth. The semantic risk already exists on the shipped workspace overview.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See The Right Tenant First (Priority: P1)
|
||||
|
||||
As a workspace operator, I want the workspace home to surface governance-critical tenants before operational noise so that I can start triage from the riskiest visible tenant instead of from the most recent activity.
|
||||
|
||||
**Why this priority**: The feature only delivers value if the workspace entry point stops feeling quieter than the visible tenant truth.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding multiple visible tenants with mixes of overdue findings, lapsed governance, stale, failed, or materially degraded compare posture, high-severity active findings, alert failures, and active operations, then verifying that workspace attention promotes governance-critical tenants first.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** one visible tenant has lapsed governance and another only has active operations, **When** the operator opens `/admin`, **Then** the governance-critical tenant is surfaced as higher-priority workspace attention.
|
||||
2. **Given** a visible tenant has overdue findings while operations and alerts look healthy, **When** the workspace home loads, **Then** the page still presents governance attention instead of an all-clear impression.
|
||||
3. **Given** a visible tenant has stale, failed, or materially degraded compare posture but little recent activity, **When** the workspace home loads, **Then** compare risk remains visible on the workspace entry point.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Distinguish Risk From Activity (Priority: P1)
|
||||
|
||||
As a workspace operator, I want the workspace home to separate governance risk from operations activity so that I can tell whether the portfolio is risky, merely busy, or genuinely calm.
|
||||
|
||||
**Why this priority**: A workspace overview that mixes posture and activity cannot support MSP triage with confidence.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the workspace home in scenarios that contain only activity, only governance risk, both, or neither, then verifying that stats, attention, and empty states describe those cases differently.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the workspace has active operations but no visible governance-critical tenant states, **When** the overview renders, **Then** activity appears without governance-risk wording.
|
||||
2. **Given** the workspace has no failed runs or alert failures but visible overdue findings exist, **When** the overview renders, **Then** the page does not present a calm empty state.
|
||||
3. **Given** the workspace has no visible governance-critical states and no unusual operations issues, **When** the overview renders, **Then** calm messaging may appear and must match the covered truth domains.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Jump Into The Right Tenant Surface (Priority: P2)
|
||||
|
||||
As a workspace operator, I want each attention item to carry tenant identity and a trustworthy next jump so that I can recover the same problem family immediately on a tenant-level working surface.
|
||||
|
||||
**Why this priority**: Workspace attention is only trustworthy if the operator can click it and rediscover the same tenant problem without guesswork.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding representative workspace attention cases and verifying that each item names the tenant, names the problem family, and lands on the matching tenant dashboard, findings, compare, evidence, review, or operation destination.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace attention item represents overdue findings for one visible tenant, **When** the operator opens it, **Then** the destination is that tenant's findings surface or a precise allowed fallback that preserves the same problem meaning.
|
||||
2. **Given** a workspace attention item represents stale, failed, or materially degraded compare posture, **When** the operator opens it, **Then** the destination is the same tenant's baseline compare landing.
|
||||
3. **Given** the operator can see a tenant on the workspace home but lacks a precise downstream capability, **When** the attention surface renders, **Then** it uses an allowed fallback or a disabled non-clickable state instead of a dead-end drill-through.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A single visible tenant may have multiple governance-critical conditions at the same time; the workspace home must stay bounded while still exposing the tenant identity and the highest-priority next action.
|
||||
- A workspace may be operationally quiet while one or more visible tenants still have overdue findings, lapsed governance, high-severity active findings, or stale, failed, or materially degraded compare posture; calm messaging must stay suppressed in that case.
|
||||
- A workspace may be busy but healthy from a governance perspective; governance-risk stats and attention must remain calm even when recent operations are active.
|
||||
- Some visible tenants may contribute only through a broader tenant dashboard drill-through because the operator lacks a more specific downstream capability; the workspace home must not create dead-end links or authorization surprises.
|
||||
- A user may belong to the workspace but have zero accessible tenants; the zero-tenant state must remain distinct from a healthy calm state and must still provide one valid next step.
|
||||
- Evidence or review truth may already indicate attention for a visible tenant even when operations are quiet; if that truth is already surfaced on tenant-level pages, the workspace home should be able to promote it without inventing a parallel status language.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call, no new write workflow, and no new queued or scheduled operation. It hardens the workspace home by promoting already-existing tenant-level truth into existing workspace surfaces.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, no new abstraction layer, no new status family, and no new posture framework. The required change is truthful workspace aggregation over existing tenant truth.
|
||||
|
||||
**Constitution alignment (OPS-UX):** No new `OperationRun` type, progress surface, or execution path is introduced. Existing operations surfaces remain the sole execution-truth surfaces. This slice only changes how the workspace home summarizes and points to those existing operations.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature lives in the admin workspace plane at `/admin` with drill-through into tenant-context or canonical admin destinations. Non-members or out-of-scope actors remain `404`. In-scope members lacking a downstream capability remain governed by existing server-side authorization on that destination. Workspace aggregation must stay capability-safe and tenant-safe: hidden tenants must not influence visible counts or wording, and attention items must not become clickable dead ends. If the semantically correct destination is not authorized, the item may only remain if an allowed fallback still preserves tenant and problem meaning. No destructive action is introduced.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing centralized badge or tone semantics for findings severity, compare posture, and operations status or outcome remain the semantic source. This feature may expose those existing meanings at workspace level, but it must not invent page-local badge vocabularies for calmness, risk, or urgency.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature reuses the existing Filament page and workspace widgets, existing stat cards, and existing shared status primitives. It should harden semantics through aligned copy, ordering, and drill-through meaning rather than through custom local status markup or a new widget family.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must keep `Governance`, `Findings`, `Baseline Compare`, `Evidence`, `Reviews`, `Operations`, and `Needs attention` distinct. Calm wording must not imply portfolio health when the page only proves operational quietness.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The workspace overview remains the canonical workspace landing surface. `WorkspaceNeedsAttention` is the primary workspace triage surface. `WorkspaceSummaryStats` is a supporting summary strip and must separate risk from activity. `WorkspaceRecentOperations` remains diagnostic recency, not posture. Each affected surface must keep one primary inspect or open model and must not add redundant affordances.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin` must stay operator-first. Workspace identity, governance attention, and the distinction between risk and activity must remain visible before any diagnostic detail. Mutation scope remains none for all affected surfaces.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not create a new workspace posture layer, presenter framework, or persisted summary. It should instead align workspace semantics directly on top of existing tenant governance, findings, compare, evidence, review, and operations truth. Tests must protect business consequences such as false calmness, missing tenant labels, or broken drill-through continuity.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `WorkspaceOverview` and its widgets remain inspection and drill-through surfaces with no destructive actions, no empty action groups, and no redundant `View` actions. Embedded widgets are exempt from list-action conventions where they are not tables, but each still needs one primary open model.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add create or edit screens. It refines the existing workspace landing page. Governance attention must stay above recent operations context, and empty states must use specific, bounded wording plus exactly one next step instead of broad health claims.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-175-001**: The workspace home MUST become governance-aware rather than remaining a purely operations and alerts summary.
|
||||
- **FR-175-002**: Workspace attention MUST be able to surface, when present within visible scope, at least lapsed governance, overdue findings, high-severity active findings, and stale, failed, or materially degraded compare posture.
|
||||
- **FR-175-003**: Workspace attention SHOULD also be able to promote already-available evidence or review truth when that truth is more actionable than a generic tenant landing.
|
||||
- **FR-175-004**: Every workspace attention item MUST clearly identify the tenant, the problem family, the urgency or relevance, and one clear next jump.
|
||||
- **FR-175-005**: Workspace attention MUST semantically distinguish governance issues, findings issues, compare posture issues, evidence or review issues, and operations issues rather than flattening them into one generic alert stream.
|
||||
- **FR-175-006**: Governance-critical tenant states MUST rank at least as high as, and ordinarily above, active-operations-only or alert-delivery-only items in workspace attention ordering.
|
||||
- **FR-175-007**: The workspace home MUST NOT appear calmer or healthier than the worst visible tenant condition covered by its workspace attention and summary surfaces.
|
||||
- **FR-175-008**: Calm or empty states on the workspace home MUST only claim calmness across the signal families that were actually checked and MUST NOT imply full portfolio health merely because operations or alerts are quiet.
|
||||
- **FR-175-009**: Workspace summary stats MUST include at least one real governance-risk metric such as tenants needing governance attention, tenants with overdue findings, tenants with lapsed governance, or tenants with stale, failed, or materially degraded compare posture.
|
||||
- **FR-175-010**: Workspace summary stats MUST clearly separate governance attention from activity or volume so an operator can tell whether the portfolio is risky, busy, both, or neither.
|
||||
- **FR-175-011**: Every workspace attention item MUST resolve to one semantically matching destination surface such as the tenant dashboard, tenant findings, tenant baseline compare, tenant evidence, tenant reviews, or canonical operations detail.
|
||||
- **FR-175-012**: When the exact matching destination is not authorized for the current in-scope user, the workspace home MUST use an allowed fallback that preserves tenant and problem meaning or suppress the clickable affordance rather than exposing a dead-end drill-through.
|
||||
- **FR-175-013**: Workspace aggregation MUST reuse existing tenant governance aggregate, findings workflow and governance validity truth, compare assessment, and already-shipped evidence or review truth rather than introducing a weaker parallel workspace-only interpretation.
|
||||
- **FR-175-014**: `WorkspaceRecentOperations` MUST remain an activity and recency surface and MUST NOT be treated as the workspace's primary posture or governance summary.
|
||||
- **FR-175-015**: Zero-tenant and low-permission workspace states MUST remain distinct from calm or healthy portfolio states and MUST still provide one valid next action. Zero-tenant recovery MUST default to `Switch workspace`, and low-permission workspace-state recovery MUST default to `Open operations` unless a more specific allowed in-scope recovery action exists.
|
||||
- **FR-175-016**: The feature MUST be achievable without a new table, new persisted workspace summary model, or heavy pre-aggregated materialized view.
|
||||
- **FR-175-017**: Regression coverage MUST verify governance-attention promotion, priority order, calmness suppression, tenant identification, governance-versus-operations separation, drill-through continuity, RBAC-safe omission or fallback behavior, DB-only query-bounded render behavior, and the absence of schema requirements.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace overview page | `app/Filament/Pages/WorkspaceOverview.php` | Existing page chrome only; no new page-header action required | n/a | n/a | none | One bounded CTA per empty state, aligned to the specific empty condition | n/a | n/a | no new audit behavior | Singleton landing surface whose main job is truthful orientation, not mutation |
|
||||
| `WorkspaceSummaryStats` | `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` | none | Explicit stat click only when the metric has a matching actionable destination | none | none | Zero-value reassurance remains intentionally passive unless a specific next step is required | n/a | n/a | no new audit behavior | Stat meanings must stay honest about whether they represent risk or activity |
|
||||
| `WorkspaceNeedsAttention` | `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | none | One explicit destination or one full-item primary open model per attention item | none | none | Healthy fallback remains read-only reassurance only when no covered attention condition exists | n/a | n/a | no new audit behavior | Multi-destination triage widget; no destructive action and no redundant secondary action model |
|
||||
| `WorkspaceRecentOperations` | `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` | none | Each operation row or card opens canonical operation detail | none | none | Existing empty state remains diagnostic and must not imply broader portfolio health | n/a | n/a | no new audit behavior | Diagnostic recency surface; keeps operations context visible without redefining workspace posture |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Visible tenant governance state**: The already-derived tenant condition that tells whether a visible tenant currently carries overdue findings, lapsed or expiring governance, stale, failed, or materially degraded compare posture, high-severity active findings, or already-surfaced evidence or review attention.
|
||||
- **Workspace governance attention item**: A bounded workspace-level triage item that promotes one visible tenant problem into the workspace home with tenant identity, problem family, urgency, and a next jump.
|
||||
- **Workspace governance metric**: A workspace-level count that describes how many visible tenants currently require governance attention, independent from operations volume.
|
||||
- **Workspace calmness claim**: Any workspace wording or empty state that implies the portfolio is calm; it is only valid when the covered governance and activity signals are genuinely calm within visible scope.
|
||||
- **Workspace drill-through contract**: The semantic promise that a workspace stat or attention item can be rediscovered on the surface it opens without losing tenant identity or problem meaning.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-175-001**: In regression coverage, every seeded workspace scenario containing at least one visible tenant with lapsed governance, overdue findings, high-severity active findings, or stale, failed, or materially degraded compare posture produces at least one governance attention signal on the workspace home and no calm empty state.
|
||||
- **SC-175-002**: In regression coverage, 100% of covered workspace attention items display tenant context, problem-family context, and either a matching destination or an allowed disabled or fallback state when the exact destination is not authorized.
|
||||
- **SC-175-003**: In seeded operator review, an operator can identify within 10 seconds which tenant to open first, why that tenant needs attention, and whether the issue is governance or operations.
|
||||
- **SC-175-004**: In regression coverage, operations-only activity scenarios do not trigger governance-risk wording, and governance-critical tenant scenarios are not ranked beneath pure activity-only or alert-delivery-only items.
|
||||
- **SC-175-005**: The feature ships without a required schema migration, a new persisted workspace posture model, or a required materialized aggregate view.
|
||||
- **SC-175-006**: In regression coverage, a dedicated DB-only workspace overview test proves render-time aggregation stays query-bounded for representative visible-tenant scenarios and does not rely on uncontrolled polling or unbounded per-tenant query fanout.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing tenant governance aggregate, findings workflow state, compare assessment, and already-available evidence or review truth are sufficient to harden the workspace home without inventing a new posture system.
|
||||
- Existing tenant dashboard, findings, baseline compare, evidence, review, and operations surfaces remain the correct downstream destinations for workspace drill-through.
|
||||
- The current workspace overview page structure remains in place for this slice; the work changes truth ordering and meaning, not the overall workspace information architecture.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a full portfolio matrix or tenant grid
|
||||
- Introducing a global workspace posture score, grade, or traffic-light system
|
||||
- Redesigning the entire workspace overview layout
|
||||
- Adding choose-tenant posture annotations
|
||||
- Creating new persistence, materialized views, or a new workspace summary artifact
|
||||
- Extending the work into cross-workspace or platform-wide operator surfaces
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing workspace overview page and workspace widgets
|
||||
- Existing tenant governance truth, compare assessment, findings governance state, and evidence or review trust work
|
||||
- Existing tenant dashboard, findings, compare, evidence, review, and operations destinations used as canonical drill-through surfaces
|
||||
- Existing workspace and tenant RBAC plus capability-safe query scoping
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- **Spec 176 — Workspace Portfolio Posture Surface** for per-tenant posture summaries, risk concentration, and a first true portfolio matrix
|
||||
- **Spec 177 — Choose Tenant Posture Annotation** for posture-aware tenant selection and faster triage from tenant pickers
|
||||
- **Spec 178 — Workspace Portfolio Stats Redesign** for a broader governance-oriented workspace stat model that goes beyond this foundation slice
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Spec 175 is complete when:
|
||||
|
||||
- workspace attention is no longer purely operations-centered,
|
||||
- governance-relevant tenant problems are visible on the workspace home,
|
||||
- workspace calmness no longer reads healthier than the worst visible tenant conditions,
|
||||
- attention items carry tenant identity, problem type, and a meaningful drill-through,
|
||||
- workspace summary stats include at least one real governance-risk metric and clearly separate risk from activity,
|
||||
- and the improvement ships without a new persistence structure or a full workspace redesign.
|
||||
207
specs/175-workspace-governance-attention/tasks.md
Normal file
207
specs/175-workspace-governance-attention/tasks.md
Normal file
@ -0,0 +1,207 @@
|
||||
# Tasks: Workspace Governance Attention Foundation
|
||||
|
||||
**Input**: Design documents from `/specs/175-workspace-governance-attention/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
**Prerequisites**: `/specs/175-workspace-governance-attention/plan.md` (required), `/specs/175-workspace-governance-attention/spec.md` (required for user stories)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo. Use focused workspace overview coverage in `tests/Feature/Filament/WorkspaceOverviewAccessTest.php`, `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewLandingTest.php`, `tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`, `tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`.
|
||||
**Operations**: This feature does not create a new `OperationRun` type or change operations lifecycle ownership. Existing canonical Operations routes remain the only operations destinations involved, and the work here is limited to truthful workspace aggregation and destination continuity.
|
||||
**RBAC**: Preserve workspace membership enforcement on `/admin`, deny-as-not-found `404` for non-members or out-of-scope tenants, capability-safe fallback or disabled states for drill-through items, and visible-tenant-only aggregation.
|
||||
**Operator Surfaces**: `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and `WorkspaceRecentOperations` must stay operator-first, with governance truth above recency context and no dead-end navigation.
|
||||
**Filament UI Action Surfaces**: No destructive actions or redundant inspect affordances are added. `WorkspaceSummaryStats` and `WorkspaceNeedsAttention` remain drill-through summary surfaces, and `WorkspaceRecentOperations` remains a row-open diagnostic surface.
|
||||
**Filament UI UX-001**: No new create, edit, or view pages are introduced. Existing workspace landing layout remains in place while semantics, ordering, and empty-state wording are hardened.
|
||||
**Badges**: Existing badge semantics for findings severity, compare posture, governance validity, operations status, and operations outcome remain authoritative; no new page-local badge vocabulary is introduced.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
|
||||
|
||||
## Phase 1: Setup (Context And Existing Surface Review)
|
||||
|
||||
**Purpose**: Reconfirm the exact workspace overview seams, tenant truth sources, and canonical destinations before changing `/admin` semantics.
|
||||
|
||||
- [X] T001 Review current workspace overview composition in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`
|
||||
- [X] T002 [P] Review existing tenant governance and compare truth sources in `app/Support/Baselines/TenantGovernanceAggregateResolver.php`, `app/Support/Baselines/TenantGovernanceAggregate.php`, `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T003 [P] Review canonical drill-through destinations and current workspace overview regression seams in `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `tests/Feature/Filament/WorkspaceOverview*.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Payload And Continuity Seams)
|
||||
|
||||
**Purpose**: Establish the shared workspace payload and continuity helpers that every user story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Create governance-attention and performance regression scaffolding in `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||
- [X] T005 Extend the shared workspace overview payload to match `specs/175-workspace-governance-attention/contracts/workspace-governance-attention.openapi.yaml` for metric categories, calmness state, zero-tenant recovery, and structured attention destinations in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T006 [P] Prepare canonical findings-subset, alerts-overview, and operations-continuity seams for workspace-originated drill-through in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Alerts.php`, and `app/Support/OperationRunLinks.php`
|
||||
- [X] T007 [P] Add shared authorization, visibility, zero-tenant recovery, and DB-only query-bounded assertions for workspace governance destinations in `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||
|
||||
**Checkpoint**: The builder exposes the shared payload shape, the destination seams are ready, and the new regression files exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See The Right Tenant First (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make `/admin` surface governance-critical tenants ahead of operational noise so the riskiest visible tenant is obvious first.
|
||||
|
||||
**Independent Test**: Seed multiple visible tenants with overdue findings, lapsed governance, expiring governance, stale, failed, or materially degraded compare posture, high-severity active findings, alerts, and operations, then verify governance-critical tenants rank above activity-only and alert-only items and suppress false calmness.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T008 [P] [US1] Add governance-ranking scenarios for overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and alert-only supporting items in `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`
|
||||
- [X] T009 [P] [US1] Add false-calmness and zero-tenant distinctness scenarios for quiet operations but risky governance in `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [US1] Promote visible-tenant governance aggregate states into bounded governance-first attention candidates, including expiring governance, stale, failed, or materially degraded compare posture, and lower-priority alert-only supporting items when they can be attributed to one visible tenant, in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T011 [US1] Render tenant label, problem family, urgency, and governance-first ordering in `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
|
||||
- [X] T012 [US1] Align workspace landing copy and calmness framing so operations-only quiet never implies portfolio health in `app/Filament/Pages/WorkspaceOverview.php` and `resources/views/filament/pages/workspace-overview.blade.php`
|
||||
- [X] T013 [US1] Run focused US1 verification against `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||
|
||||
**Checkpoint**: The workspace home no longer looks calmer than the worst visible tenant governance state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Distinguish Risk From Activity (Priority: P1)
|
||||
|
||||
**Goal**: Make the workspace home clearly separate governance risk from operational activity so the portfolio can be read as risky, busy, both, or calm.
|
||||
|
||||
**Independent Test**: Render `/admin` in governance-only, activity-only, mixed, and healthy scenarios, then verify metrics, attention, and empty states describe those cases differently and truthfully.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T014 [P] [US2] Add governance-risk-versus-activity metric scenarios, including expiring governance and stale, failed, or materially degraded compare posture counts, in `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||
- [X] T015 [P] [US2] Add operations-only, risk-only, mixed, healthy-state, and zero-tenant recovery scenarios in `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` and `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Split workspace summary metrics into scope, governance-risk, activity, and alert categories, preserving expiring governance and stale, failed, or materially degraded compare posture as governance-risk inputs, in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T017 [US2] Update stat-card labels, descriptions, and destination semantics for governance-risk versus activity counts in `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `resources/views/filament/pages/workspace-overview.blade.php`
|
||||
- [X] T018 [US2] Keep recent operations diagnostic-only and remove its ability to define calmness on its own in `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`, and `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T019 [US2] Run focused US2 verification against `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||
|
||||
**Checkpoint**: The summary strip and surrounding copy now distinguish portfolio risk from portfolio activity.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Jump Into The Right Tenant Surface (Priority: P2)
|
||||
|
||||
**Goal**: Make each attention item identify the tenant and open a trustworthy next surface for the same problem family.
|
||||
|
||||
**Independent Test**: Seed representative findings, stale, failed, or materially degraded compare, evidence, review, alert, and operations cases, then verify each workspace attention item preserves tenant identity and reaches the correct destination or a safe fallback or disabled state.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T020 [P] [US3] Add drill-through continuity coverage for tenant dashboard, findings, stale, failed, or materially degraded compare posture, evidence, review, alerts overview, and operations destinations in `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||
- [X] T021 [P] [US3] Add capability-limited fallback, non-clickable state, zero-tenant choose-workspace recovery, and low-permission operations fallback coverage in `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php` and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T022 [US3] Implement per-family primary destination selection with tenant-safe fallback or disabled states, explicit alerts-overview routing, `switch_workspace` as the zero-tenant default next action, `operations_index` as the low-permission workspace-state fallback, aggregate lapsed-governance fallback to the tenant dashboard when findings filters would narrow the invalid-governance family, and tenant-scope authorization checks through `app/Services/Auth/CapabilityResolver.php` alongside `app/Services/Auth/WorkspaceCapabilityResolver.php` in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T023 [US3] Wire primary actions and helper text for workspace attention items across findings, compare, evidence, reviews, alerts, and operations, keeping every promoted item tenant-identified, in `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`, and `app/Filament/Pages/WorkspaceOverview.php`
|
||||
- [X] T024 [US3] Preserve canonical subset continuity for workspace-originated findings and operations drill-throughs, and route aggregate lapsed-governance attention through the tenant dashboard when a findings filter would otherwise narrow the invalid-governance family, in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||
- [X] T025 [US3] Run focused US3 verification against `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
|
||||
**Checkpoint**: Every central attention family now opens the correct tenant surface or a safe, non-deceptive fallback state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish copy alignment, formatting, and the final focused verification pack across all stories.
|
||||
|
||||
- [X] T026 [P] Align final operator copy, urgency labels, disabled helper text, zero-tenant recovery wording, and low-permission `Open operations` fallback wording across `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/pages/workspace-overview.blade.php`, and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
|
||||
- [X] T027 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `resources/views/filament/pages/workspace-overview.blade.php`, `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`, and `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`
|
||||
- [X] T028 Run the final quickstart verification pack from `specs/175-workspace-governance-attention/quickstart.md` against `tests/Feature/Filament/WorkspaceOverviewAccessTest.php`, `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewLandingTest.php`, `tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`, `tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`, `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||
- [X] T029 Run the manual smoke checks in `specs/175-workspace-governance-attention/quickstart.md` for quiet-operations-risky-governance, stale or failed compare posture, healthy workspace, zero-tenant recovery, and permission-limited member scenarios
|
||||
- [X] T030 Fix the low-permission workspace operations fallback so workspace-originated drill-through clears tenant context and immediately shows workspace-wide follow-up in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Support/OperationRunLinks.php`, `app/Filament/Pages/Monitoring/Operations.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and stays independently testable, though it overlaps with the same workspace builder and page shell.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 because the destination contract is clearer once governance promotion and metric separation are in place.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: First deliverable and recommended MVP. No dependency on other user stories after Foundational work.
|
||||
- **User Story 2 (P1)**: Can start after Foundational completion and remains independently testable, though it shares the builder and page shell with US1.
|
||||
- **User Story 3 (P2)**: Can start after Foundational completion and is best delivered after US1 and US2 because it hardens the attention items already introduced there.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Story tests should be added before or alongside implementation and must fail before the story is considered complete.
|
||||
- Builder changes should land before widget and page copy refinements that depend on the new payload.
|
||||
- Destination continuity changes should land before story-level verification runs.
|
||||
- Story-level verification should complete before moving on to polish.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Setup review tasks `T002` and `T003` can run in parallel.
|
||||
- In Foundational work, `T006` and `T007` can run in parallel after `T005` defines the payload contract.
|
||||
- In US1, `T008` and `T009` can run in parallel.
|
||||
- In US2, `T014` and `T015` can run in parallel.
|
||||
- In US3, `T020` and `T021` can run in parallel.
|
||||
- In Phase 6, `T026` can run while the final verification command set for `T028` is being prepared.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch US1 tests in parallel:
|
||||
T008 tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||
T009 tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php + tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch US2 test work in parallel:
|
||||
T014 tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php + tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||
T015 tests/Feature/Filament/WorkspaceOverviewOperationsTest.php + tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch US3 drill-through and fallback tests in parallel:
|
||||
T020 tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||
T021 tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php + tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate that `/admin` no longer emits a false calm signal when visible tenant governance issues exist.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to make the workspace home governance-aware and suppress false calmness.
|
||||
2. Add US2 to separate governance risk from activity and protect calmness semantics.
|
||||
3. Add US3 to harden drill-through continuity and capability-safe fallbacks.
|
||||
4. Finish with copy alignment, formatting, the quickstart verification pack, and manual smoke checks.
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = Phases 1 through 3 only.
|
||||
|
||||
---
|
||||
|
||||
## Format Validation
|
||||
|
||||
- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`.
|
||||
- Setup, Foundational, and Polish phases intentionally omit story labels.
|
||||
- User story phases use `[US1]`, `[US2]`, and `[US3]` labels.
|
||||
- Parallel markers are used only on tasks that can proceed independently without conflicting incomplete prerequisites.
|
||||
34
specs/176-backup-quality-truth/checklists/requirements.md
Normal file
34
specs/176-backup-quality-truth/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Backup Quality Truth Surfaces
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-04-07. The spec keeps solution details out of the behavior sections; the only structural references are the mandatory surface-identification fields required by this repository's constitution and spec template.
|
||||
@ -0,0 +1,498 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Backup Quality Truth Surface Contracts
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Internal reference contract for backup-quality truth surfaces. The application
|
||||
continues to return rendered HTML through Filament and Livewire. The vendor
|
||||
media types below document the structured list, detail, and selection models
|
||||
that must be derivable before rendering. This is not a public API commitment.
|
||||
paths:
|
||||
/admin/t/{tenant}/backup-sets:
|
||||
get:
|
||||
summary: Backup-set list surface
|
||||
description: >-
|
||||
Returns the rendered backup-set list page. The vendor media type documents
|
||||
the quality summary model that each visible row must expose.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered backup-set list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.backup-set-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSetCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks backup or version viewing capability
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/backup-sets/{backupSet}:
|
||||
get:
|
||||
summary: Backup-set detail surface
|
||||
description: >-
|
||||
Returns the rendered backup-set detail page. The vendor media type documents
|
||||
the summary-first quality model and the related per-item quality rows.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: backupSet
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered backup-set detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.backup-set-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSetDetailSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks required capability for a linked maintenance action
|
||||
'404':
|
||||
description: Backup set is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/policy-versions:
|
||||
get:
|
||||
summary: Policy-version list surface
|
||||
description: >-
|
||||
Returns the rendered policy-version list page. The vendor media type documents
|
||||
the snapshot mode and backup-quality model that each row must expose.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered policy-version list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.policy-version-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PolicyVersionCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks policy-version viewing capability
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/policy-versions/{policyVersion}:
|
||||
get:
|
||||
summary: Policy-version detail surface
|
||||
description: >-
|
||||
Returns the rendered policy-version detail page. The vendor media type documents
|
||||
the explicit backup-quality model that must be available before rendering.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: policyVersion
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered policy-version detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.policy-version-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PolicyVersionDetailSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks capability for a linked mutation action
|
||||
'404':
|
||||
description: Policy version is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/restore-runs/create:
|
||||
get:
|
||||
summary: Restore selection surface with backup-quality hints
|
||||
description: >-
|
||||
Returns the rendered restore wizard. The vendor media type documents the
|
||||
selection-stage backup-quality hints that must appear before risk checks.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: backup_set_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered restore wizard page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.restore-selection-quality+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreSelectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks restore capability
|
||||
'404':
|
||||
description: Restore surface is not visible because workspace or tenant membership is missing
|
||||
components:
|
||||
schemas:
|
||||
BackupSetCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupSetRow'
|
||||
BackupSetRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- lifecycleStatus
|
||||
- itemCount
|
||||
- qualitySummary
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
lifecycleStatus:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
itemCount:
|
||||
type: integer
|
||||
capturedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
qualitySummary:
|
||||
$ref: '#/components/schemas/QualitySummary'
|
||||
BackupSetDetailSurface:
|
||||
type: object
|
||||
required:
|
||||
- header
|
||||
- qualitySummary
|
||||
- itemRows
|
||||
properties:
|
||||
header:
|
||||
$ref: '#/components/schemas/BackupSetHeader'
|
||||
qualitySummary:
|
||||
$ref: '#/components/schemas/QualitySummary'
|
||||
itemRows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupItemQualityRow'
|
||||
positiveClaimBoundary:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
BackupSetHeader:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- lifecycleStatus
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
lifecycleStatus:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
archived:
|
||||
type: boolean
|
||||
itemCount:
|
||||
type: integer
|
||||
BackupItemQualityRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- policyType
|
||||
- snapshotCompleteness
|
||||
- assignmentCapture
|
||||
- hasDegradations
|
||||
- summaryMessage
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
policyType:
|
||||
type: string
|
||||
platform:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
versionNumber:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
snapshotCompleteness:
|
||||
$ref: '#/components/schemas/SnapshotCompleteness'
|
||||
assignmentCapture:
|
||||
$ref: '#/components/schemas/AssignmentCapture'
|
||||
integrityWarning:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
hasDegradations:
|
||||
type: boolean
|
||||
degradationFamilies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summaryMessage:
|
||||
type: string
|
||||
nextAction:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
PolicyVersionCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PolicyVersionRow'
|
||||
PolicyVersionRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- versionNumber
|
||||
- snapshotCompleteness
|
||||
- hasDegradations
|
||||
- summaryMessage
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
versionNumber:
|
||||
type: integer
|
||||
capturedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
snapshotCompleteness:
|
||||
$ref: '#/components/schemas/SnapshotCompleteness'
|
||||
assignmentCapture:
|
||||
$ref: '#/components/schemas/AssignmentCapture'
|
||||
integrityWarning:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
hasDegradations:
|
||||
type: boolean
|
||||
degradationFamilies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summaryMessage:
|
||||
type: string
|
||||
PolicyVersionDetailSurface:
|
||||
type: object
|
||||
required:
|
||||
- header
|
||||
- qualityFact
|
||||
- positiveClaimBoundary
|
||||
properties:
|
||||
header:
|
||||
$ref: '#/components/schemas/PolicyVersionHeader'
|
||||
qualityFact:
|
||||
$ref: '#/components/schemas/QualityFact'
|
||||
positiveClaimBoundary:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
PolicyVersionHeader:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- versionNumber
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
versionNumber:
|
||||
type: integer
|
||||
capturedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
RestoreSelectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- backupSetOptions
|
||||
- positiveClaimBoundary
|
||||
properties:
|
||||
backupSetOptions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RestoreBackupSetOption'
|
||||
itemOptions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RestoreBackupItemOption'
|
||||
positiveClaimBoundary:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
RestoreBackupSetOption:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- qualitySummary
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
qualitySummary:
|
||||
$ref: '#/components/schemas/QualitySummary'
|
||||
RestoreBackupItemOption:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- qualityFact
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
qualityFact:
|
||||
$ref: '#/components/schemas/QualityFact'
|
||||
QualitySummary:
|
||||
type: object
|
||||
required:
|
||||
- hasDegradations
|
||||
- degradedItemCount
|
||||
- metadataOnlyCount
|
||||
- assignmentIssueCount
|
||||
- orphanedAssignmentCount
|
||||
- summaryLabel
|
||||
properties:
|
||||
hasDegradations:
|
||||
type: boolean
|
||||
degradedItemCount:
|
||||
type: integer
|
||||
metadataOnlyCount:
|
||||
type: integer
|
||||
assignmentIssueCount:
|
||||
type: integer
|
||||
orphanedAssignmentCount:
|
||||
type: integer
|
||||
integrityWarningCount:
|
||||
type: integer
|
||||
unknownQualityCount:
|
||||
type: integer
|
||||
degradationFamilies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summaryLabel:
|
||||
type: string
|
||||
nextAction:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
QualityFact:
|
||||
type: object
|
||||
required:
|
||||
- snapshotCompleteness
|
||||
- assignmentCapture
|
||||
- hasDegradations
|
||||
- summaryMessage
|
||||
properties:
|
||||
snapshotCompleteness:
|
||||
$ref: '#/components/schemas/SnapshotCompleteness'
|
||||
assignmentCapture:
|
||||
$ref: '#/components/schemas/AssignmentCapture'
|
||||
integrityWarning:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
hasDegradations:
|
||||
type: boolean
|
||||
degradationFamilies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summaryMessage:
|
||||
type: string
|
||||
nextAction:
|
||||
$ref: '#/components/schemas/Fact'
|
||||
SnapshotCompleteness:
|
||||
type: object
|
||||
required:
|
||||
- mode
|
||||
- badgeLabel
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum:
|
||||
- full
|
||||
- metadata_only
|
||||
- unknown
|
||||
badgeLabel:
|
||||
type: string
|
||||
sourceSignal:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
AssignmentCapture:
|
||||
type: object
|
||||
required:
|
||||
- issuePresent
|
||||
- orphanedAssignments
|
||||
properties:
|
||||
issuePresent:
|
||||
type: boolean
|
||||
fetchFailed:
|
||||
type: boolean
|
||||
captureReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
orphanedAssignments:
|
||||
type: boolean
|
||||
assignmentCount:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
Fact:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
247
specs/176-backup-quality-truth/data-model.md
Normal file
247
specs/176-backup-quality-truth/data-model.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Data Model: Backup Quality Truth Surfaces
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add or change a top-level persisted domain entity. It introduces a tighter derived backup-quality model around the existing tenant-owned backup, version, and restore-selection surfaces.
|
||||
|
||||
The central design task is to make existing backup truth visible without changing:
|
||||
|
||||
- `BackupSet`, `BackupItem`, or `PolicyVersion` ownership
|
||||
- existing backup or restore route identity
|
||||
- existing restore-safety, preview, and execution authority
|
||||
- existing audit and RBAC responsibilities
|
||||
- the no-new-table boundary of this feature
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### 1. BackupSet
|
||||
|
||||
- Purpose: Tenant-owned backup collection that records lifecycle state and groups captured backup items.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `name`
|
||||
- `status`
|
||||
- `item_count`
|
||||
- `metadata`
|
||||
- `created_by`
|
||||
- `completed_at`
|
||||
- `created_at`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `items`
|
||||
- `restoreRuns`
|
||||
|
||||
#### Proposed nested metadata additions
|
||||
|
||||
None. Backup-set quality is derived from related backup items and existing set facts. No new backup-set status or metadata field is required.
|
||||
|
||||
### 2. BackupItem
|
||||
|
||||
- Purpose: Tenant-owned captured recovery input for one backed-up policy or foundation record.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `backup_set_id`
|
||||
- `policy_id`
|
||||
- `policy_version_id`
|
||||
- `policy_identifier`
|
||||
- `policy_type`
|
||||
- `platform`
|
||||
- `payload`
|
||||
- `assignments`
|
||||
- `metadata`
|
||||
- `captured_at`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `backupSet`
|
||||
- `policy`
|
||||
- `policyVersion`
|
||||
|
||||
#### Existing metadata signals used by this feature
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `source` | string or null | Primary source marker; may be `metadata_only` |
|
||||
| `snapshot_source` | string or null | Copied source marker from a linked policy version when a backup item is created from a version |
|
||||
| `warnings` | array<string> | Warning messages; may include metadata-only fallback wording |
|
||||
| `assignments_fetch_failed` | boolean | Assignment capture failed for this item |
|
||||
| `assignment_capture_reason` | string or null | Informational reason such as `separate_role_assignments`; not all reasons are degradations |
|
||||
| `has_orphaned_assignments` | boolean | One or more resolved assignment targets were orphaned |
|
||||
| `assignment_count` | integer or null | Captured assignment count |
|
||||
| `scope_tag_ids` | array<int|string> | Captured scope-tag identifiers |
|
||||
| `scope_tag_names` | array<string> | Captured scope-tag names |
|
||||
| `integrity_warning` | string or null | Existing integrity or redaction warning copied into the backup item |
|
||||
| `protected_paths_count` | integer or null | Count of protected or redacted paths copied from the policy version context |
|
||||
|
||||
### 3. PolicyVersion
|
||||
|
||||
- Purpose: Tenant-owned immutable version record for a policy snapshot.
|
||||
- Existing persistent fields used by this feature:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `policy_id`
|
||||
- `version_number`
|
||||
- `snapshot`
|
||||
- `metadata`
|
||||
- `assignments`
|
||||
- `scope_tags`
|
||||
- `secret_fingerprints`
|
||||
- `redaction_version`
|
||||
- `captured_at`
|
||||
- `capture_purpose`
|
||||
- Existing relationships used by this feature:
|
||||
- `tenant`
|
||||
- `policy`
|
||||
- `operationRun`
|
||||
|
||||
#### Existing metadata signals used by this feature
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `source` | string or null | Snapshot source marker; `metadata_only` is the primary degraded completeness signal |
|
||||
| `warnings` | array<string> | Snapshot warnings; may include metadata-only fallback language |
|
||||
| `assignments_fetch_failed` | boolean | Assignment capture failed during version capture |
|
||||
| `assignments_fetch_error` | string or null | Human-readable assignment capture error |
|
||||
| `assignments_fetch_error_code` | int or string or null | Technical assignment capture error code |
|
||||
| `has_orphaned_assignments` | boolean | One or more captured assignment targets were orphaned |
|
||||
| `capture_source` | string or null | Existing capture context such as `version_capture` |
|
||||
|
||||
#### Related persisted integrity context used by this feature
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `secret_fingerprints` | array | Existing redaction context used to expose integrity notes on version-derived restore inputs |
|
||||
| `redaction_version` | integer | Existing redaction version for operator diagnostics |
|
||||
| `scope_tags` | array | Existing scope-tag context surfaced alongside quality truth where useful |
|
||||
|
||||
### 4. Restore selection context
|
||||
|
||||
- Purpose: Existing wizard state that lets operators choose a backup set and optional backup-item subset before running risk checks.
|
||||
- Existing state used by this feature:
|
||||
- `backup_set_id`
|
||||
- `scope_mode`
|
||||
- `backup_item_ids`
|
||||
- `group_mapping`
|
||||
- `is_dry_run`
|
||||
|
||||
No new persisted restore-selection state is planned. This feature only enriches the current rendered option models.
|
||||
|
||||
## Derived Models
|
||||
|
||||
### 1. SnapshotCompletenessFact
|
||||
|
||||
Derived completeness truth shared by backup items and policy versions.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | metadata-derived | `full`, `metadata_only`, or `unknown` |
|
||||
| `sourceSignal` | string or null | `metadata.source` or `metadata.snapshot_source` | Authoritative direct signal when present |
|
||||
| `warningEvidence` | list<string> | `metadata.warnings` | Secondary fallback signal |
|
||||
| `badgeState` | string | derived | Routes to the existing `PolicySnapshotModeBadge` state |
|
||||
|
||||
Rules:
|
||||
|
||||
- `metadata_only` when `source` or `snapshot_source` equals `metadata_only`, or when warning evidence clearly states metadata-only capture.
|
||||
- `full` only when there is no metadata-only evidence and the record contains enough captured payload context to justify a complete-snapshot claim.
|
||||
- `unknown` only when existing metadata cannot prove either `full` or `metadata_only`.
|
||||
|
||||
### 2. AssignmentCaptureFact
|
||||
|
||||
Derived assignment-quality truth for backup items and policy versions.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `fetchFailed` | boolean | `assignments_fetch_failed` | Primary degraded assignment signal |
|
||||
| `captureReason` | string or null | `assignment_capture_reason` | Informational reason; not always degraded |
|
||||
| `orphanedAssignments` | boolean | `has_orphaned_assignments` | Secondary degraded signal |
|
||||
| `assignmentCount` | integer or null | `assignment_count` or `assignments` length | Informational support data |
|
||||
| `issuePresent` | boolean | derived | True when fetch failed or orphaned targets exist |
|
||||
|
||||
Rules:
|
||||
|
||||
- `assignment_capture_reason = separate_role_assignments` is informative and must not be misread as a failure on its own.
|
||||
- `fetchFailed = true` is a degraded quality signal.
|
||||
- `orphanedAssignments = true` is a degraded quality signal even if fetch succeeded.
|
||||
|
||||
### 3. BackupItemQualityFact
|
||||
|
||||
Default item-level backup-quality model for backup items.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `backupItemId` | integer | record id | Identity |
|
||||
| `snapshotCompleteness` | `SnapshotCompletenessFact` | derived | Primary completeness truth |
|
||||
| `assignmentCapture` | `AssignmentCaptureFact` | derived | Assignment quality truth |
|
||||
| `integrityWarning` | string or null | `metadata.integrity_warning` | Existing integrity signal |
|
||||
| `degradationFamilies` | list<string> | derived | Examples: `metadata_only`, `assignment_capture_issue`, `orphaned_assignments`, `integrity_warning`, `unknown_quality` |
|
||||
| `hasDegradations` | boolean | derived | True when one or more degradation families apply |
|
||||
| `summaryMessage` | string | derived | Concise operator-facing truth |
|
||||
| `nextAction` | string | derived | Primary next step such as inspect detail or continue with caution |
|
||||
|
||||
### 4. BackupSetQualitySummary
|
||||
|
||||
Aggregate backup-quality truth for one backup set.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `backupSetId` | integer | record id | Identity |
|
||||
| `totalItems` | integer | `item_count` or related count | Informational total |
|
||||
| `degradedItemCount` | integer | aggregated item facts | Number of degraded items |
|
||||
| `metadataOnlyCount` | integer | aggregated item facts | Count of metadata-only items |
|
||||
| `assignmentIssueCount` | integer | aggregated item facts | Count of assignment capture failures |
|
||||
| `orphanedAssignmentCount` | integer | aggregated item facts | Count of orphaned-assignment signals |
|
||||
| `integrityWarningCount` | integer | aggregated item facts | Count of integrity warnings carried into backup items |
|
||||
| `unknownQualityCount` | integer | aggregated item facts | Count of items whose quality is truly unknown |
|
||||
| `degradationFamilies` | list<string> | derived | Set-level union of degradation families |
|
||||
| `summaryMessage` | string | derived | Compact summary for list and detail |
|
||||
| `nextAction` | string | derived | Open detail, inspect degraded items, prefer stronger version, or continue with caution |
|
||||
| `positiveClaimBoundary` | string | derived | Explains that quality does not equal safe restore or tenant recoverability |
|
||||
|
||||
Rules:
|
||||
|
||||
- Aggregate counts are computed from related `BackupItemQualityFact` values, never from `BackupSet.status`.
|
||||
- `completed but degraded` remains a display combination of lifecycle plus quality summary, not a new persisted backup-set status.
|
||||
|
||||
### 5. PolicyVersionQualityFact
|
||||
|
||||
Version-level backup-quality truth for policy versions.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `policyVersionId` | integer | record id | Identity |
|
||||
| `snapshotCompleteness` | `SnapshotCompletenessFact` | derived from version metadata | Primary completeness truth |
|
||||
| `assignmentCapture` | `AssignmentCaptureFact` | derived from version metadata and assignments | Assignment quality truth |
|
||||
| `integrityWarning` | string or null | derived from existing redaction or integrity context | Existing warning already present in current restore and version flows |
|
||||
| `degradationFamilies` | list<string> | derived | Same family as backup items where applicable |
|
||||
| `hasDegradations` | boolean | derived | True when one or more degradation families apply |
|
||||
| `summaryMessage` | string | derived | Concise operator-facing truth |
|
||||
| `nextAction` | string | derived | Prefer stronger version, inspect raw settings, or continue to restore with caution |
|
||||
|
||||
### 6. RestoreSelectionQualityHint
|
||||
|
||||
Selection-stage quality model for restore wizard step 1 and step 2.
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `targetType` | string | derived | `backup_set` or `backup_item` |
|
||||
| `targetId` | integer | selected record id | Identity |
|
||||
| `summaryMessage` | string | derived | Early warning before risk checks |
|
||||
| `degradationFamilies` | list<string> | derived | Carries through set-level or item-level truth |
|
||||
| `nextAction` | string | derived | Inspect detail or continue with caution |
|
||||
| `positiveClaimBoundary` | string | derived | Explicitly states that input quality is not restore safety |
|
||||
|
||||
Rules:
|
||||
|
||||
- Step 1 uses `BackupSetQualitySummary` facts.
|
||||
- Step 2 uses `BackupItemQualityFact` facts.
|
||||
- Neither step may claim `safe to restore`, `restore ready`, or `tenant recoverable`.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Never derive backup quality from `BackupSet.status`, `PolicyVersion` action availability, or restore gating alone.
|
||||
- `assignments_fetch_failed` and `has_orphaned_assignments` are distinct signals and must be surfaced separately where the UI can support it.
|
||||
- `assignment_capture_reason` is explanatory metadata, not automatically a degraded state.
|
||||
- `unknown quality` is permitted only when current metadata cannot justify `full` or `metadata_only` and cannot justify an assignment-quality claim.
|
||||
- `TENANT_VIEW` visibility for backup-quality truth must remain independent from `TENANT_MANAGE` restore capability.
|
||||
- Restore selection hints must explicitly preserve the claim boundary that backup quality is not restore safety.
|
||||
288
specs/176-backup-quality-truth/plan.md
Normal file
288
specs/176-backup-quality-truth/plan.md
Normal file
@ -0,0 +1,288 @@
|
||||
# Implementation Plan: Backup Quality Truth Surfaces
|
||||
|
||||
**Branch**: `176-backup-quality-truth` | **Date**: 2026-04-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Harden the backup and versioning surfaces so operators can distinguish `stored` from `usable` and `degraded` recovery input before they reach restore-safety or execution surfaces. The implementation keeps `BackupSet`, `BackupItem`, and `PolicyVersion` as the existing sources of truth, introduces only a narrow derived backup-quality layer over current metadata and relationships, aggregates existing metadata-only and assignment-quality signals into summary facts, and hardens backup-set list and detail, backup-item relation, policy-version list and detail, and restore wizard step 1 and step 2 selection seams without adding a new persistence model.
|
||||
|
||||
Key approach: work inside the existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, and `CreateRestoreRun` seams; derive per-item and aggregate quality from existing metadata keys such as `source`, `snapshot_source`, `assignments_fetch_failed`, `assignment_capture_reason`, and `has_orphaned_assignments`; reuse Filament v5 tables, infolists, enterprise-detail builders, and shared badge infrastructure; keep all changes Livewire v4 compliant; avoid new tables, new Graph calls, and new asset registration; validate the result with focused Pest, Livewire, RBAC, and regression coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned
|
||||
**Testing**: Pest feature tests, Livewire page or action tests, unit tests for narrow derived backup-quality helpers, all run through Sail
|
||||
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||
**Project Type**: Laravel monolith web application
|
||||
**Performance Goals**: Keep backup, version, and restore-selection surfaces server-driven and DB-backed at render time, avoid new render-time external calls, preserve fast list scanability, and avoid introducing new N+1 query hotspots while computing quality summaries
|
||||
**Constraints**: No new backup-health table, no new Graph contract path, no new queue or `OperationRun`, no route identity change, no RBAC drift, no conflation of backup quality with restore safety or tenant recoverability, no page-local badge mappings, and no new global Filament assets
|
||||
**Scale/Scope**: One tenant-scoped backup-set list and detail flow, one backup-items relation-manager table, one tenant-scoped policy-version list and detail flow, restore wizard step 1 and step 2 selection surfaces, one narrow derived backup-quality helper layer, and focused regression coverage across truth presentation and RBAC behavior
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | Backups and versions remain immutable snapshot truth; no inventory ownership rule changes |
|
||||
| Read/write separation | Pass | This slice is read-first truth hardening; existing restore and delete flows retain their current confirmations, audits, and tests |
|
||||
| Graph contract path | Pass | No new Graph endpoints, no new Graph calls, and no contract registry changes are introduced |
|
||||
| Deterministic capabilities | Pass | Existing capability registry, `CapabilityResolver`, and `UiEnforcement` remain authoritative |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | All changed surfaces remain tenant-scoped; non-members still get 404, in-scope members without mutation capability still get 403 on execution |
|
||||
| Workspace isolation | Pass | No workspace-scope broadening or cross-workspace visibility changes are planned |
|
||||
| Tenant isolation | Pass | `BackupSet`, `BackupItem`, and `PolicyVersion` stay tenant-owned and tenant-entitled across list, detail, and wizard selection surfaces |
|
||||
| Dangerous and destructive confirmations | Pass | Existing archive, restore, force-delete, and remove actions stay confirmation-gated and server-authorized |
|
||||
| Global search safety | Pass | This feature adds no new globally searchable resource. `PolicyVersionResource` remains non-globally-searchable. `BackupSetResource` already has a view page if current configuration exposes it to search, and this slice adds no new cross-tenant hints |
|
||||
| Run observability | Pass | No new long-running work or `OperationRun` usage is introduced |
|
||||
| Ops-UX 3-surface feedback | Pass | No new operation start, toast, progress, or terminal notification surface is added |
|
||||
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` are untouched |
|
||||
| Ops-UX summary counts | Pass | No new `summary_counts` keys or operation metrics are required |
|
||||
| Data minimization | Pass | The slice reuses existing metadata and keeps diagnostics secondary; no new secret or raw payload exposure is planned |
|
||||
| Proportionality (PROP-001) | Pass | Added logic is limited to a narrow derived backup-quality helper and direct surface integration across existing resources |
|
||||
| Persisted truth (PERSIST-001) | Pass | No new table, column, or stored mirror is introduced; quality remains derived |
|
||||
| Behavioral state (STATE-001) | Pass | Quality distinctions remain derived presentation truth from existing metadata, not new persisted lifecycle state |
|
||||
| Badge semantics (BADGE-001) | Pass | Snapshot-mode rendering continues through `BadgeDomain::PolicySnapshotMode`; any new quality chips or labels stay inside shared badge or copy seams |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament tables, infolists, enterprise-detail cards, and wizard form descriptions remain the primary seams |
|
||||
| UI naming (UI-NAMING-001) | Pass | The plan preserves operator vocabulary such as `metadata-only`, `assignment issues`, `degraded`, `full payload`, and `recovery input`, while avoiding `safe to restore` claims |
|
||||
| Operator surfaces (OPSURF-001) | Pass | Changed surfaces become more operator-first by surfacing quality summary before diagnostics or later restore checks |
|
||||
| Filament Action Surface Contract | Pass | No new inspect model, redundant View action, or empty action group is introduced; action placement remains unchanged |
|
||||
| Filament UX-001 | Pass with documented variance | Backup-set detail continues to use the existing enterprise-detail layout and relation manager, but the plan adds a summary-first quality section before technical detail |
|
||||
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
|
||||
| Provider registration location | Pass | No provider or panel changes; Laravel 11+ registration remains in `bootstrap/providers.php` |
|
||||
| Asset strategy | Pass | No new panel assets are planned; deployment keeps the existing `php artisan filament:assets` step unchanged |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Derive backup quality from existing item and version metadata rather than introducing a persisted backup-health model.
|
||||
- Treat backup lifecycle status and backup quality as separate truths on every affected surface.
|
||||
- Reuse the central snapshot-mode badge and shared badge semantics instead of introducing page-local color or status logic.
|
||||
- Extend the existing backup-set enterprise-detail builder, backup-items relation manager, policy-version resource, and restore wizard descriptions instead of creating a parallel dashboard or UI shell.
|
||||
- Surface backup-set and item quality in restore wizard selection steps before the current restore-safety checks and preview steps, without turning quality hints into safety claims.
|
||||
- Keep quality truth visible for `TENANT_VIEW` users even when restore actions remain unavailable.
|
||||
- Use `unknown quality` only when the existing record does not contain authoritative metadata that can justify a stronger claim.
|
||||
- Extend the existing Pest and Livewire test surfaces rather than creating a new browser-first harness.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/`:
|
||||
|
||||
- `research.md`: design and framework decisions for deriving and surfacing backup quality
|
||||
- `data-model.md`: existing entities, current metadata signals, and narrow derived backup-quality models
|
||||
- `contracts/backup-quality-truth.openapi.yaml`: internal logical contract for backup-set list and detail, backup-item relation rows, policy-version list and detail, and restore wizard selection surfaces
|
||||
- `quickstart.md`: focused automated and manual validation workflow for backup-quality truth hardening
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required; the design derives quality from existing `backup_items.metadata`, `policy_versions.metadata`, relationships, and current restore wizard state.
|
||||
- A narrow derived helper layer is justified because the same quality truth must appear consistently across backup-set list, backup-set detail, backup-items, policy versions, and restore selection surfaces.
|
||||
- Backup-set detail hardening stays inside `BackupSetResource::enterpriseDetailPage()` and existing enterprise-detail cards or sections rather than a new page shell.
|
||||
- Policy-version hardening stays inside the existing table and infolist schema, replacing disabled-action-only signaling with explicit quality truth.
|
||||
- Restore selection hardening stays inside `RestoreRunResource::getWizardSteps()` and `restoreItemOptionData()` so input quality appears before the existing checks and preview steps.
|
||||
- Snapshot mode remains the primary quality badge, while aggregate counts and next-action language stay derived and secondary.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/176-backup-quality-truth/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── backup-quality-truth.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── Resources/
|
||||
│ ├── BackupSetResource.php
|
||||
│ ├── PolicyVersionResource.php
|
||||
│ ├── RestoreRunResource.php
|
||||
│ └── BackupSetResource/
|
||||
│ └── RelationManagers/
|
||||
│ └── BackupItemsRelationManager.php
|
||||
├── Models/
|
||||
│ ├── BackupItem.php
|
||||
│ ├── BackupSet.php
|
||||
│ └── PolicyVersion.php
|
||||
├── Services/
|
||||
│ ├── AssignmentBackupService.php
|
||||
│ └── Intune/
|
||||
│ ├── PolicySnapshotService.php
|
||||
│ ├── RestoreRiskChecker.php
|
||||
│ ├── RestoreService.php
|
||||
│ └── VersionService.php
|
||||
└── Support/
|
||||
├── BackupQuality/
|
||||
│ ├── BackupQualityResolver.php
|
||||
│ └── BackupQualitySummary.php
|
||||
├── Badges/
|
||||
│ └── Domains/
|
||||
│ └── PolicySnapshotModeBadge.php
|
||||
├── Ui/
|
||||
│ └── EnterpriseDetail/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ │ ├── BackupSetUiEnforcementTest.php
|
||||
│ │ ├── BackupSetEnterpriseDetailPageTest.php
|
||||
│ │ ├── BackupItemsRelationManagerFiltersTest.php
|
||||
│ │ ├── BackupQualityTruthSurfaceTest.php
|
||||
│ │ ├── PolicyVersionQualityTruthSurfaceTest.php
|
||||
│ │ ├── PolicyVersionTest.php
|
||||
│ │ ├── PolicyVersionRestoreViaWizardTest.php
|
||||
│ │ ├── RestoreItemSelectionTest.php
|
||||
│ │ └── RestoreSelectionQualityTruthTest.php
|
||||
│ └── Rbac/
|
||||
│ ├── BackupItemsRelationManagerUiEnforcementTest.php
|
||||
│ ├── BackupQualityVisibilityTest.php
|
||||
│ ├── CreateRestoreRunAuthorizationTest.php
|
||||
│ └── PolicyVersionsRestoreToIntuneUiEnforcementTest.php
|
||||
│ └── RestoreRiskChecksWizardTest.php
|
||||
└── Unit/
|
||||
├── Support/
|
||||
│ └── BackupQuality/
|
||||
│ ├── BackupQualityResolverTest.php
|
||||
│ └── BackupSetQualitySummaryTest.php
|
||||
├── AssignmentBackupServiceTest.php
|
||||
└── BackupItemTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The implementation stays inside existing Filament resources, existing models and services that already hold the underlying metadata, and the current test structure. Any new helper types stay under the existing `app/Support/BackupQuality/` namespace as a narrow derived layer shared across backup, version, and restore-selection surfaces.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Introduce Narrow Derived Backup-Quality Facts
|
||||
|
||||
**Goal**: Create one reusable derivation path for backup quality from current metadata without adding a new persistence model.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | New narrow helper(s) under `app/Support/` if needed | Introduce a minimal backup-quality resolver or read-model helper that computes snapshot mode, assignment capture issues, orphaned assignment flags, integrity warnings, aggregate counts, and next-action guidance from existing `BackupItem` and `PolicyVersion` metadata |
|
||||
| A.2 | `app/Models/BackupItem.php` and, only if clearly justified, `app/Models/PolicyVersion.php` | Add small convenience helpers for repeated metadata checks where this reduces duplication without embedding presentation language into the models |
|
||||
| A.3 | `app/Support/Badges/Domains/PolicySnapshotModeBadge.php` and shared copy seams only if needed | Reuse the current snapshot-mode badge as the canonical item or version completeness signal; add no new badge domain unless a shared value cannot be expressed through current badge semantics |
|
||||
|
||||
### Phase B — Harden Backup-Set List And Detail Truth
|
||||
|
||||
**Goal**: Make backup-set surfaces answer `stored versus degraded` before diagnostics or restore intent.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `app/Filament/Resources/BackupSetResource.php` | Add a compact backup-quality summary to the table that stays separate from lifecycle status and uses aggregate degraded counts rather than `status` to imply quality |
|
||||
| B.2 | `app/Filament/Resources/BackupSetResource.php` | Update `enterpriseDetailPage()` to place a quality summary card or section ahead of technical detail, including metadata-only count, assignment issue count, orphaned assignment count, one primary next action, and contextual related links that stay out of the header |
|
||||
| B.3 | `app/Filament/Resources/BackupSetResource.php` query seams | Ensure the list and detail surfaces eager-load or aggregate the needed backup-item quality facts without introducing a new N+1 hotspot |
|
||||
|
||||
### Phase C — Harden Backup-Item And Policy-Version Truth
|
||||
|
||||
**Goal**: Expose item-level and version-level input quality directly where operators inspect captured records.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` | Add per-item snapshot mode, assignment capture issue, and orphaned-assignment truth to the relation table, preserving the current inspect model and action placement |
|
||||
| C.2 | `app/Filament/Resources/PolicyVersionResource.php` | Add explicit snapshot mode or quality columns plus a single empty-state CTA to the policy-version list so metadata-only versions are visible at scan speed |
|
||||
| C.3 | `app/Filament/Resources/PolicyVersionResource.php` | Add an explicit backup-quality section to the policy-version detail infolist so restore availability no longer acts as the only quality signal |
|
||||
| C.4 | `app/Filament/Resources/PolicyVersionResource.php` | Preserve current restore-via-wizard gating and tooltip behavior while making quality truth visible independently from action disablement |
|
||||
|
||||
### Phase D — Harden Restore Selection Entry Points
|
||||
|
||||
**Goal**: Expose weak backup inputs before existing restore-safety checks and preview steps begin.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `app/Filament/Resources/RestoreRunResource.php` | Enrich backup-set option labels or helper copy on wizard step 1 with backup-quality summary facts and degraded counts |
|
||||
| D.2 | `app/Filament/Resources/RestoreRunResource.php` | Enrich `restoreItemOptionData()` so wizard step 2 descriptions include snapshot mode and item-level degradation truth before any risk checks run |
|
||||
| D.3 | `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php` | Preserve the current step order and restore-safety authority, while ensuring backup-quality messaging stops short of `safe to restore` or `recovery guaranteed` language |
|
||||
|
||||
### Phase E — Regression Protection And Focused Verification
|
||||
|
||||
**Goal**: Lock the new truth semantics into automated tests without weakening existing backup or restore behavior.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | Existing and new unit tests under `tests/Unit/Support/` | Add deterministic coverage for item-level quality derivation, aggregate backup-set counts, metadata-only detection, assignment failure mapping, and unknown-quality fallback |
|
||||
| E.2 | `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php` and new backup-set truth tests | Cover list or detail quality summary visibility, mixed-quality aggregation, and summary-first ordering |
|
||||
| E.3 | `tests/Feature/Filament/PolicyVersionTest.php`, `tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php`, and new policy-version truth tests | Cover snapshot mode visibility, explicit detail quality truth, and non-reliance on disabled actions |
|
||||
| E.4 | `tests/Feature/Filament/RestoreItemSelectionTest.php` and new restore-selection truth tests | Cover backup-set quality in step 1 and per-item quality in step 2 before risk checks |
|
||||
| E.5 | RBAC tests under `tests/Feature/Rbac/` | Preserve 404 versus 403 behavior and verify that `TENANT_VIEW` users still see quality truth without restore rights |
|
||||
| E.6 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — Backup quality is derived from existing capture truth, not stored separately
|
||||
|
||||
The current product already records the signals that matter: metadata-only source markers, assignment fetch failures, orphaned assignments, warnings, and integrity hints. The missing piece is a consistent way to aggregate and display them across surfaces.
|
||||
|
||||
### D-002 — Backup lifecycle status and backup quality stay orthogonal
|
||||
|
||||
`completed`, `partial`, and `failed` remain capture-lifecycle truth. Aggregate backup-quality summaries answer whether the captured inputs appear strong or degraded as recovery input. The plan never reuses lifecycle status as a proxy for quality.
|
||||
|
||||
### D-003 — Snapshot completeness stays on the central badge system
|
||||
|
||||
The existing `PolicySnapshotModeBadge` already defines the primary `full` versus `metadata only` language. This slice reuses that badge instead of introducing a second status vocabulary for the same truth.
|
||||
|
||||
### D-004 — Restore selection surfaces expose input quality, not safety approval
|
||||
|
||||
Step 1 and step 2 only need to tell the operator whether the chosen backup set or items look degraded. Restore safety, preview decisions, and execution readiness remain owned by the later steps and existing restore-safety logic.
|
||||
|
||||
### D-005 — RBAC can suppress actions, not truth
|
||||
|
||||
Users with view rights must still see backup-quality truth even when restore entry points or maintenance actions are unavailable. Hiding or muting quality because of missing restore capability would falsify the surface.
|
||||
|
||||
### D-006 — Existing Filament seams are sufficient
|
||||
|
||||
The current enterprise-detail builder, table columns, infolist sections, and checkbox-list descriptions already provide the UI seams this slice needs. A dashboard, custom shell, or new client-side state layer would be disproportionate.
|
||||
|
||||
### D-007 — Unknown quality is an explicit fallback, not the default
|
||||
|
||||
The product should only emit `unknown quality` where current records truly lack authoritative metadata. If existing metadata can justify `metadata-only`, `assignment issue`, or `orphaned assignments`, the surface must say so directly.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Aggregation logic diverges between backup items, policy versions, and restore selection descriptions | High | Medium | Use one narrow derived helper path and cover it with mixed-quality unit and feature tests |
|
||||
| Quality summary introduces N+1 queries or heavy per-row work on backup-set list pages | High | Medium | Preload relations or aggregate counts deliberately and add list-focused regression coverage |
|
||||
| UI wording slips from backup quality into restore safety or tenant recoverability claims | High | Medium | Keep operator copy centralized and test for explicit non-claims on degraded and healthy-looking cases |
|
||||
| Read-only users lose quality visibility because existing restore gating is accidentally reused | High | Medium | Add dedicated RBAC visibility tests for `TENANT_VIEW` members without restore capability |
|
||||
| Metadata-only restore blocking semantics regress because selection hints are coupled too tightly to risk checks | Medium | Medium | Keep restore selection quality read-only and rerun focused restore-safety regression tests alongside the new surface tests |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend existing backup-set, backup-items, policy-version, restore-selection, and RBAC Pest coverage before introducing any new harness.
|
||||
- Add unit tests for the narrow backup-quality helper so metadata-only detection, assignment issue mapping, orphaned-assignment mapping, and aggregate counts remain deterministic.
|
||||
- Add feature tests that prove `completed` and `good backup` are no longer visually conflated on backup-set list and detail surfaces.
|
||||
- Add feature tests that prove metadata-only and assignment-capture issues are visible on backup items and policy versions without relying on disabled actions or late restore checks.
|
||||
- Add feature tests that prove restore wizard step 1 and step 2 expose degraded input before risk checks or preview generation.
|
||||
- Add RBAC tests that prove `TENANT_VIEW` users still see backup-quality truth while restore actions remain unavailable, and non-members still receive 404 semantics.
|
||||
- Re-run existing restore-safety and restore-selection tests so earlier input-quality visibility does not change existing risk-check or execution behavior.
|
||||
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a full-suite pass.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or exception-driven complexity were identified. The only added structure is a narrow derived backup-quality helper layer justified by cross-surface reuse and the need to keep current metadata interpretation consistent across list, detail, and wizard selection surfaces.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators can currently tell that a backup set, backup item, or policy version exists, but they cannot quickly tell whether it is strong, degraded, or metadata-only as recovery input before they reach deep diagnostics or restore-safety surfaces.
|
||||
- **Existing structure is insufficient because**: The relevant truth is fragmented across backup metadata, version metadata, assignment fetch flags, orphaned-assignment markers, and disabled restore actions. Presence is visible earlier than usefulness, which creates false trust.
|
||||
- **Narrowest correct implementation**: Add one narrow derived backup-quality helper path and integrate it directly into existing backup-set, backup-item, policy-version, and restore-selection surfaces without adding new persistence or a broad taxonomy framework.
|
||||
- **Ownership cost created**: A small amount of derivation logic, additional list or detail wiring, and focused unit and feature tests to keep the mapping stable.
|
||||
- **Alternative intentionally rejected**: A persisted backup-health table, a recovery-confidence score, or a dashboard-wide backup-health program. Each would create broader truth and ownership cost than the current operator problem requires.
|
||||
- **Release truth**: Current-release truth. This slice corrects the truth on already-shipped backup and version surfaces before later backup-health or recovery-confidence work builds on them.
|
||||
132
specs/176-backup-quality-truth/quickstart.md
Normal file
132
specs/176-backup-quality-truth/quickstart.md
Normal file
@ -0,0 +1,132 @@
|
||||
# Quickstart: Backup Quality Truth Surfaces
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that backup-set, backup-item, policy-version, and restore-selection surfaces now communicate backup quality truth early and explicitly without overstating restore safety, restore readiness, or tenant recoverability.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail if it is not already running.
|
||||
2. Ensure the workspace has representative fixtures for:
|
||||
- a backup set with only full-payload items
|
||||
- a backup set with at least one metadata-only item
|
||||
- a backup set with assignment fetch failure metadata
|
||||
- a backup set with orphaned-assignment metadata
|
||||
- one policy version captured as full payload
|
||||
- one policy version captured as metadata-only
|
||||
- one user with `TENANT_VIEW` but without restore capability
|
||||
- one user with restore capability for the same tenant
|
||||
3. Ensure the acting users are valid workspace and tenant members.
|
||||
4. Ensure archived backup-set and policy-version fixtures exist if lifecycle plus quality combinations need validation.
|
||||
|
||||
## Focused Automated Verification
|
||||
|
||||
Run the smallest existing backup, version, and restore-selection pack first:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreItemSelectionTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/RestoreRiskChecksWizardTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/AssignmentBackupServiceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/BackupItemTest.php
|
||||
```
|
||||
|
||||
Expected new or expanded spec-scoped tests:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupQualityTruthSurfaceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreSelectionQualityTruthTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/BackupQualityVisibilityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Support/BackupQuality/
|
||||
```
|
||||
|
||||
Use `--filter` for a smaller pass while iterating.
|
||||
|
||||
## Manual Validation Pass
|
||||
|
||||
### 1. Verify backup-set list truth
|
||||
|
||||
Open `/admin/t/{tenant}/backup-sets` and confirm:
|
||||
|
||||
- lifecycle status remains visible and separate from backup-quality summary
|
||||
- a full-quality set reads as `no degradations detected` or equivalent without implying safe restore
|
||||
- a degraded set shows metadata-only, assignment issues, orphaned assignments, or degraded-item count within one scan
|
||||
|
||||
### 2. Verify backup-set detail summary-first layout
|
||||
|
||||
Open a degraded backup set and confirm:
|
||||
|
||||
- the first visible summary answers whether the set is strong or weak as recovery input
|
||||
- metadata-only count, assignment issue count, and orphaned-assignment count appear before raw metadata
|
||||
- one primary next action is visible when degraded truth exists
|
||||
|
||||
### 3. Verify backup-items relation truth
|
||||
|
||||
Within the same backup-set detail page, confirm the relation table shows:
|
||||
|
||||
- snapshot mode per item
|
||||
- assignment capture issue truth per item
|
||||
- orphaned-assignment truth per item
|
||||
- current inspect model and action placement remain unchanged
|
||||
|
||||
### 4. Verify policy-version list and detail truth
|
||||
|
||||
Open `/admin/t/{tenant}/policy-versions` and confirm:
|
||||
|
||||
- metadata-only versions are visible at scan speed in the list itself
|
||||
- full-payload and degraded versions are distinguishable without hovering disabled actions
|
||||
|
||||
Open a degraded policy version and confirm:
|
||||
|
||||
- an explicit backup-quality section appears on the detail surface
|
||||
- the page explains degraded input quality without claiming safe restore or meaningful rollback certainty
|
||||
|
||||
### 5. Verify restore-selection truth before risk checks
|
||||
|
||||
Open `/admin/t/{tenant}/restore-runs/create` and confirm:
|
||||
|
||||
- step 1 backup-set choices expose degraded input before the wizard reaches checks or preview
|
||||
- step 2 item descriptions expose metadata-only and assignment-quality truth before risk checks run
|
||||
- the page still treats backup quality as input truth, not restore safety approval
|
||||
|
||||
### 6. Verify RBAC-safe truth visibility
|
||||
|
||||
Repeat the list and detail checks as a user with `TENANT_VIEW` but without restore permission and confirm:
|
||||
|
||||
- backup-quality truth remains visible
|
||||
- restore entry points remain unavailable or disabled with the current RBAC behavior
|
||||
- non-members still receive deny-as-not-found behavior rather than resource hints
|
||||
|
||||
## Non-Regression Checks
|
||||
|
||||
Confirm the feature did not change:
|
||||
|
||||
- tenant route identity for backup, version, or restore pages
|
||||
- current archive, restore, force-delete, or remove confirmation behavior
|
||||
- existing restore-safety blocking behavior for metadata-only input
|
||||
- existing assignment capture semantics and orphaned-assignment detection
|
||||
- current global asset registration and deployment requirements
|
||||
|
||||
## Formatting And Final Verification
|
||||
|
||||
Before finalizing implementation work:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Then rerun the smallest affected test set and offer the full suite only after the focused backup-quality pack passes.
|
||||
|
||||
Close the feature only after manual validation confirms:
|
||||
|
||||
- an operator can identify degraded versus full-looking backup input within 10 seconds on backup-set list and detail surfaces
|
||||
- the first restore selection step exposes weak inputs before risk-check work begins
|
||||
- reduced-permission users still see truthful quality signals without gaining restore capability
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user