Compare commits
61 Commits
131-cross-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f45ff5a84 | |||
| 1655cc481e | |||
| 28e62bd22c | |||
| 9fbd3e5ec7 | |||
| 53e799fea7 | |||
| f1a73490e4 | |||
| 03b1beb616 | |||
| ce0615a9c1 | |||
| 6f8eb28ca2 | |||
| e840007127 | |||
| a107e7e41b | |||
| 1142d283eb | |||
| f52d52540c | |||
| dc46c4fa58 | |||
| 98be510362 | |||
| 44898a98ac | |||
| 3a2a06e8d7 | |||
| 671abbed53 | |||
| 1b88d28739 | |||
| fdd3a85b64 | |||
| 37c6d0622c | |||
| 807d574d31 | |||
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 | |||
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 | |||
| d4fb886de0 | |||
| 8ee1174c8d | |||
| b15d1950b4 | |||
| 4b3113498c |
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
@ -1,7 +1,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
coverage/
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@ -18,12 +23,19 @@ Dockerfile*
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
storage/framework/
|
storage/framework/
|
||||||
|
apps/platform/storage/framework/
|
||||||
storage/logs/
|
storage/logs/
|
||||||
|
apps/platform/storage/logs/
|
||||||
storage/debugbar/
|
storage/debugbar/
|
||||||
|
apps/platform/storage/debugbar/
|
||||||
storage/*.key
|
storage/*.key
|
||||||
|
apps/platform/storage/*.key
|
||||||
/references/
|
/references/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
133
.github/agents/copilot-instructions.md
vendored
133
.github/agents/copilot-instructions.md
vendored
@ -2,6 +2,14 @@ # TenantAtlas Development Guidelines
|
|||||||
|
|
||||||
Auto-generated from all feature plans. Last updated: 2025-12-22
|
Auto-generated from all feature plans. Last updated: 2025-12-22
|
||||||
|
|
||||||
|
## Relocation override
|
||||||
|
- The authoritative Laravel application root is `apps/platform`.
|
||||||
|
- Human-facing commands should use `cd apps/platform && ...`.
|
||||||
|
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
|
||||||
|
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||||
@ -59,27 +67,140 @@ ## Active Technologies
|
|||||||
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||||
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
||||||
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||||
|
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||||
|
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||||
|
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||||
|
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||||
|
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||||
|
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
|
||||||
|
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||||
|
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
||||||
|
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||||
|
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||||
|
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
||||||
|
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||||
|
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||||
|
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||||
|
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||||
|
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||||
|
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||||
|
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||||
|
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||||
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||||
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs (169-action-surface-v11)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifac (169-action-surface-v11)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (170-system-operations-surface-alignment)
|
||||||
|
- PostgreSQL with existing `operation_runs` and `audit_logs` tables; no schema changes (170-system-operations-surface-alignment)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation)
|
||||||
|
- PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation)
|
||||||
|
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
|
||||||
|
- 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 (173-tenant-dashboard-truth-alignment)
|
||||||
|
- 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, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers (180-tenant-backup-health)
|
||||||
|
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
|
||||||
|
- PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose (182-platform-relocation)
|
||||||
|
- PostgreSQL, Redis, filesystem storage under the Laravel app `storage/` tree, plus existing Vite build artifacts in `public/build`; no new database persistence planned (182-platform-relocation)
|
||||||
|
- PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose (183-website-workspace-foundation)
|
||||||
|
- Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence (183-website-workspace-foundation)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers (184-dashboard-recovery-honesty)
|
||||||
|
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces (185-workspace-recovery-posture-visibility)
|
||||||
|
- PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned (185-workspace-recovery-posture-visibility)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure (186-tenant-registry-recovery-triage)
|
||||||
|
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context)
|
||||||
|
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
|
||||||
|
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
||||||
|
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
apps/
|
||||||
tests/
|
platform/
|
||||||
|
website/
|
||||||
|
docs/
|
||||||
|
specs/
|
||||||
|
scripts/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
# Add commands for PHP 8.4.15
|
- Root workspace:
|
||||||
|
- `corepack pnpm install`
|
||||||
|
- `corepack pnpm dev:platform`
|
||||||
|
- `corepack pnpm dev:website`
|
||||||
|
- `corepack pnpm dev`
|
||||||
|
- `corepack pnpm build:website`
|
||||||
|
- `corepack pnpm build:platform`
|
||||||
|
- Platform app:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 130-structured-snapshot-rendering: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
- 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns
|
||||||
- 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries
|
||||||
- 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@ -40,7 +40,7 @@ ## 3) Panel setup defaults
|
|||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
@ -254,7 +254,7 @@ ## Testing
|
|||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
@ -291,8 +291,12 @@ ## Application Structure & Architecture
|
|||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Workspace Commands
|
||||||
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Replies
|
## Replies
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
@ -372,28 +376,29 @@ ## Enums
|
|||||||
## Laravel Sail
|
## Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- The canonical application working directory is `apps/platform`. Repo-root launchers such as MCP or VS Code tasks may use `./scripts/platform-sail`, but that helper is compatibility-only.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
@ -404,7 +409,7 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
@ -428,10 +433,10 @@ ### Configuration
|
|||||||
### Testing
|
### Testing
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
### Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
@ -460,7 +465,7 @@ ### Models
|
|||||||
## Livewire
|
## Livewire
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
- Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||||
- State should live on the server, with the UI reflecting it.
|
- State should live on the server, with the UI reflecting it.
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||||
|
|
||||||
@ -504,8 +509,8 @@ ## Testing Livewire
|
|||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
@ -514,7 +519,7 @@ ### Testing
|
|||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
### Pest Tests
|
### Pest Tests
|
||||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
- All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
@ -527,9 +532,9 @@ ### Pest Tests
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
- To run all tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`.
|
||||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||||
|
|
||||||
### Pest Assertions
|
### Pest Assertions
|
||||||
|
|||||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
agent: speckit.specify
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
20
.gitignore
vendored
20
.gitignore
vendored
@ -15,22 +15,42 @@
|
|||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/apps/platform/node_modules
|
||||||
|
/apps/website/node_modules
|
||||||
|
/.pnpm-store
|
||||||
|
/apps/website/.astro
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
/public/build
|
/public/build
|
||||||
|
/apps/platform/public/build
|
||||||
|
/apps/website/dist
|
||||||
/public/hot
|
/public/hot
|
||||||
|
/apps/platform/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/apps/platform/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/apps/platform/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/apps/platform/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
|
/apps/platform/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/apps/platform/storage/logs
|
||||||
/storage/debugbar
|
/storage/debugbar
|
||||||
|
/apps/platform/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
|
/apps/platform/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
|
/apps/platform/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/references
|
/references
|
||||||
|
/tests/Browser/Screenshots
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
/apps/platform/.env
|
||||||
|
/apps/platform/.env.*
|
||||||
|
/apps/website/.env
|
||||||
|
/apps/website/.env.*
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -2,12 +2,22 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
coverage/
|
coverage/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
apps/website/node_modules/
|
||||||
|
apps/website/.astro/
|
||||||
|
apps/website/dist/
|
||||||
storage/
|
storage/
|
||||||
|
apps/platform/storage/
|
||||||
bootstrap/cache/
|
bootstrap/cache/
|
||||||
|
apps/platform/bootstrap/cache/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
@ -1,23 +1,24 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.9.0 → 1.10.0
|
- Version change: 2.0.0 -> 2.1.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
|
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||||
|
with cross-reference to new HDR-001
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||||
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
|
||||||
- Summary counts contract (OPS-UX-SUM-001)
|
|
||||||
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
|
||||||
- Scheduled/system runs (OPS-UX-SYS-001)
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||||
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
- ⚠ .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:
|
- Follow-up TODOs:
|
||||||
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
|
- None.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
@ -44,6 +45,82 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Proportionality First (PROP-001)
|
||||||
|
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||||
|
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||||
|
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||||
|
|
||||||
|
### No Premature Abstraction (ABSTR-001)
|
||||||
|
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||||
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
|
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||||
|
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||||
|
|
||||||
|
### No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||||
|
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||||
|
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||||
|
|
||||||
|
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||||
|
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||||
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
- The burden of proof is always on the broader abstraction.
|
||||||
|
|
||||||
|
### One Truth, Few Layers (LAYER-001)
|
||||||
|
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||||
|
- Prefer one canonical truth with thin adapters.
|
||||||
|
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||||
|
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||||
|
|
||||||
|
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||||
|
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||||
|
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||||
|
|
||||||
|
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||||
|
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||||
|
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||||
|
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||||
|
|
||||||
|
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||||
|
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||||
|
|
||||||
|
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||||
|
- That review MUST answer:
|
||||||
|
1. What current operator problem does this solve?
|
||||||
|
2. Why is existing structure insufficient?
|
||||||
|
3. Why is this the narrowest correct implementation?
|
||||||
|
4. What ownership cost does this create?
|
||||||
|
5. What alternative was intentionally rejected?
|
||||||
|
6. Is this current-release truth or future-release preparation?
|
||||||
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### Spec Candidate Gate (SPEC-GATE-001)
|
||||||
|
- Every new spec candidate MUST pass the Spec Approval Rubric (`.specify/memory/spec-approval-rubric.md`) before progressing beyond Draft status.
|
||||||
|
- The spec MUST include a filled-out "Spec Candidate Check" section answering the 5 mandatory questions (operator workflow, trust/safety, smallest version, permanent complexity, why now).
|
||||||
|
- The spec MUST be classified into exactly one approval class: Core Enterprise, Workflow Compression, Cleanup, or Defer.
|
||||||
|
- The spec MUST include a scored evaluation (6 dimensions, 0–2 each). Specs scoring below 7/12 MUST NOT be approved without explicit scope reduction.
|
||||||
|
- If two or more red flags from the rubric are triggered, the spec MUST include an explicit defense justifying why it should proceed.
|
||||||
|
- Specs classified as "Defer" or scoring 0–3 MUST NOT be implemented.
|
||||||
|
- This gate applies to all spec-creating agents (speckit.specify, speckit.plan) and manual spec creation alike.
|
||||||
|
|
||||||
|
### Default Bias (BIAS-001)
|
||||||
|
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||||
|
|
||||||
### Workspace Isolation is Non-negotiable
|
### Workspace Isolation is Non-negotiable
|
||||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
@ -72,6 +149,7 @@ ### Tenant Isolation is Non-negotiable
|
|||||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||||
|
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||||
|
|
||||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
@ -236,78 +314,515 @@ ### Scheduled/system runs (OPS-UX-SYS-001)
|
|||||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||||
|
|
||||||
### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||||
|
|
||||||
For every new or modified Filament Resource / RelationManager / Page:
|
Purpose and scope
|
||||||
|
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
||||||
|
- It defines allowed surface types, allowed interaction models, primary/secondary/destructive action hierarchy, list/detail/queue semantics, scope and context signals, canonical navigation and naming rules, visibility of critical operational truth, scanability and density rules, exception handling, and review and enforcement requirements.
|
||||||
|
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
||||||
|
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
||||||
|
|
||||||
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
|
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||||
|
|
||||||
|
##### CRUD / List-first Resource
|
||||||
|
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||||
|
- Primary behavior: Browse -> Open -> Decide / Mutate.
|
||||||
|
- Primary model: one-click inspect/open. Full-row click is the default; identifier click is allowed only when full-row click conflicts with another dominant row mechanism.
|
||||||
|
- Secondary actions: at most one inline non-destructive shortcut; everything else belongs in overflow.
|
||||||
|
- Destructive actions: never inline beside inspect; only in overflow or the detail header; confirmation is mandatory.
|
||||||
|
- Explicit View/Inspect: forbidden when row click or identifier click already opens the same destination.
|
||||||
|
|
||||||
|
##### Queue / Review Surface
|
||||||
|
- Purpose: triage items, inspect them in context, decide, and continue working through the queue.
|
||||||
|
- Primary behavior: Inspect in context -> Decide -> Continue.
|
||||||
|
- Primary model: explicit Inspect using a slide-over, inline detail pane, or same-page inspect.
|
||||||
|
- Secondary actions: only queue-relevant actions belong in the row.
|
||||||
|
- Destructive actions: inline is allowed only when the destructive decision is part of the real queue work; irreversibility or high risk still requires confirmation.
|
||||||
|
- Row click: forbidden by default.
|
||||||
|
- Explicit View/Inspect: required unless the detail is already visible inline.
|
||||||
|
|
||||||
|
##### History / Audit Surface
|
||||||
|
- Purpose: inspect immutable history, events, and evidence without losing chronology.
|
||||||
|
- Primary behavior: Inspect event -> Follow trace -> Return to history context.
|
||||||
|
- Primary model: explicit Inspect, preferably in a slide-over or same-page detail.
|
||||||
|
- Secondary actions: related navigation only.
|
||||||
|
- Destructive actions: normally none.
|
||||||
|
- Row click: forbidden.
|
||||||
|
- Explicit View/Inspect: required.
|
||||||
|
|
||||||
|
##### Config-lite Resource
|
||||||
|
- Purpose: manage small, low-cardinality configuration where edit is effectively the detail surface.
|
||||||
|
- Primary behavior: Open config -> Adjust.
|
||||||
|
- Primary model: edit-as-inspect.
|
||||||
|
- Secondary actions: minimal and usually limited to Edit or overflow.
|
||||||
|
- Destructive actions: overflow or detail header only.
|
||||||
|
- Row click: allowed when it opens Edit directly and no separate View surface exists.
|
||||||
|
- Explicit View/Inspect: forbidden.
|
||||||
|
|
||||||
|
##### Read-only Registry / Report Surface
|
||||||
|
- Purpose: inspect, compare, reference, and export immutable or mostly read-only artifacts.
|
||||||
|
- Primary behavior: Scan -> Open detail -> Reference / Export.
|
||||||
|
- Primary model: row click or identifier click to detail.
|
||||||
|
- Secondary actions: optional single inline non-destructive shortcut when it serves the operator flow.
|
||||||
|
- Destructive actions: normally none; if they exist they belong in detail only.
|
||||||
|
- Explicit View/Inspect: forbidden when a functional one-click open already exists.
|
||||||
|
|
||||||
|
##### Detail-first Operational Surface
|
||||||
|
- Purpose: fully understand one operational record, including state, truth, context, and next steps.
|
||||||
|
- Primary behavior: Read -> Understand -> Act / Navigate.
|
||||||
|
- Primary model: dedicated detail page or dedicated operational page.
|
||||||
|
- Secondary actions: header actions and related-link groups.
|
||||||
|
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||||
|
- Row click and explicit View/Inspect: not applicable.
|
||||||
|
|
||||||
|
#### Hard Rules (UI-HARD-001)
|
||||||
|
|
||||||
|
##### Primary inspect model
|
||||||
|
- Every list surface MUST expose exactly one primary inspect/open model.
|
||||||
|
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
||||||
|
- CRUD / List-first and Read-only Registry / Report surfaces MUST provide an obvious one-click open path.
|
||||||
|
- Queue / Review and History / Audit surfaces MUST use explicit Inspect rather than row-click navigation.
|
||||||
|
|
||||||
|
##### Row-click semantics
|
||||||
|
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
|
||||||
|
- Identifier-only click is allowed only when full-row click would conflict with another dominant row behavior such as selection-heavy interaction, expand/collapse, drag/sort, or another primary row mechanism.
|
||||||
|
- When row click is enabled, the row MUST feel consistent. Silent split behavior inside the same row is forbidden.
|
||||||
|
- Edit-as-inspect is allowed only for Config-lite resources.
|
||||||
|
|
||||||
|
##### View and Inspect actions
|
||||||
|
- Explicit View MUST NOT exist when the same destination is already opened through row click or identifier click.
|
||||||
|
- Explicit Inspect is the default only for Queue / Review, History / Audit, and explicitly catalogued exceptions.
|
||||||
|
- View and Inspect MUST NOT be treated as interchangeable labels. If the interaction preserves context and behaves unlike ordinary navigation, it is Inspect, not View.
|
||||||
|
|
||||||
|
##### Action hierarchy
|
||||||
|
- Every surface MUST distinguish between the primary inspect/open action, secondary safe actions, destructive actions, and long-running workflow launches.
|
||||||
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed the primary open interaction plus one inline safe shortcut.
|
||||||
|
- All other secondary actions MUST move to overflow.
|
||||||
|
- Long-running workflow launches such as sync, compare, verify, generate, consent, setup, or retry SHOULD live in list headers or detail headers rather than in every row.
|
||||||
|
|
||||||
|
##### Destructive actions
|
||||||
|
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces.
|
||||||
|
- Destructive actions MUST live in overflow or the detail header.
|
||||||
|
- Destructive actions MUST use confirmation.
|
||||||
|
- High-risk or high-volume destructive bulk actions SHOULD use typed confirmation.
|
||||||
|
- The Queue Decision exception applies only when the destructive decision is part of the actual queue work.
|
||||||
|
|
||||||
|
##### Overflow and More
|
||||||
|
- Overflow actions MUST follow one product-wide pattern per surface class.
|
||||||
|
- Mixed labeled-overflow versus icon-only overflow patterns inside the same surface class are forbidden unless an approved exception documents why.
|
||||||
|
- Empty `ActionGroup` and empty `BulkActionGroup` are forbidden.
|
||||||
|
- Placeholder UI added only to satisfy a contract or slot is forbidden.
|
||||||
|
|
||||||
|
##### Bulk actions
|
||||||
|
- Bulk actions are allowed only when they are safe enough, materially faster than row-by-row execution, and genuinely fit the surface.
|
||||||
|
- A surface with no real bulk need MUST NOT render bulk UI.
|
||||||
|
- Bulk destructive actions follow the same protection rules as row destructive actions, with stricter confirmation and review expectations.
|
||||||
|
|
||||||
|
##### Row label length and action budget
|
||||||
|
- Inline row action labels MUST stay short and SHOULD be one or two words.
|
||||||
|
- Long workflow labels belong in overflow, headers, or detail surfaces.
|
||||||
|
- Standard list rows MUST NOT become control centers for onboarding recovery, provider management, consent flows, RBAC setup, diagnostics, and destructive lifecycle actions all at once.
|
||||||
|
|
||||||
|
##### Scope and context semantics
|
||||||
|
- Scope chips, tenant pills, and similar context signals MUST correspond to real scoping behavior.
|
||||||
|
- A scope signal MUST NOT be shown when it neither scopes the displayed data nor materially changes the action targets.
|
||||||
|
- Remembered context is allowed only when labeled clearly as reference context rather than active scope.
|
||||||
|
- Cross-panel navigation MUST NOT imply that the operator remains inside the same logical scope when that is not true.
|
||||||
|
|
||||||
|
##### Canonical navigation and terminology
|
||||||
|
- Every domain object MUST have one canonical collection noun and one canonical singular noun.
|
||||||
|
- The same domain object MUST NOT use competing primary nouns across shells.
|
||||||
|
- The Operations domain MUST use one canonical collection noun. Parallel primary nouns such as Runs beside Operations are forbidden.
|
||||||
|
- Cross-panel navigation is allowed only when it lands on a canonical surface, uses stable nouns, and keeps back navigation clear.
|
||||||
|
|
||||||
|
##### Visibility of critical operational truth
|
||||||
|
- Critical operational truth MUST be visible by default.
|
||||||
|
- It MUST NOT be hidden only in default-off columns, tooltips, helper text, overflow menus, or detail pages when list decisions depend on it.
|
||||||
|
- Lifecycle truth, operability truth, health truth, execution outcome, trust/confidence, and next action MUST remain separate semantic dimensions.
|
||||||
|
- One badge, column, or label MUST NOT collapse multiple truth dimensions into a generic status.
|
||||||
|
|
||||||
|
##### Row density and scanability
|
||||||
|
- Standard CRUD lists MUST remain scanable.
|
||||||
|
- Outside Queue / Review and History / Audit exceptions, each row MAY contain at most one multi-line explanatory column and at most one prose-heavy explanatory context.
|
||||||
|
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose.
|
||||||
|
- Next-step prose belongs in detail, inspect, or queue surfaces, not in ordinary CRUD rows.
|
||||||
|
|
||||||
|
##### Custom abstractions
|
||||||
|
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior.
|
||||||
|
- Contract systems MUST NOT force placeholder UI.
|
||||||
|
- Behavior matters more than declaration. If declared conformance and rendered behavior differ, the surface is non-conformant.
|
||||||
|
- A feature MUST NOT ship when its implemented interaction semantics contradict its declared surface type.
|
||||||
|
|
||||||
|
#### Exception Model (UI-EX-001)
|
||||||
|
|
||||||
|
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
||||||
|
|
||||||
|
##### Queue Decision Exception
|
||||||
|
- Allowed when per-item decision-making is the real queue work.
|
||||||
|
- Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
|
||||||
|
|
||||||
|
##### History In-place Inspect Exception
|
||||||
|
- Allowed when leaving the page would break chronology or traceability.
|
||||||
|
- Guardrails: explicit Inspect is mandatory; row click is forbidden; generic mutation rails are forbidden.
|
||||||
|
|
||||||
|
##### Config-lite Edit-as-Inspect Exception
|
||||||
|
- Allowed when a separate View surface would add no value.
|
||||||
|
- Guardrails: no parallel View surface; no high-risk destructive flow as the default entry point.
|
||||||
|
|
||||||
|
##### Read-only Shortcut Exception
|
||||||
|
- Allowed for exactly one dominant non-destructive shortcut.
|
||||||
|
- Guardrails: inspect/open remains dominant; only one shortcut exists; the shortcut does not compete with the primary open path.
|
||||||
|
|
||||||
|
##### Cross-panel Canonical Route Exception
|
||||||
|
- Allowed when only one canonical surface makes sense.
|
||||||
|
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful.
|
||||||
|
|
||||||
|
#### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
For every new or modified Filament Resource, RelationManager, or Page:
|
||||||
|
|
||||||
Required surfaces
|
Required surfaces
|
||||||
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
- List/Table MUST define Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||||
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
|
- Every table MUST provide a record inspection affordance that matches its surface type.
|
||||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
||||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
- CRUD / List-first, Config-lite, and Read-only Registry surfaces MUST NOT render a redundant View action when the same destination is already available through row click or identifier click.
|
||||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
- Queue / Review and History / Audit surfaces MAY use a lone explicit Inspect action because context-preserving inspect is the primary interaction.
|
||||||
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
- View/Detail MUST define header actions and MUST keep destructive actions grouped and confirmed.
|
||||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
- View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
|
||||||
|
- Create/Edit MUST provide consistent Save and Cancel UX.
|
||||||
|
|
||||||
Grouping & safety
|
Grouping and safety
|
||||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
||||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
- Queue / Review rows MAY expose inline decision actions only when allowed by UI-EX-001.
|
||||||
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
- Everything else MUST move to `ActionGroup::make()` or the detail header.
|
||||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
- Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
|
||||||
|
- Empty `ActionGroup` and `BulkActionGroup` are forbidden.
|
||||||
|
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
|
||||||
- Relevant mutations MUST write an audit log entry.
|
- Relevant mutations MUST write an audit log entry.
|
||||||
|
|
||||||
RBAC enforcement
|
RBAC enforcement
|
||||||
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
||||||
- Member without capability: UI visible but disabled with tooltip; server-side MUST abort(403).
|
- Members without capability MAY see disabled actions with helper text, but server-side execution MUST still abort(403).
|
||||||
- Central enforcement helpers (tenant/workspace UI enforcement) MUST be used for gating.
|
- Central tenant and workspace UI enforcement helpers MUST be used for gating.
|
||||||
|
|
||||||
Spec / DoD gates
|
Behavior over declaration
|
||||||
- Every spec MUST include a “UI Action Matrix”.
|
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||||
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||||
|
|
||||||
### Filament UI — Layout & Information Architecture Standards (UX-001)
|
#### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||||
|
|
||||||
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
|
Goal: operator-facing Filament screens MUST feel enterprise-grade, legible, and decisive.
|
||||||
|
|
||||||
Page layout
|
Page layout
|
||||||
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
||||||
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
|
- All fields MUST live inside Sections or Cards. Naked root-level inputs are forbidden.
|
||||||
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
|
- Main content carries domain definition and working content. Aside carries status and meta such as scope, owner, timestamps, or version labels.
|
||||||
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
|
- Related data MUST render as separate sections, tabs, or subordinate surfaces rather than as one long unstructured form or detail page.
|
||||||
|
|
||||||
View pages
|
View pages
|
||||||
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
|
- View/Detail MUST be a read-only surface built with Infolists or an equivalent read-first structure, not disabled edit forms.
|
||||||
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
|
- Status-like values MUST render via BADGE-001 semantics.
|
||||||
- Long text MUST render as readable prose (not textarea styling).
|
- Long text MUST read like prose, not like disabled textarea output.
|
||||||
|
|
||||||
Empty states
|
Empty states
|
||||||
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
|
- Empty lists and tables MUST show a specific title, a one-sentence explanation, and exactly one primary CTA.
|
||||||
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
|
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||||
|
|
||||||
Actions & flows
|
Actions and flows
|
||||||
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
|
- Pages 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 (e.g., capture/compare/restore with preview + confirmation).
|
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||||
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
|
- Destructive actions remain non-primary and confirmed.
|
||||||
|
|
||||||
Table work-surface defaults
|
Table defaults
|
||||||
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
|
- Tables SHOULD provide search when the dataset can grow, a meaningful default sort, and filters for core dimensions.
|
||||||
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
|
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
||||||
|
- Critical operational truth that informs list decisions MUST be default-visible.
|
||||||
|
|
||||||
Enforcement
|
Enforcement
|
||||||
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||||
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||||
|
|
||||||
Spec Scope Fields (SCOPE-002)
|
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
||||||
|
|
||||||
- Every feature spec MUST declare:
|
Goal: record and detail pages MUST be comprehensible within seconds.
|
||||||
- Scope: workspace | tenant | canonical-view
|
Header actions are reserved for the primary workflow of the current page
|
||||||
- Primary Routes
|
and MUST NOT become a dumping ground for every available action or
|
||||||
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
|
navigation jump.
|
||||||
- RBAC: membership requirements + capability requirements
|
|
||||||
- For canonical-view specs, the spec MUST define:
|
##### Core rule
|
||||||
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
|
|
||||||
- Explicit entitlement checks that prevent cross-tenant leakage
|
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.
|
||||||
|
|
||||||
|
Naming model
|
||||||
|
- Operator-facing copy MUST distinguish Scope, Source/Domain, Operation, and Target Object.
|
||||||
|
- Scope terms such as Workspace and Tenant describe execution context and MUST NOT become the primary action label unless they are the actual target object.
|
||||||
|
- Source/domain terms such as Intune or Entra are secondary and lead only when same-screen disambiguation genuinely requires them.
|
||||||
|
|
||||||
|
Primary labels
|
||||||
|
- Primary buttons, header actions, and menu actions MUST use Verb + Object.
|
||||||
|
- Preferred examples are `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, and `Export review pack`.
|
||||||
|
- Implementation-first labels such as `Sync from tenant`, `Sync from Intune`, `Run tenant sync now`, or `Start inventory refresh from provider` are forbidden.
|
||||||
|
|
||||||
|
Canonical nouns and routes
|
||||||
|
- Every domain object MUST keep one canonical collection noun and one canonical singular noun.
|
||||||
|
- Cross-shell or cross-panel navigation MUST preserve the same noun.
|
||||||
|
- Operations is the canonical collection noun for run records. Runs MUST NOT appear as a competing primary collection noun.
|
||||||
|
|
||||||
|
Run, notification, and audit semantics
|
||||||
|
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD remain concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||||
|
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short.
|
||||||
|
- Audit prose MUST use the same operator-facing language as the initiating action.
|
||||||
|
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, audit prose, and related navigation.
|
||||||
|
|
||||||
|
Verb standard
|
||||||
|
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||||
|
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided unless the domain specifically requires them.
|
||||||
|
- `Run` MAY be used only when the object is itself run-like, such as `Run review`; it MUST NOT become the fallback verb for everything.
|
||||||
|
|
||||||
|
Current binding decision
|
||||||
|
- The Policies screen primary action MUST be `Sync policies`.
|
||||||
|
- The Policies screen modal title MUST be `Sync policies`.
|
||||||
|
- The Policies screen success toast MUST be `Policy sync queued`.
|
||||||
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
|
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||||
|
|
||||||
|
#### Operator Surface Principles (OPSURF-001)
|
||||||
|
|
||||||
|
Goal: operator-facing surfaces MUST optimize for the operator's working question instead of raw implementation visibility.
|
||||||
|
|
||||||
|
Operator-first default surfaces
|
||||||
|
- `/admin` is operator-first.
|
||||||
|
- Default-visible content MUST use operator language, clear scope, and actionable status communication.
|
||||||
|
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||||
|
|
||||||
|
Progressive disclosure for diagnostics
|
||||||
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
|
|
||||||
|
Distinct truth dimensions
|
||||||
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
|
|
||||||
|
Explicit mutation scope
|
||||||
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
|
|
||||||
|
Safe execution
|
||||||
|
- Dangerous actions MUST follow a consistent safety flow: configuration, safety checks or simulation, preview, hard confirmation where required, then execution.
|
||||||
|
- One-click high-blast-radius actions are forbidden unless an approved exception documents replacement safeguards.
|
||||||
|
|
||||||
|
Explicit workspace and tenant context
|
||||||
|
- Workspace and tenant context MUST remain explicit in navigation, action copy, and page semantics.
|
||||||
|
- Tenant surfaces MUST NOT silently expose workspace-wide actions.
|
||||||
|
- Canonical workspace views that operate on tenant-owned records MUST make both workspace and tenant context legible before the operator acts.
|
||||||
|
|
||||||
|
Critical truth visibility and scanability
|
||||||
|
- Critical operational truth MUST be default-visible wherever the list or summary surface is used to prepare decisions.
|
||||||
|
- Standard CRUD surfaces MUST preserve scanability and MUST avoid collapsing multiple truth dimensions into one generic badge or one prose-heavy row.
|
||||||
|
|
||||||
|
Page contract requirement
|
||||||
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
|
|
||||||
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
|
- Every feature spec MUST declare Scope, Primary Routes, Data Ownership, and RBAC requirements.
|
||||||
|
- Canonical-view specs MUST define the default filter behavior when tenant context is active and the entitlement checks that prevent cross-tenant leakage.
|
||||||
|
|
||||||
|
#### Enforcement Model (UI-REVIEW-001)
|
||||||
|
|
||||||
|
Spec review requirements
|
||||||
|
- Every spec that changes an operator-facing surface MUST answer: surface type, primary inspect/open model, row-click rule, whether explicit View/Inspect exists or is forbidden, where secondary actions live, where destructive actions live, canonical collection route, canonical detail route, scope signals and their exact meaning, canonical noun, critical truth visible by default, and whether an exception type is used.
|
||||||
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
|
PR review requirements
|
||||||
|
- A PR MUST NOT pass when it introduces more than one primary inspect model, redundant View beside row click, destructive inline actions beside inspect on standard lists, empty overflow or bulk groups, long workflow labels in dense rows, misleading scope chips, drifting domain nouns, hidden critical operational truth, or undocumented exceptions without dedicated tests.
|
||||||
|
|
||||||
|
Guard tests
|
||||||
|
- Repository guards SHOULD validate: declared surface type, conformant primary inspect model, absence of redundant View actions, presence of explicit Inspect on Queue / Review and History / Audit surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, correct placement of destructive actions, truthful scope signals, stable canonical nouns across shells, and dedicated tests for every approved exception.
|
||||||
|
|
||||||
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
|
Wave 1 - Interaction normalization
|
||||||
|
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
||||||
|
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||||
|
- Wave 1 is done only when each surface has exactly one primary inspect model, destructive actions are protected, and placeholder groups are gone.
|
||||||
|
|
||||||
|
Wave 2 - Scope, nouns, and truth
|
||||||
|
- Then fix scope and context leaks, stabilize canonical nouns, make cross-panel transitions explicit, move critical operational truth to default-visible regions, and reduce prose-heavy dense rows.
|
||||||
|
|
||||||
|
Wave 3 - Enforcement
|
||||||
|
- Then move the constitution into repo enforcement, require the PR checklist, anchor guard tests, and trim old declaration-only action-surface checks until behavior is the governing truth.
|
||||||
|
|
||||||
|
#### Appendix A - One-page Condensed Constitution
|
||||||
|
|
||||||
|
- Every admin surface has one surface type.
|
||||||
|
- Every list has exactly one primary inspect/open model.
|
||||||
|
- CRUD and Registry surfaces use one-click open.
|
||||||
|
- Queue and Audit surfaces use explicit Inspect.
|
||||||
|
- Edit-as-inspect exists only for Config-lite resources.
|
||||||
|
- Standard lists expose at most one inline safe shortcut.
|
||||||
|
- Destructive actions never sit openly beside inspect on standard lists.
|
||||||
|
- Overflow is standardized per surface class and is never empty.
|
||||||
|
- Bulk exists only when it is genuinely useful.
|
||||||
|
- Scope chips must be truthful.
|
||||||
|
- Domain nouns are canonical and stable.
|
||||||
|
- Critical operational truth is default-visible.
|
||||||
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
|
- Standard lists stay scanable.
|
||||||
|
- Exceptions are catalogued, justified, and tested.
|
||||||
|
- Features with ambiguous interaction semantics do not ship.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Surface type is declared.
|
||||||
|
- Primary inspect/open model is defined.
|
||||||
|
- Row-click rule is decided.
|
||||||
|
- View/Inspect is correctly present or correctly forbidden.
|
||||||
|
- Edit-as-inspect is used only when allowed.
|
||||||
|
- Secondary actions are grouped correctly.
|
||||||
|
- Destructive actions are placed correctly.
|
||||||
|
- Overflow is not empty.
|
||||||
|
- Bulk is justified.
|
||||||
|
- Inline labels are short.
|
||||||
|
- Scope signals are truthful.
|
||||||
|
- Canonical nouns stay consistent.
|
||||||
|
- Critical truth is visible.
|
||||||
|
- Scanability is preserved.
|
||||||
|
- Exceptions are documented and tested.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Row click and View open the same destination.
|
||||||
|
- A row becomes a control center.
|
||||||
|
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||||
|
- More menus or bulk menus are empty.
|
||||||
|
- Scope chips have no real scope effect.
|
||||||
|
- Runs and Operations are used as competing primary collection nouns.
|
||||||
|
- Long workflow labels live in dense tables.
|
||||||
|
- Edit is used as default inspect even though a true View surface exists.
|
||||||
|
- Queue surfaces throw the operator out of context through row click.
|
||||||
|
- Critical health or operability truth is hidden by default.
|
||||||
|
- A contract claims conformance while the rendered UI behaves differently.
|
||||||
|
- 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
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
@ -320,6 +835,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
@ -341,9 +889,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### Scope, Compliance, and Review Expectations
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
|
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||||
|
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||||
|
|
||||||
### Amendment Procedure
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
@ -355,4 +906,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23
|
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||||
|
|||||||
236
.specify/memory/spec-approval-rubric.md
Normal file
236
.specify/memory/spec-approval-rubric.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# TenantPilot Spec Approval Rubric (Anti-Overengineering Guardrails)
|
||||||
|
|
||||||
|
## Leitsatz
|
||||||
|
|
||||||
|
> Kein neuer Layer ohne klaren Operatorgewinn, und kein neuer Spec nur für interne semantische Schönheit.
|
||||||
|
|
||||||
|
Ein neuer Spec ist nur dann stark genug, wenn er **sichtbar mehr Produktwahrheit oder Operator-Wirkung** erzeugt als er dauerhafte Systemkomplexität importiert.
|
||||||
|
|
||||||
|
Jeder Spec muss zwei Dinge gleichzeitig beweisen:
|
||||||
|
|
||||||
|
1. Welches echte Problem wird gelöst?
|
||||||
|
2. Warum ist diese Lösung die kleinste enterprise-taugliche Form?
|
||||||
|
|
||||||
|
Wenn der Spec nur interne Eleganz, feinere Semantik oder mehr Konsistenz bringt, aber keinen klaren Workflow-, Trust- oder Audit-Gewinn, dann ist er **verdächtig**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 Pflichtfragen vor jeder Freigabe
|
||||||
|
|
||||||
|
Ein Spec darf nur weiterverfolgt werden, wenn diese 5 Fragen sauber beantwortet sind.
|
||||||
|
|
||||||
|
### A. Welcher konkrete Operator-Workflow wird besser?
|
||||||
|
|
||||||
|
Nicht abstrakt „Konsistenz verbessern", sondern konkret: welcher Nutzer, auf welcher Fläche, in welchem Schritt, mit welchem heutigen Schmerz, und was danach schneller, sicherer oder ehrlicher wird.
|
||||||
|
|
||||||
|
Wenn kein klarer Vorher/Nachher-Workflow benennbar ist → Spec ist zu abstrakt.
|
||||||
|
|
||||||
|
### B. Welche falsche oder gefährliche Produktaussage wird verhindert?
|
||||||
|
|
||||||
|
Legitime Antworten:
|
||||||
|
|
||||||
|
- Falscher „alles okay"-Eindruck
|
||||||
|
- Irreführende Recovery-Claims
|
||||||
|
- Unsaubere Ownership
|
||||||
|
- Fehlende nächste Aktion
|
||||||
|
- Fehlende Audit-Nachvollziehbarkeit
|
||||||
|
- Tenant/Workspace Leakage
|
||||||
|
- RBAC-Missverständnisse
|
||||||
|
|
||||||
|
Wenn ein Spec weder Workflow noch Trust verbessert → kaum zu rechtfertigen.
|
||||||
|
|
||||||
|
### C. Was ist die kleinste brauchbare Version?
|
||||||
|
|
||||||
|
Explizit benennen:
|
||||||
|
|
||||||
|
- Was ist die v1-Minimalversion?
|
||||||
|
- Welche Teile sind bewusst nicht enthalten?
|
||||||
|
- Welche Generalisierung wird absichtlich verschoben?
|
||||||
|
|
||||||
|
Wenn v1 wie ein Framework, eine Plattform oder eine universelle Taxonomie klingt → zu groß.
|
||||||
|
|
||||||
|
### D. Welche dauerhafte Komplexität entsteht?
|
||||||
|
|
||||||
|
Nicht nur Implementierungsaufwand, sondern Dauerfolgen:
|
||||||
|
|
||||||
|
- Neue Models / Tables?
|
||||||
|
- Neue Enums / Statusachsen?
|
||||||
|
- Neue UI-Semantik?
|
||||||
|
- Neue cross-surface Contracts?
|
||||||
|
- Neue Tests, die dauerhaft gepflegt werden müssen?
|
||||||
|
- Neue Begriffe, die jeder verstehen muss?
|
||||||
|
|
||||||
|
Wenn die Liste lang ist → Produktgewinn muss entsprechend hoch sein.
|
||||||
|
|
||||||
|
### E. Warum jetzt?
|
||||||
|
|
||||||
|
Legitime Gründe:
|
||||||
|
|
||||||
|
- Blockiert Kernworkflow
|
||||||
|
- Verhindert gefährliche Fehlinterpretation
|
||||||
|
- Ist Voraussetzung für unmittelbar folgende Hauptdomäne
|
||||||
|
- Beseitigt echten systemischen Widerspruch
|
||||||
|
- Wird bereits von mehreren Flächen schmerzhaft benötigt
|
||||||
|
|
||||||
|
Schwache Gründe:
|
||||||
|
|
||||||
|
- „wäre sauberer"
|
||||||
|
- „brauchen wir später bestimmt"
|
||||||
|
- „passt gut zur Architektur"
|
||||||
|
- „macht das Modell vollständiger"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 Spec-Klassen
|
||||||
|
|
||||||
|
Jeden Kandidaten zwingend in genau eine Klasse einordnen.
|
||||||
|
|
||||||
|
### Klasse 1 — Core Enterprise Spec
|
||||||
|
|
||||||
|
Mindestens eins muss stimmen:
|
||||||
|
|
||||||
|
- Schützt echte System-/Tenant-/RBAC-Korrektheit
|
||||||
|
- Verhindert falsche Governance-/Recovery-/Audit-Aussagen
|
||||||
|
- Schließt klaren Workflow-Gap
|
||||||
|
- Beseitigt cross-surface Widerspruch mit realem Operator-Schaden
|
||||||
|
- Ist echte Voraussetzung für eine wichtige Produktfunktion
|
||||||
|
|
||||||
|
Dürfen Komplexität einführen, aber nur gezielt.
|
||||||
|
|
||||||
|
### Klasse 2 — Workflow Compression Spec
|
||||||
|
|
||||||
|
Gut, wenn sie:
|
||||||
|
|
||||||
|
- Klickpfade verkürzen
|
||||||
|
- Kontextverlust senken
|
||||||
|
- Return-/Drilldown-Kontinuität verbessern
|
||||||
|
- Triage-/Review-/Run-Bearbeitung beschleunigen
|
||||||
|
|
||||||
|
Nützlich, aber klein halten.
|
||||||
|
|
||||||
|
### Klasse 3 — Cleanup / Consolidation
|
||||||
|
|
||||||
|
- Vereinfachung, Zusammenführung, Entkopplung
|
||||||
|
- Entfernen von Legacy / Duplikaten
|
||||||
|
- Reduktion unnötiger Schichten
|
||||||
|
|
||||||
|
Explizit erwünscht als Gegengewicht zu Wachstum.
|
||||||
|
|
||||||
|
### Klasse 4 — Premature / Defer
|
||||||
|
|
||||||
|
Wenn der Kandidat hauptsächlich bringt:
|
||||||
|
|
||||||
|
- Neue Semantik, Frameworks, Taxonomien
|
||||||
|
- Generalisierung für künftige Fälle
|
||||||
|
- Infrastruktur ohne breite aktuelle Nutzung
|
||||||
|
|
||||||
|
→ Nicht freigeben. Verschieben oder brutal einkürzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rote Flaggen
|
||||||
|
|
||||||
|
Wenn **zwei oder mehr** zutreffen → Spec muss aktiv verteidigt werden.
|
||||||
|
|
||||||
|
| # | Rote Flagge | Prüffrage |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Neue Achsen** — neues Truth-Modell, Statusdimension, Taxonomie, Bewertungsachse | Braucht der Operator das wirklich, oder nur das Modell? |
|
||||||
|
| 2 | **Neue Meta-Infrastruktur** — Presenter, Resolver, Catalog, Matrix, Registry, Builder, Policy-Layer | Sehr hoher Beweiswert nötig. |
|
||||||
|
| 3 | **Viele Flächen, wenig Nutzerwert** — 6 Flächen „harmonisiert", kein klarer Nutzerflow besser | Architektur um ihrer selbst willen? |
|
||||||
|
| 4 | **Klingt nach Foundation** — foundation, framework, generalized, reusable, future-proof, canonical semantics | Fast immer erklärungsbedürftig. |
|
||||||
|
| 5 | **Mehr Begriffe als Outcomes** — lange semantische Erklärung, Nutzerverbesserung kaum in einem Satz | Verdächtig. |
|
||||||
|
| 6 | **Mehrere Mikrospecs für eine Domäne** — foundation + semantics + presentation + hardening + integration | Zu fein zerlegt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grüne Flaggen
|
||||||
|
|
||||||
|
- Löst klar beobachtbaren Operator-Schmerz
|
||||||
|
- Verbessert echte Entscheidungssituation
|
||||||
|
- Verhindert konkrete Fehlinterpretation
|
||||||
|
- Reduziert Navigation oder Denkaufwand
|
||||||
|
- Vereinfacht bereits existierende Komplexität
|
||||||
|
- Führt wenig neue Begriffe ein
|
||||||
|
- Hat klare Nicht-Ziele
|
||||||
|
- Ist in einer Sitzung gut erklärbar
|
||||||
|
- Braucht keine neue Meta-Schicht
|
||||||
|
- Macht mehrere Flächen einfacher statt abstrakter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewertungsraster (0–2 pro Dimension)
|
||||||
|
|
||||||
|
| Dimension | 0 | 1 | 2 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nutzen** | unklar | lokal nützlich | klarer Workflow-/Trust-/Audit-Gewinn |
|
||||||
|
| **Dringlichkeit** | kann warten | sinnvoll bald | blockiert oder schützt Wichtiges jetzt |
|
||||||
|
| **Scope-Disziplin** | wirkt wie Framework/Plattform | etwas breit | klar begrenzte v1 |
|
||||||
|
| **Komplexitätslast** | hohe dauerhafte Last | mittel | niedrig / gut beherrschbar |
|
||||||
|
| **Produktnähe** | vor allem intern/architektonisch | gemischt | direkt spürbar für Operatoren |
|
||||||
|
| **Wiederverwendung belegt** | hypothetisch | wahrscheinlich | bereits an mehreren echten Stellen nötig |
|
||||||
|
|
||||||
|
### Auswertung
|
||||||
|
|
||||||
|
| Score | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| **10–12** | Freigabefähig |
|
||||||
|
| **7–9** | Nur freigeben wenn Scope enger gezogen wird |
|
||||||
|
| **4–6** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
|
||||||
|
| **0–3** | Nicht freigeben |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TenantPilot-spezifische Regeln
|
||||||
|
|
||||||
|
### Regel A — Keine neue semantische Achse ohne UI-Beweis
|
||||||
|
|
||||||
|
Wo wird sie sichtbar? Warum reichen bestehende Achsen nicht? Welche Fehlentscheidung bleibt ohne sie bestehen?
|
||||||
|
|
||||||
|
### Regel B — Keine neue Support-/Presentation-Schicht ohne ≥ 3 echte Verbraucher
|
||||||
|
|
||||||
|
Registry, Resolver, Catalog, Presenter, Matrix, Explanation-Layer → nur mit mindestens drei echten (nicht künstlich erzeugten) Verbrauchern. Sonst lokal lösen.
|
||||||
|
|
||||||
|
### Regel C — Keine Spec-Aufspaltung unterhalb Operator-Domäne
|
||||||
|
|
||||||
|
Wenn ein Thema nicht eigenständig als Operator-Problem beschrieben werden kann → kein eigener Spec.
|
||||||
|
|
||||||
|
### Regel D — Jeder neue Status braucht eine echte Folgehandlung
|
||||||
|
|
||||||
|
Neue Status/Outcome nur erlaubt wenn sie etwas Konkretes ändern: andere nächste Aktion, anderes Routing, andere Audit-Bedeutung, andere Workflow-Behandlung.
|
||||||
|
|
||||||
|
### Regel E — Consolidation ist ein legitimer Spec-Typ
|
||||||
|
|
||||||
|
Zusammenführen von Semantik, Reduktion von Komplexität, Entfernen von Parallelmodellen, Vereinfachung von Navigation/Resolvern, Rückbau unnötiger Zwischenlayer — aktiv Platz geben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabe-Template (Pflichtabschnitt in spec.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Spec Candidate Check
|
||||||
|
|
||||||
|
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||||
|
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||||
|
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||||
|
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||||
|
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||||
|
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||||
|
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||||
|
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||||
|
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||||
|
- **Red flags triggered**: [Welche roten Flaggen treffen zu?]
|
||||||
|
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||||
|
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erlaubt vs. Verdächtig (Schnellreferenz)
|
||||||
|
|
||||||
|
| Erlaubt | Verdächtig |
|
||||||
|
|---|---|
|
||||||
|
| Echte Workflow-Specs | Neue truth sub-axes |
|
||||||
|
| Governance-/Finding-/Review-Bearbeitbarkeit | Neue explanation frameworks |
|
||||||
|
| Trust-/Audit-/RBAC-Härtung | Neue presentation taxonomies |
|
||||||
|
| Portfolio-Operator-Durchsatzverbesserungen | Neue generalized support layers |
|
||||||
|
| Consolidation-Specs | Mikro-Specs für bereits stark zerlegte Domänen |
|
||||||
@ -48,8 +48,30 @@ ## Constitution Check
|
|||||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI 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-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
- UI/UX placeholder ban (UI-HARD-001): empty `ActionGroup` / `BulkActionGroup` placeholders and declaration-only UI conformance are forbidden
|
||||||
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
|
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||||
|
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||||
|
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||||
|
- Operator surfaces (OPSURF-001): 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 (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
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
@ -113,9 +135,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
|
|||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
<!-- This section MUST be completed before the spec progresses beyond Draft.
|
||||||
|
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
|
||||||
|
|
||||||
|
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||||
|
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||||
|
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||||
|
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||||
|
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||||
|
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||||
|
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||||
|
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||||
|
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||||
|
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
|
||||||
|
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||||
|
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
- **Scope**: [workspace | tenant | canonical-view]
|
- **Scope**: [workspace | tenant | canonical-view]
|
||||||
@ -17,6 +35,44 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
|
fill out one row per affected surface.
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | CRUD / List-first Resource | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -94,6 +150,16 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||||
@ -119,9 +185,55 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
|
- the target object,
|
||||||
|
- the operator verb,
|
||||||
|
- whether source/domain disambiguation is actually needed,
|
||||||
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||||
|
- the chosen surface type and why it is the correct classification,
|
||||||
|
- the one and only primary inspect/open model,
|
||||||
|
- whether row click is required, allowed, or forbidden,
|
||||||
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where secondary actions live,
|
||||||
|
- where destructive actions live,
|
||||||
|
- the canonical collection route and canonical detail route,
|
||||||
|
- the scope signals shown to the operator and what real effect each one has,
|
||||||
|
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||||
|
- which critical operational truth is visible by default,
|
||||||
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||||
|
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
@ -150,7 +262,7 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
|||||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
|||||||
@ -32,14 +32,39 @@ # Tasks: [FEATURE NAME]
|
|||||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- at least one positive + one negative authorization test.
|
||||||
|
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
|
||||||
|
- aligning primary action labels to `Verb + Object`,
|
||||||
|
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
|
||||||
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
|
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||||
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
|
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||||
|
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||||
|
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||||
|
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||||
|
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||||
|
- moving additional secondary actions into More or the detail header,
|
||||||
|
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
|
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
@ -47,10 +72,18 @@ # Tasks: [FEATURE NAME]
|
|||||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
- 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,
|
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -197,6 +230,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
81
Agents.md
81
Agents.md
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
@ -316,12 +318,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -333,10 +336,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -350,11 +353,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -431,7 +434,7 @@ ## 3) Panel setup defaults
|
|||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
@ -668,7 +671,7 @@ ## Testing
|
|||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
|
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
@ -681,7 +684,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.4.15
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -718,7 +721,9 @@ ## Application Structure & Architecture
|
|||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
|
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
@ -810,28 +815,28 @@ ## PHPDoc Blocks
|
|||||||
# Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
# Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
# Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@ -844,7 +849,7 @@ ## Database
|
|||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
|
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
@ -875,11 +880,11 @@ ## Testing
|
|||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
## Vite Error
|
## Vite Error
|
||||||
|
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
@ -910,15 +915,15 @@ ### Models
|
|||||||
|
|
||||||
# Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- Do NOT delete tests without approval.
|
- Do NOT delete tests without approval.
|
||||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
|
|||||||
77
GEMINI.md
77
GEMINI.md
@ -156,12 +156,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -173,10 +174,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -190,11 +191,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -271,7 +272,7 @@ ## 3) Panel setup defaults
|
|||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
@ -508,7 +509,7 @@ ## Testing
|
|||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
|
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
@ -521,7 +522,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.4.15
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -558,7 +559,9 @@ ## Application Structure & Architecture
|
|||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
|
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||||
|
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||||
|
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
@ -650,28 +653,28 @@ ## PHPDoc Blocks
|
|||||||
# Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
# Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
# Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@ -684,7 +687,7 @@ ## Database
|
|||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
|
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
@ -715,11 +718,11 @@ ## Testing
|
|||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
## Vite Error
|
## Vite Error
|
||||||
|
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
@ -750,15 +753,15 @@ ### Models
|
|||||||
|
|
||||||
# Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- Do NOT delete tests without approval.
|
- Do NOT delete tests without approval.
|
||||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
|
|||||||
134
README.md
134
README.md
@ -1,19 +1,50 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# TenantPilot Workspace
|
||||||
|
|
||||||
<p align="center">
|
TenantPilot is an Intune management platform built around a stable Laravel application in
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
`apps/platform` and, starting with Spec 183, a standalone public Astro website in
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
`apps/website`. The repository root is now the official JavaScript workspace entry point and
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
orchestrates app-local commands without becoming a runtime itself.
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## TenantPilot setup
|
## Multi-App Topology
|
||||||
|
|
||||||
|
- `apps/platform`: the Laravel 12 + Filament v5 + Livewire v4 product runtime
|
||||||
|
- `apps/website`: the Astro v6 public website runtime
|
||||||
|
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
|
||||||
|
- `./scripts/platform-sail`: platform-only compatibility helper for tooling that cannot set `cwd`
|
||||||
|
|
||||||
|
## Official Root Commands
|
||||||
|
|
||||||
|
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
|
||||||
|
- Start the platform stack: `corepack pnpm dev:platform`
|
||||||
|
- Start the website dev server: `corepack pnpm dev:website`
|
||||||
|
- Start platform + website together: `corepack pnpm dev`
|
||||||
|
- Build the website: `corepack pnpm build:website`
|
||||||
|
- Build platform frontend assets: `corepack pnpm build:platform`
|
||||||
|
|
||||||
|
## App-Local Commands
|
||||||
|
|
||||||
|
### Platform
|
||||||
|
|
||||||
|
- Install PHP dependencies: `cd apps/platform && composer install`
|
||||||
|
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||||
|
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||||
|
- Run frontend watch/build inside Sail: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||||
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
|
### Website
|
||||||
|
|
||||||
|
- Start the dev server: `cd apps/website && pnpm dev`
|
||||||
|
- Build the static site: `cd apps/website && pnpm build`
|
||||||
|
|
||||||
|
## Port Overrides
|
||||||
|
|
||||||
|
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Website dev server port: set `WEBSITE_PORT` before `corepack pnpm dev:website` or pass `--port <port>` to `cd apps/website && pnpm dev`
|
||||||
|
- Parallel local development keeps both apps isolated, even when one or both ports are overridden
|
||||||
|
|
||||||
|
## Platform Setup Notes
|
||||||
|
|
||||||
- Local dev (Sail-first):
|
|
||||||
- Start stack: `./vendor/bin/sail up -d`
|
|
||||||
- Init DB: `./vendor/bin/sail artisan migrate --seed`
|
|
||||||
- Tests: `./vendor/bin/sail artisan test`
|
|
||||||
- Policy sync: `./vendor/bin/sail artisan intune:sync-policies`
|
|
||||||
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
||||||
- Microsoft Graph (Intune) env vars:
|
- Microsoft Graph (Intune) env vars:
|
||||||
- `GRAPH_TENANT_ID`
|
- `GRAPH_TENANT_ID`
|
||||||
@ -25,10 +56,17 @@ ## TenantPilot setup
|
|||||||
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||||
- Deployment (Dokploy, staging → production):
|
- Deployment (Dokploy, staging → production):
|
||||||
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
||||||
|
- Run application commands from `apps/platform`, including `php artisan filament:assets`.
|
||||||
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
||||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||||
- Keep secrets/env in Dokploy, never in code.
|
- Keep secrets/env in Dokploy, never in code.
|
||||||
|
|
||||||
|
## Platform relocation rollout notes
|
||||||
|
|
||||||
|
- Open branches that still touch legacy root app paths should merge `dev` first, then remap file moves from `app/`, `bootstrap/`, `config/`, `database/`, `lang/`, `public/`, `resources/`, `routes/`, `storage/`, and `tests/` into `apps/platform/...`.
|
||||||
|
- Keep using merge-based catch-up on shared feature branches; do not rebase long-lived shared branches just to absorb the relocation.
|
||||||
|
- VS Code tasks expose the official root workspace commands, while MCP launchers remain platform-only and delegate through `./scripts/platform-sail`.
|
||||||
|
|
||||||
## Bulk operations (Feature 005)
|
## Bulk operations (Feature 005)
|
||||||
|
|
||||||
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||||
@ -39,8 +77,23 @@ ### Troubleshooting
|
|||||||
|
|
||||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
- Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
|
||||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||||
|
- **Moved app but old commands still fail** usually means the command is still being run from repo root. Switch to `cd apps/platform && ...` or use `./scripts/platform-sail ...` only for tooling that cannot set `cwd`.
|
||||||
|
|
||||||
|
## Rollback checklist
|
||||||
|
|
||||||
|
1. Revert the relocation commit or merge on your feature branch instead of hard-resetting shared history.
|
||||||
|
2. Preserve any local app env overrides before switching commits: `cp apps/platform/.env /tmp/tenantatlas.platform.env.backup` if needed.
|
||||||
|
3. Stop local containers and clean generated artifacts: `cd apps/platform && ./vendor/bin/sail down -v`, then remove `apps/platform/vendor`, `apps/platform/node_modules`, `apps/platform/public/build`, and `apps/platform/public/hot` if they need a clean rebuild.
|
||||||
|
4. After rollback, restore the matching env file for the restored topology and rerun the documented setup flow for that commit.
|
||||||
|
5. Notify owners of open feature branches that the topology changed so they can remap outstanding work before the next merge from `dev`.
|
||||||
|
|
||||||
|
## Deployment unknowns
|
||||||
|
|
||||||
|
- Dokploy build context for a repo-root compose file plus an app-root Laravel runtime still needs staging confirmation.
|
||||||
|
- Production web, queue, and scheduler working directories must be verified explicitly after the move; do not assume repo root and app root behave interchangeably.
|
||||||
|
- Any Dokploy volume mounts or storage persistence paths that previously targeted repo-root `storage/` must be reviewed against `apps/platform/storage/`.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@ -64,7 +117,7 @@ ## Graph Contract Registry & Drift Guard
|
|||||||
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
||||||
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
||||||
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
||||||
- Drift check: `php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
- Drift check: `cd apps/platform && php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
||||||
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
||||||
|
|
||||||
## Policy Settings Display
|
## Policy Settings Display
|
||||||
@ -89,54 +142,3 @@ ## Policy JSON Viewer (Feature 002)
|
|||||||
- Scrollable container with max height to prevent page overflow
|
- Scrollable container with max height to prevent page overflow
|
||||||
- **Usage**: See `specs/002-filament-json/quickstart.md` for detailed examples and configuration
|
- **Usage**: See `specs/002-filament-json/quickstart.md` for detailed examples and configuration
|
||||||
- **Performance**: Optimized for payloads up to 1 MB; auto-collapse improves initial render for large snapshots
|
- **Performance**: Optimized for payloads up to 1 MB; auto-collapse improves initial render for large snapshots
|
||||||
|
|
||||||
## About Laravel
|
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
|
||||||
|
|
||||||
## Learning Laravel
|
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
|
||||||
|
|
||||||
## Laravel Sponsors
|
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
|
||||||
|
|
||||||
### Premium Partners
|
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class AuditLog extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Audit Log';
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'audit-log';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Audit Log';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return app(OperateHubShell::class)->headerActions(
|
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class Operations extends Page implements HasForms, HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
public string $activeTab = 'all';
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Operations';
|
|
||||||
|
|
||||||
// Must be non-static
|
|
||||||
protected string $view = 'filament.pages.monitoring.operations';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('operate_hub_scope_operations')
|
|
||||||
->label($operateHubShell->scopeLabel(request()))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
|
||||||
->label('Back to '.$activeTenant->name)
|
|
||||||
->icon('heroicon-o-arrow-left')
|
|
||||||
->color('gray')
|
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
|
||||||
|
|
||||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
|
||||||
->label('Show all tenants')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (): void {
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
|
|
||||||
$this->redirect('/admin/operations');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return OperationRunResource::table($table)
|
|
||||||
->query(function (): Builder {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
$query = OperationRun::query()
|
|
||||||
->with('user')
|
|
||||||
->latest('id')
|
|
||||||
->when(
|
|
||||||
$workspaceId,
|
|
||||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
! $workspaceId,
|
|
||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$activeTenant instanceof Tenant,
|
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->applyActiveTab($query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return match ($this->activeTab) {
|
|
||||||
'active' => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
]),
|
|
||||||
'succeeded' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
|
||||||
'partial' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
|
||||||
'failed' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,272 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Operations;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use App\Support\RedactionIntegrity;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
|
||||||
{
|
|
||||||
use AuthorizesRequests;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $title = 'Operation run';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action|ActionGroup>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('operate_hub_scope_run_detail')
|
|
||||||
->label($operateHubShell->scopeLabel(request()))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
|
||||||
->label('← Back to '.$activeTenant->name)
|
|
||||||
->color('gray')
|
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
|
||||||
|
|
||||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
|
||||||
->label('Show all operations')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => route('admin.operations.index'));
|
|
||||||
} else {
|
|
||||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
|
||||||
->label('Back to Operations')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => route('admin.operations.index'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('refresh')
|
|
||||||
->label('Refresh')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => isset($this->run)
|
|
||||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
|
||||||
: route('admin.operations.index'));
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $tenant);
|
|
||||||
|
|
||||||
$relatedActions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
|
||||||
->label((string) $label)
|
|
||||||
->url((string) $url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($relatedActions !== []) {
|
|
||||||
$actions[] = ActionGroup::make($relatedActions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray');
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = $this->resumeCaptureAction();
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('view', $run);
|
|
||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return OperationRunResource::infolist($schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function defaultInfolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->record($this->run)
|
|
||||||
->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function redactionIntegrityNote(): ?string
|
|
||||||
{
|
|
||||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
EmbeddedSchema::make('infolist'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resumeCaptureAction(): Action
|
|
||||||
{
|
|
||||||
return Action::make('resumeCapture')
|
|
||||||
->label('Resume capture')
|
|
||||||
->icon('heroicon-o-forward')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Resume capture')
|
|
||||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
|
||||||
->visible(fn (): bool => $this->canResumeCapture())
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run not loaded')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
|
||||||
$result = $service->resume($this->run, $user);
|
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
|
||||||
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Cannot resume capture')
|
|
||||||
->body('Reason: '.str_replace('.', ' ', $reason))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $result['run'] ?? null;
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Cannot resume capture')
|
|
||||||
->body('Reason: missing operation run')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$viewAction = Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::tenantlessView($run));
|
|
||||||
|
|
||||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
|
||||||
->actions([$viewAction])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $run->type)
|
|
||||||
->actions([$viewAction])
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canResumeCapture(): bool
|
|
||||||
{
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $this->run->status !== 'completed') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
|
||||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
|
||||||
? 'baseline_capture.resume_token'
|
|
||||||
: 'baseline_compare.resume_token';
|
|
||||||
$token = data_get($context, $tokenKey);
|
|
||||||
|
|
||||||
if (! is_string($token) || trim($token) === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = $this->run->workspace;
|
|
||||||
|
|
||||||
if (! $workspace instanceof \App\Models\Workspace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->isMember($user, $workspace)
|
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListBackupSchedules extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupScheduleResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
BackupScheduleResource::makeCreateAction()
|
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
BackupScheduleResource::makeCreateAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListBackupSets extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
BackupSetResource::makeCreateAction()
|
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
BackupSetResource::makeCreateAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewBackupSet extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineSnapshotResource;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class ViewBaselineSnapshot extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = BaselineSnapshotResource::class;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
public array $presentedSnapshot = [];
|
|
||||||
|
|
||||||
public function mount(int|string $record): void
|
|
||||||
{
|
|
||||||
parent::mount($record);
|
|
||||||
|
|
||||||
$snapshot = $this->getRecord();
|
|
||||||
|
|
||||||
if ($snapshot instanceof BaselineSnapshot) {
|
|
||||||
$this->presentedSnapshot = app(BaselineSnapshotPresenter::class)
|
|
||||||
->present($snapshot)
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$snapshot = $this->getRecord();
|
|
||||||
$workspace = BaselineSnapshotResource::resolveWorkspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $snapshot instanceof BaselineSnapshot || ! $workspace instanceof Workspace) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $snapshot->workspace_id !== (int) $workspace->getKey()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $workspace)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Snapshot')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('snapshot_id')
|
|
||||||
->label('Snapshot')
|
|
||||||
->state(function (): string {
|
|
||||||
$snapshotId = data_get($this->presentedSnapshot, 'snapshot.snapshotId');
|
|
||||||
|
|
||||||
return is_numeric($snapshotId) ? '#'.$snapshotId : '—';
|
|
||||||
}),
|
|
||||||
TextEntry::make('baseline_profile_name')
|
|
||||||
->label('Baseline')
|
|
||||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.baselineProfileName', '—'))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('captured_at')
|
|
||||||
->label('Captured')
|
|
||||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.capturedAt'))
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('state_label')
|
|
||||||
->label('State')
|
|
||||||
->badge()
|
|
||||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.stateLabel', 'Complete'))
|
|
||||||
->color(fn (string $state): string => $state === 'Captured with gaps' ? 'warning' : 'success'),
|
|
||||||
TextEntry::make('overall_fidelity')
|
|
||||||
->label('Overall fidelity')
|
|
||||||
->badge()
|
|
||||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.overallFidelity'))
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineSnapshotFidelity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BaselineSnapshotFidelity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineSnapshotFidelity)),
|
|
||||||
TextEntry::make('fidelity_summary')
|
|
||||||
->label('Evidence mix')
|
|
||||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.fidelitySummary', 'Content 0, Meta 0')),
|
|
||||||
TextEntry::make('overall_gap_count')
|
|
||||||
->label('Evidence gaps')
|
|
||||||
->state(fn (): int => (int) data_get($this->presentedSnapshot, 'snapshot.overallGapCount', 0)),
|
|
||||||
TextEntry::make('snapshot_identity_hash')
|
|
||||||
->label('Identity hash')
|
|
||||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.snapshotIdentityHash'))
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Coverage summary')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('summary_rows')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.baseline-snapshot-summary-table')
|
|
||||||
->state(fn (): array => data_get($this->presentedSnapshot, 'summaryRows', []))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Captured policy types')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('groups')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.baseline-snapshot-groups')
|
|
||||||
->state(fn (): array => data_get($this->presentedSnapshot, 'groups', []))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Technical detail')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('technical_detail')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.baseline-snapshot-technical-detail')
|
|
||||||
->state(fn (): array => data_get($this->presentedSnapshot, 'technicalDetail', []))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->collapsible()
|
|
||||||
->collapsed()
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewEntraGroup extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupResource::class;
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
|
||||||
->label('Actions')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSubheading(): string|Htmlable|null
|
|
||||||
{
|
|
||||||
return FindingResource::findingSubheading($this->getRecord());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,708 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
|
||||||
use App\Filament\Support\VerificationReportViewer;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\VerificationCheckAcknowledgement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
|
||||||
use App\Support\RedactionIntegrity;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class OperationRunResource extends Resource
|
|
||||||
{
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = OperationRun::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'operations';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Operations';
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
|
||||||
moreGroupLabel: 'More',
|
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
|
||||||
))
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListHeader,
|
|
||||||
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
|
||||||
)
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListBulkMoreGroup,
|
|
||||||
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
|
||||||
)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::ListEmptyState,
|
|
||||||
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
|
|
||||||
)
|
|
||||||
->exempt(
|
|
||||||
ActionSurfaceSlot::DetailHeader,
|
|
||||||
'Tenantless detail view is informational and currently has no header actions.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('user')
|
|
||||||
->latest('id')
|
|
||||||
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
|
||||||
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Run')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('type')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
TextEntry::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextEntry::make('initiator_name')->label('Initiator'),
|
|
||||||
TextEntry::make('target_scope_display')
|
|
||||||
->label('Target')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('target_scope_empty_state')
|
|
||||||
->label('Target')
|
|
||||||
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('elapsed')
|
|
||||||
->label('Elapsed')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
|
||||||
TextEntry::make('expected_duration')
|
|
||||||
->label('Expected')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
|
|
||||||
TextEntry::make('stuck_guidance')
|
|
||||||
->label('')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
|
|
||||||
TextEntry::make('created_at')->dateTime(),
|
|
||||||
TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
|
|
||||||
])
|
|
||||||
->extraAttributes([
|
|
||||||
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
])
|
|
||||||
->poll(function (OperationRun $record, $livewire): ?string {
|
|
||||||
if (($livewire->opsUxIsTabHidden ?? false) === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($livewire->mountedActions ?? null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RunDetailPolling::interval($record);
|
|
||||||
})
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Counts')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('summary_counts')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Failures')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('failure_summary')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Baseline compare')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('baseline_compare_fidelity')
|
|
||||||
->label('Fidelity')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(function (OperationRun $record): string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
|
||||||
|
|
||||||
return is_string($fidelity) && $fidelity !== '' ? $fidelity : 'meta';
|
|
||||||
}),
|
|
||||||
TextEntry::make('baseline_compare_coverage_status')
|
|
||||||
->label('Coverage')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(function (OperationRun $record): string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
|
||||||
$proof = is_bool($proof) ? $proof : null;
|
|
||||||
|
|
||||||
$uncovered = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
|
||||||
$uncovered = is_array($uncovered) ? array_values(array_filter($uncovered, 'is_string')) : [];
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$proof === false => 'unproven',
|
|
||||||
$uncovered !== [] => 'warnings',
|
|
||||||
$proof === true => 'ok',
|
|
||||||
default => 'unknown',
|
|
||||||
};
|
|
||||||
})
|
|
||||||
->color(fn (?string $state): string => match ((string) $state) {
|
|
||||||
'ok' => 'success',
|
|
||||||
'warnings', 'unproven' => 'warning',
|
|
||||||
default => 'gray',
|
|
||||||
}),
|
|
||||||
TextEntry::make('baseline_compare_why_no_findings')
|
|
||||||
->label('Why no findings')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$code = data_get($context, 'baseline_compare.reason_code');
|
|
||||||
$code = is_string($code) ? trim($code) : null;
|
|
||||||
$code = $code !== '' ? $code : null;
|
|
||||||
|
|
||||||
if ($code === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$enum = BaselineCompareReasonCode::tryFrom($code);
|
|
||||||
$message = $enum?->message();
|
|
||||||
|
|
||||||
return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : '');
|
|
||||||
})
|
|
||||||
->visible(function (OperationRun $record): bool {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$code = data_get($context, 'baseline_compare.reason_code');
|
|
||||||
|
|
||||||
return is_string($code) && trim($code) !== '';
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('baseline_compare_uncovered_types')
|
|
||||||
->label('Uncovered types')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
|
||||||
$types = is_array($types) ? array_values(array_filter($types, 'is_string')) : [];
|
|
||||||
$types = array_values(array_unique(array_filter(array_map('trim', $types), fn (string $type): bool => $type !== '')));
|
|
||||||
|
|
||||||
if ($types === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($types, SORT_STRING);
|
|
||||||
|
|
||||||
return implode(', ', array_slice($types, 0, 12)).(count($types) > 12 ? '…' : '');
|
|
||||||
})
|
|
||||||
->visible(function (OperationRun $record): bool {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
|
||||||
|
|
||||||
return is_array($types) && $types !== [];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('baseline_compare_inventory_sync_run_id')
|
|
||||||
->label('Inventory sync run')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$syncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id');
|
|
||||||
|
|
||||||
return is_numeric($syncRunId) ? '#'.(string) (int) $syncRunId : null;
|
|
||||||
})
|
|
||||||
->visible(function (OperationRun $record): bool {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
|
|
||||||
return is_numeric(data_get($context, 'baseline_compare.inventory_sync_run_id'));
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Baseline compare evidence')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('baseline_compare_subjects_total')
|
|
||||||
->label('Subjects total')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?int {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.subjects_total');
|
|
||||||
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
|
||||||
})
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('baseline_compare_gap_count')
|
|
||||||
->label('Evidence gaps')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?int {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.evidence_gaps.count');
|
|
||||||
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
|
||||||
})
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('baseline_compare_resume_token')
|
|
||||||
->label('Resume token')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.resume_token');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== '' ? $value : null;
|
|
||||||
})
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull()
|
|
||||||
->visible(function (OperationRun $record): bool {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.resume_token');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== '';
|
|
||||||
}),
|
|
||||||
ViewEntry::make('baseline_compare_evidence_capture')
|
|
||||||
->label('Evidence capture')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.evidence_capture');
|
|
||||||
|
|
||||||
return is_array($value) ? $value : [];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
ViewEntry::make('baseline_compare_evidence_gaps')
|
|
||||||
->label('Evidence gaps')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_compare.evidence_gaps');
|
|
||||||
|
|
||||||
return is_array($value) ? $value : [];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Baseline capture evidence')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('baseline_capture_subjects_total')
|
|
||||||
->label('Subjects total')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?int {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.subjects_total');
|
|
||||||
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
|
||||||
})
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('baseline_capture_gap_count')
|
|
||||||
->label('Gaps')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?int {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.gaps.count');
|
|
||||||
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
|
||||||
})
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('baseline_capture_resume_token')
|
|
||||||
->label('Resume token')
|
|
||||||
->getStateUsing(function (OperationRun $record): ?string {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.resume_token');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== '' ? $value : null;
|
|
||||||
})
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull()
|
|
||||||
->visible(function (OperationRun $record): bool {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.resume_token');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== '';
|
|
||||||
}),
|
|
||||||
ViewEntry::make('baseline_capture_evidence_capture')
|
|
||||||
->label('Evidence capture')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.evidence_capture');
|
|
||||||
|
|
||||||
return is_array($value) ? $value : [];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
ViewEntry::make('baseline_capture_gaps')
|
|
||||||
->label('Gaps')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$value = data_get($context, 'baseline_capture.gaps');
|
|
||||||
|
|
||||||
return is_array($value) ? $value : [];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_capture')
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Verification report')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('verification_report')
|
|
||||||
->label('')
|
|
||||||
->view('filament.components.verification-report-viewer')
|
|
||||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
|
||||||
->viewData(function (OperationRun $record): array {
|
|
||||||
$report = VerificationReportViewer::report($record);
|
|
||||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
|
||||||
|
|
||||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
|
||||||
|
|
||||||
$previousRunUrl = null;
|
|
||||||
|
|
||||||
if ($changeIndicator !== null) {
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
$previousRunUrl = $tenant instanceof Tenant
|
|
||||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
|
||||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
|
||||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
|
||||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
|
||||||
->where('operation_run_id', (int) $record->getKey())
|
|
||||||
->with('acknowledgedByUser')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
|
||||||
$user = $ack->acknowledgedByUser;
|
|
||||||
|
|
||||||
return [
|
|
||||||
(string) $ack->check_key => [
|
|
||||||
'check_key' => (string) $ack->check_key,
|
|
||||||
'ack_reason' => (string) $ack->ack_reason,
|
|
||||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
|
||||||
'expires_at' => $ack->expires_at?->toJSON(),
|
|
||||||
'acknowledged_by' => $user instanceof User
|
|
||||||
? [
|
|
||||||
'id' => (int) $user->getKey(),
|
|
||||||
'name' => (string) $user->name,
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'run' => [
|
|
||||||
'id' => (int) $record->getKey(),
|
|
||||||
'type' => (string) $record->type,
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'outcome' => (string) $record->outcome,
|
|
||||||
'started_at' => $record->started_at?->toJSON(),
|
|
||||||
'completed_at' => $record->completed_at?->toJSON(),
|
|
||||||
],
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'changeIndicator' => $changeIndicator,
|
|
||||||
'previousRunUrl' => $previousRunUrl,
|
|
||||||
'acknowledgements' => $acknowledgements,
|
|
||||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Integrity')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('redaction_integrity_note')
|
|
||||||
->label('')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => RedactionIntegrity::noteForRun($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => RedactionIntegrity::noteForRun($record) !== null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Context')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('context')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(function (OperationRun $record): array {
|
|
||||||
$context = $record->context ?? [];
|
|
||||||
$context = is_array($context) ? $context : [];
|
|
||||||
|
|
||||||
if (array_key_exists('verification_report', $context)) {
|
|
||||||
$context['verification_report'] = [
|
|
||||||
'redacted' => true,
|
|
||||||
'note' => 'Rendered in the Verification report section.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->paginated(TablePaginationProfiles::resource())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->since()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('duration')
|
|
||||||
->getStateUsing(function (OperationRun $record): string {
|
|
||||||
if ($record->started_at && $record->completed_at) {
|
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '—';
|
|
||||||
}),
|
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(function (): array {
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->default(function (): ?string {
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $activeTenant->getKey();
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
Tables\Filters\SelectFilter::make('type')
|
|
||||||
->options(function (): array {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$types = OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->select('type')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('type')
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
|
||||||
}),
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
|
||||||
OperationRunStatus::Running->value => 'Running',
|
|
||||||
OperationRunStatus::Completed->value => 'Completed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('outcome')
|
|
||||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
|
||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->options(function (): array {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
|
||||||
? (int) $tenant->getKey()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
|
||||||
->whereNotNull('initiator_name')
|
|
||||||
->select('initiator_name')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('initiator_name')
|
|
||||||
->pluck('initiator_name', 'initiator_name')
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at', [
|
|
||||||
'from' => now()->subDays(30)->toDateString(),
|
|
||||||
'until' => now()->toDateString(),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label('View run')
|
|
||||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No operation runs found')
|
|
||||||
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
|
||||||
->emptyStateIcon('heroicon-o-queue-list');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
|
|
||||||
$targetScope = $context['target_scope'] ?? null;
|
|
||||||
if (! is_array($targetScope)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
|
||||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
|
||||||
|
|
||||||
$entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null;
|
|
||||||
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null;
|
|
||||||
|
|
||||||
$directoryContextId = match (true) {
|
|
||||||
is_string($directoryContextId) => trim($directoryContextId),
|
|
||||||
is_int($directoryContextId) => (string) $directoryContextId,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
$entra = null;
|
|
||||||
|
|
||||||
if ($entraTenantName !== null && $entraTenantName !== '') {
|
|
||||||
$entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName;
|
|
||||||
} elseif ($entraTenantId !== null && $entraTenantId !== '') {
|
|
||||||
$entra = $entraTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = array_values(array_filter([
|
|
||||||
$entra,
|
|
||||||
$directoryContextId ? "directory_context_id: {$directoryContextId}" : null,
|
|
||||||
], fn (?string $value): bool => $value !== null && $value !== ''));
|
|
||||||
|
|
||||||
return $parts !== [] ? implode(' · ', $parts) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Filament\Support\Enums\Width;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
|
|
||||||
class ViewPolicyVersion extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = PolicyVersionResource::class;
|
|
||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
|
||||||
|
|
||||||
public function getFooter(): ?View
|
|
||||||
{
|
|
||||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
|
||||||
'record' => $this->getRecord(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewProviderConnection extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListRestoreRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
RestoreRunResource::makeCreateAction()
|
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewRestoreRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record): void {
|
|
||||||
$record->delete();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->tooltip('You do not have permission to archive tenants.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenants extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('add_tenant')
|
|
||||||
->label('Add tenant')
|
|
||||||
->icon('heroicon-m-plus')
|
|
||||||
->url(route('admin.onboarding'))
|
|
||||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTableEmptyStateActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('add_tenant')
|
|
||||||
->label('Add tenant')
|
|
||||||
->icon('heroicon-m-plus')
|
|
||||||
->url(route('admin.onboarding')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Pages\Ops;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class Failures extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Failures';
|
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
|
||||||
|
|
||||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'ops/failures';
|
|
||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.failures';
|
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
|
||||||
{
|
|
||||||
$count = OperationRun::query()
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return $count > 0 ? (string) $count : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationBadgeColor(): string|array|null
|
|
||||||
{
|
|
||||||
return 'danger';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('id', 'desc')
|
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
|
||||||
->query(function (): Builder {
|
|
||||||
return OperationRun::query()
|
|
||||||
->with(['tenant', 'workspace'])
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value);
|
|
||||||
})
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('id')
|
|
||||||
->label('Run')
|
|
||||||
->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)),
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('workspace.name')
|
|
||||||
->label('Workspace')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant')
|
|
||||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('created_at')->label('Started')->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
|
||||||
->actions([
|
|
||||||
Action::make('retry')
|
|
||||||
->label('Retry')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$retryRun = $triageService->retry($record, $user);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No failed runs found')
|
|
||||||
->emptyStateDescription('Failed operations will appear here for triage.')
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Pages\Ops;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
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 Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class Runs extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Runs';
|
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'ops/runs';
|
|
||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.runs';
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('id', 'desc')
|
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
|
||||||
->query(function (): Builder {
|
|
||||||
return OperationRun::query()
|
|
||||||
->with(['tenant', 'workspace']);
|
|
||||||
})
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('id')
|
|
||||||
->label('Run')
|
|
||||||
->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)),
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('workspace.name')
|
|
||||||
->label('Workspace')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant')
|
|
||||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('initiator_name')->label('Initiator'),
|
|
||||||
TextColumn::make('created_at')->label('Started')->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
|
||||||
->actions([
|
|
||||||
Action::make('retry')
|
|
||||||
->label('Retry')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$retryRun = $triageService->retry($record, $user);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No operation runs yet')
|
|
||||||
->emptyStateDescription('Runs from all workspaces will appear here when operations are queued.')
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Pages\Ops;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Services\SystemConsole\OperationRunTriageService;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
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 Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class Stuck extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Stuck';
|
|
||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
|
||||||
|
|
||||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'ops/stuck';
|
|
||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.stuck';
|
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
|
||||||
{
|
|
||||||
$count = app(StuckRunClassifier::class)
|
|
||||||
->apply(OperationRun::query())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return $count > 0 ? (string) $count : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationBadgeColor(): string|array|null
|
|
||||||
{
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('id', 'desc')
|
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
|
||||||
->query(function (): Builder {
|
|
||||||
return app(StuckRunClassifier::class)->apply(
|
|
||||||
OperationRun::query()
|
|
||||||
->with(['tenant', 'workspace'])
|
|
||||||
);
|
|
||||||
})
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('id')
|
|
||||||
->label('Run')
|
|
||||||
->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)),
|
|
||||||
TextColumn::make('stuck_class')
|
|
||||||
->label('Stuck class')
|
|
||||||
->state(function (OperationRun $record): string {
|
|
||||||
$classification = app(StuckRunClassifier::class)->classify($record);
|
|
||||||
|
|
||||||
return $classification === OperationRunStatus::Queued->value ? 'Queued too long' : 'Running too long';
|
|
||||||
}),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('workspace.name')
|
|
||||||
->label('Workspace')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant')
|
|
||||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('created_at')->label('Started')->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
|
||||||
->actions([
|
|
||||||
Action::make('retry')
|
|
||||||
->label('Retry')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$retryRun = $triageService->retry($record, $user);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($retryRun)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('cancel')
|
|
||||||
->label('Cancel')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
|
||||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->cancel($record, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run cancelled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('mark_investigated')
|
|
||||||
->label('Mark investigated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->canManageOperations())
|
|
||||||
->form([
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Reason')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
|
||||||
$user = $this->requireManageUser();
|
|
||||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Run marked as investigated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No stuck runs found')
|
|
||||||
->emptyStateDescription('Queued and running runs outside thresholds will appear here.')
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageOperations(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireManageUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class BaselineCompareNow extends Widget
|
|
||||||
{
|
|
||||||
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
$empty = [
|
|
||||||
'hasAssignment' => false,
|
|
||||||
'profileName' => null,
|
|
||||||
'findingsCount' => 0,
|
|
||||||
'highCount' => 0,
|
|
||||||
'mediumCount' => 0,
|
|
||||||
'lowCount' => 0,
|
|
||||||
'lastComparedAt' => null,
|
|
||||||
'landingUrl' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return $empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forWidget($tenant);
|
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
|
||||||
return $empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'hasAssignment' => true,
|
|
||||||
'profileName' => $stats->profileName,
|
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
|
||||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
|
||||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
|
|
||||||
class DashboardKpis extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected function getPollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', 0),
|
|
||||||
Stat::make('High severity drift', 0),
|
|
||||||
Stat::make('Active operations', 0),
|
|
||||||
Stat::make('Inventory active', 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$openDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$highSeverityDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryActiveRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', $openDriftFindings)
|
|
||||||
->description('across all policy types')
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('High severity drift', $highSeverityDriftFindings)
|
|
||||||
->description('requiring immediate review')
|
|
||||||
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('Active operations', $activeRuns)
|
|
||||||
->description('backup, sync & compare runs')
|
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(route('admin.operations.index')),
|
|
||||||
Stat::make('Inventory syncs running', $inventoryActiveRuns)
|
|
||||||
->description('active inventory sync jobs')
|
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(route('admin.operations.index')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class NeedsAttention extends Widget
|
|
||||||
{
|
|
||||||
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'pollingInterval' => null,
|
|
||||||
'items' => [],
|
|
||||||
'healthyChecks' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
$highSeverityCount = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'High severity drift findings',
|
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareSuccess = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestBaselineCompareSuccess) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'No baseline compare yet',
|
|
||||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare stale',
|
|
||||||
'body' => 'Last baseline compare is older than 7 days.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Operations in progress',
|
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = array_slice($items, 0, 5);
|
|
||||||
|
|
||||||
$healthyChecks = [];
|
|
||||||
|
|
||||||
if ($items === []) {
|
|
||||||
$healthyChecks = [
|
|
||||||
[
|
|
||||||
'title' => 'Drift findings look healthy',
|
|
||||||
'body' => 'No high severity drift findings are open.',
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Baseline compares are up to date',
|
|
||||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
|
||||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
|
||||||
: 'Baseline compare history is available in Baseline Compare.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'linkLabel' => 'Open Baseline Compare',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'No active operations',
|
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
|
||||||
'items' => $items,
|
|
||||||
'healthyChecks' => $healthyChecks,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Widgets\TableWidget;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class RecentOperations extends TableWidget
|
|
||||||
{
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
return $table
|
|
||||||
->heading('Recent Operations')
|
|
||||||
->query($this->getQuery())
|
|
||||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('short_id')
|
|
||||||
->label('Run')
|
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey())
|
|
||||||
->copyable()
|
|
||||||
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->sortable()
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->limit(40)
|
|
||||||
->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)),
|
|
||||||
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)),
|
|
||||||
TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->sortable()
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
|
|
||||||
? OperationRunLinks::view($record, $tenant)
|
|
||||||
: null)
|
|
||||||
->emptyStateHeading('No operations yet')
|
|
||||||
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<OperationRun>
|
|
||||||
*/
|
|
||||||
private function getQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->latest('created_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Inventory;
|
|
||||||
|
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
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 Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\HtmlString;
|
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Total items', 0),
|
|
||||||
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
|
||||||
Stat::make('Last inventory sync', '—'),
|
|
||||||
Stat::make('Active ops', 0),
|
|
||||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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">
|
|
||||||
View run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'badgeColor' => $badgeColor,
|
|
||||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
|
||||||
'viewUrl' => $lastInventorySyncViewUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$activeOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->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))),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class BaselineCompareCoverageBanner extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.tenant.baseline-compare-coverage-banner';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'shouldShow' => false,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
|
||||||
|
|
||||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
|
||||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
|
||||||
|
|
||||||
$coverageStatus = $stats->coverageStatus;
|
|
||||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
|
||||||
|
|
||||||
$runUrl = null;
|
|
||||||
|
|
||||||
if ($stats->operationRunId !== null) {
|
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
|
||||||
'runUrl' => $runUrl,
|
|
||||||
'coverageStatus' => $coverageStatus,
|
|
||||||
'fidelity' => $stats->fidelity,
|
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
|
||||||
'uncoveredTypes' => $uncoveredTypes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Workspace;
|
|
||||||
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class WorkspaceNeedsAttention extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.workspace.workspace-needs-attention';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* url: string,
|
|
||||||
* badge: string,
|
|
||||||
* badge_color: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public array $items = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* action_label: string,
|
|
||||||
* action_url: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public array $emptyState = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* url: string,
|
|
||||||
* badge: string,
|
|
||||||
* badge_color: string
|
|
||||||
* }> $items
|
|
||||||
* @param array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* action_label: string,
|
|
||||||
* action_url: string
|
|
||||||
* } $emptyState
|
|
||||||
*/
|
|
||||||
public function mount(array $items = [], array $emptyState = []): void
|
|
||||||
{
|
|
||||||
$this->items = $items;
|
|
||||||
$this->emptyState = $emptyState;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
final class ClearTenantContextController
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
|
||||||
|
|
||||||
$previousUrl = url()->previous();
|
|
||||||
|
|
||||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
|
||||||
|
|
||||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
|
||||||
return redirect()->to('/admin/operations');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to((string) $previousUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantOnboardingController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$clientId = config('graph.client_id');
|
|
||||||
$redirectUri = route('admin.consent.callback');
|
|
||||||
$targetTenant = $request->string('tenant')->toString() ?: config('graph.tenant_id', 'organizations');
|
|
||||||
$tenantSegment = $targetTenant ?: 'organizations';
|
|
||||||
|
|
||||||
abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured');
|
|
||||||
|
|
||||||
$state = Str::uuid()->toString();
|
|
||||||
$request->session()->put('tenant_onboard_state', $state);
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
|
||||||
|
|
||||||
if ($workspaceId !== null) {
|
|
||||||
$request->session()->put('tenant_onboard_workspace_id', (int) $workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'scope' => 'https://graph.microsoft.com/.default',
|
|
||||||
'state' => $state,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->away($url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,447 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\StoredReport;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Intune\SecretClassificationService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\RedactionIntegrity;
|
|
||||||
use App\Support\ReviewPackStatus;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Throwable;
|
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
class GenerateReviewPackJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $reviewPackId,
|
|
||||||
public int $operationRunId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): void
|
|
||||||
{
|
|
||||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
||||||
|
|
||||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
|
||||||
Log::warning('GenerateReviewPackJob: missing records', [
|
|
||||||
'review_pack_id' => $this->reviewPackId,
|
|
||||||
'operation_run_id' => $this->operationRunId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $reviewPack->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark running via OperationRunService (auto-sets started_at)
|
|
||||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
|
||||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
|
||||||
{
|
|
||||||
$options = $reviewPack->options ?? [];
|
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
// 1. Collect StoredReports
|
|
||||||
$storedReports = StoredReport::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->whereIn('report_type', [
|
|
||||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
||||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
||||||
])
|
|
||||||
->get()
|
|
||||||
->keyBy('report_type');
|
|
||||||
|
|
||||||
// 2. Collect open findings
|
|
||||||
$findings = Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->orderBy('severity')
|
|
||||||
->orderBy('created_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// 3. Collect tenant hardening fields
|
|
||||||
$hardening = [
|
|
||||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
|
||||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
|
||||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
|
||||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
|
||||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 4. Collect recent OperationRuns (30 days)
|
|
||||||
$recentOperations = $includeOperations
|
|
||||||
? OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('created_at', '>=', now()->subDays(30))
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
// 5. Data freshness
|
|
||||||
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
|
||||||
|
|
||||||
// 6. Build file map
|
|
||||||
$fileMap = $this->buildFileMap(
|
|
||||||
storedReports: $storedReports,
|
|
||||||
findings: $findings,
|
|
||||||
hardening: $hardening,
|
|
||||||
recentOperations: $recentOperations,
|
|
||||||
tenant: $tenant,
|
|
||||||
dataFreshness: $dataFreshness,
|
|
||||||
includePii: $includePii,
|
|
||||||
includeOperations: $includeOperations,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 7. Assemble ZIP
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->assembleZip($tempFile, $fileMap);
|
|
||||||
|
|
||||||
// 8. Compute SHA-256
|
|
||||||
$sha256 = hash_file('sha256', $tempFile);
|
|
||||||
$fileSize = filesize($tempFile);
|
|
||||||
|
|
||||||
// 9. Store on exports disk
|
|
||||||
$filePath = sprintf(
|
|
||||||
'review-packs/%s/%s.zip',
|
|
||||||
$tenant->external_id,
|
|
||||||
now()->format('Y-m-d-His'),
|
|
||||||
);
|
|
||||||
|
|
||||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
||||||
} finally {
|
|
||||||
if (file_exists($tempFile)) {
|
|
||||||
unlink($tempFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Compute fingerprint
|
|
||||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options);
|
|
||||||
|
|
||||||
// 11. Compute summary
|
|
||||||
$summary = [
|
|
||||||
'finding_count' => $findings->count(),
|
|
||||||
'report_count' => $storedReports->count(),
|
|
||||||
'operation_count' => $recentOperations->count(),
|
|
||||||
'data_freshness' => $dataFreshness,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 12. Update ReviewPack
|
|
||||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
|
||||||
$reviewPack->update([
|
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'sha256' => $sha256,
|
|
||||||
'file_size' => $fileSize,
|
|
||||||
'file_path' => $filePath,
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
'generated_at' => now(),
|
|
||||||
'expires_at' => now()->addDays($retentionDays),
|
|
||||||
'summary' => $summary,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification)
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: $summary,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
|
||||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
|
||||||
* @return array<string, ?string>
|
|
||||||
*/
|
|
||||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
|
||||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
|
||||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
|
||||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the file map for the ZIP contents.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function buildFileMap(
|
|
||||||
$storedReports,
|
|
||||||
$findings,
|
|
||||||
array $hardening,
|
|
||||||
$recentOperations,
|
|
||||||
Tenant $tenant,
|
|
||||||
array $dataFreshness,
|
|
||||||
bool $includePii,
|
|
||||||
bool $includeOperations,
|
|
||||||
): array {
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
// findings.csv
|
|
||||||
$files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii);
|
|
||||||
|
|
||||||
// hardening.json
|
|
||||||
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
// metadata.json
|
|
||||||
$files['metadata.json'] = json_encode([
|
|
||||||
'version' => '1.0',
|
|
||||||
'tenant_id' => $tenant->external_id,
|
|
||||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
|
||||||
'generated_at' => now()->toIso8601String(),
|
|
||||||
'redaction_integrity' => [
|
|
||||||
'protected_values_hidden' => true,
|
|
||||||
'note' => RedactionIntegrity::protectedValueNote(),
|
|
||||||
],
|
|
||||||
'options' => [
|
|
||||||
'include_pii' => $includePii,
|
|
||||||
'include_operations' => $includeOperations,
|
|
||||||
],
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
// operations.csv
|
|
||||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
|
||||||
|
|
||||||
// reports/entra_admin_roles.json
|
|
||||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
|
||||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
|
||||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
||||||
);
|
|
||||||
|
|
||||||
// reports/permission_posture.json
|
|
||||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
|
||||||
$files['reports/permission_posture.json'] = json_encode(
|
|
||||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
|
||||||
);
|
|
||||||
|
|
||||||
// summary.json
|
|
||||||
$files['summary.json'] = json_encode([
|
|
||||||
'data_freshness' => $dataFreshness,
|
|
||||||
'finding_count' => $findings->count(),
|
|
||||||
'report_count' => $storedReports->count(),
|
|
||||||
'operation_count' => $recentOperations->count(),
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build findings CSV content.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
|
||||||
*/
|
|
||||||
private function buildFindingsCsv($findings, bool $includePii): string
|
|
||||||
{
|
|
||||||
$handle = fopen('php://temp', 'r+');
|
|
||||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
|
||||||
|
|
||||||
foreach ($findings as $finding) {
|
|
||||||
fputcsv($handle, [
|
|
||||||
$finding->id,
|
|
||||||
$finding->finding_type,
|
|
||||||
$finding->severity,
|
|
||||||
$finding->status,
|
|
||||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
|
||||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
|
||||||
$finding->created_at?->toIso8601String(),
|
|
||||||
$finding->updated_at?->toIso8601String(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
rewind($handle);
|
|
||||||
$content = stream_get_contents($handle);
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build operations CSV content.
|
|
||||||
*/
|
|
||||||
private function buildOperationsCsv($operations, bool $includePii): string
|
|
||||||
{
|
|
||||||
$handle = fopen('php://temp', 'r+');
|
|
||||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
|
||||||
|
|
||||||
foreach ($operations as $operation) {
|
|
||||||
fputcsv($handle, [
|
|
||||||
$operation->id,
|
|
||||||
$operation->type,
|
|
||||||
$operation->status,
|
|
||||||
$operation->outcome,
|
|
||||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
|
||||||
$operation->started_at?->toIso8601String(),
|
|
||||||
$operation->completed_at?->toIso8601String(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
rewind($handle);
|
|
||||||
$content = stream_get_contents($handle);
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redact PII from a report payload.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function redactReportPayload(array $payload, bool $includePii): array
|
|
||||||
{
|
|
||||||
$payload = $this->redactProtectedPayload($payload);
|
|
||||||
|
|
||||||
return $includePii ? $payload : $this->redactArrayPii($payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively redact PII fields from an array.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function redactArrayPii(array $data): array
|
|
||||||
{
|
|
||||||
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
|
|
||||||
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
|
||||||
$data[$key] = '[REDACTED]';
|
|
||||||
} elseif (is_array($value)) {
|
|
||||||
$data[$key] = $this->redactArrayPii($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int|string, mixed> $data
|
|
||||||
* @param array<int, string> $segments
|
|
||||||
* @return array<int|string, mixed>
|
|
||||||
*/
|
|
||||||
private function redactProtectedPayload(array $data, array $segments = []): array
|
|
||||||
{
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
$nextSegments = [...$segments, (string) $key];
|
|
||||||
$jsonPointer = $this->jsonPointer($nextSegments);
|
|
||||||
|
|
||||||
if (is_string($key) && $this->classifier()->protectsField('snapshot', $key, $jsonPointer)) {
|
|
||||||
$data[$key] = SecretClassificationService::REDACTED;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$data[$key] = $this->redactProtectedPayload($value, $nextSegments);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
$data[$key] = $this->classifier()->sanitizeAuditString($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $segments
|
|
||||||
*/
|
|
||||||
private function jsonPointer(array $segments): string
|
|
||||||
{
|
|
||||||
if ($segments === []) {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/'.implode('/', array_map(
|
|
||||||
static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment),
|
|
||||||
$segments,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function classifier(): SecretClassificationService
|
|
||||||
{
|
|
||||||
return app(SecretClassificationService::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assemble a ZIP file from a file map.
|
|
||||||
*
|
|
||||||
* @param array<string, string> $fileMap
|
|
||||||
*/
|
|
||||||
private function assembleZip(string $tempFile, array $fileMap): void
|
|
||||||
{
|
|
||||||
$zip = new ZipArchive;
|
|
||||||
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
||||||
|
|
||||||
if ($result !== true) {
|
|
||||||
throw new \RuntimeException("Failed to create ZIP archive: error code {$result}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add files in alphabetical order for deterministic output
|
|
||||||
ksort($fileMap);
|
|
||||||
|
|
||||||
foreach ($fileMap as $filename => $content) {
|
|
||||||
$zip->addFromString($filename, $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
|
||||||
{
|
|
||||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class AuditLog extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'metadata' => 'array',
|
|
||||||
'recorded_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class BaselineSnapshot extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'summary_jsonb' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function baselineProfile(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(BaselineProfile::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function items(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(BaselineSnapshotItem::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class OperationRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'summary_counts' => 'array',
|
|
||||||
'failure_summary' => 'array',
|
|
||||||
'context' => 'array',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::creating(function (self $operationRun): void {
|
|
||||||
if ($operationRun->workspace_id !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operationRun->tenant_id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->workspace_id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', ['queued', 'running']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSelectionHashAttribute(): ?string
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
return isset($context['selection_hash']) && is_string($context['selection_hash'])
|
|
||||||
? $context['selection_hash']
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSelectionHashAttribute(?string $value): void
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$context['selection_hash'] = $value;
|
|
||||||
|
|
||||||
$this->context = $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function getSelectionPayloadAttribute(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
return Arr::only($context, [
|
|
||||||
'policy_types',
|
|
||||||
'categories',
|
|
||||||
'include_foundations',
|
|
||||||
'include_dependencies',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $value
|
|
||||||
*/
|
|
||||||
public function setSelectionPayloadAttribute(?array $value): void
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$context = array_merge($context, Arr::only($value, [
|
|
||||||
'policy_types',
|
|
||||||
'categories',
|
|
||||||
'include_foundations',
|
|
||||||
'include_dependencies',
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->context = $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFinishedAtAttribute(): mixed
|
|
||||||
{
|
|
||||||
return $this->completed_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setFinishedAtAttribute(mixed $value): void
|
|
||||||
{
|
|
||||||
$this->completed_at = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ProviderConnection extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_default' => 'boolean',
|
|
||||||
'scopes_granted' => 'array',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'last_health_check_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function credential(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function makeDefault(): void
|
|
||||||
{
|
|
||||||
DB::transaction(function (): void {
|
|
||||||
static::query()
|
|
||||||
->where('tenant_id', $this->tenant_id)
|
|
||||||
->where('provider', $this->provider)
|
|
||||||
->where('is_default', true)
|
|
||||||
->whereKeyNot($this->getKey())
|
|
||||||
->update(['is_default' => false]);
|
|
||||||
|
|
||||||
static::query()
|
|
||||||
->whereKey($this->getKey())
|
|
||||||
->update(['is_default' => true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class TenantOnboardingSession extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'managed_tenant_onboarding_sessions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public const STATE_ALLOWED_KEYS = [
|
|
||||||
'entra_tenant_id',
|
|
||||||
'tenant_id',
|
|
||||||
'tenant_name',
|
|
||||||
'environment',
|
|
||||||
'primary_domain',
|
|
||||||
'notes',
|
|
||||||
'provider_connection_id',
|
|
||||||
'selected_provider_connection_id',
|
|
||||||
'verification_operation_run_id',
|
|
||||||
'verification_run_id',
|
|
||||||
'bootstrap_operation_types',
|
|
||||||
'bootstrap_operation_runs',
|
|
||||||
'bootstrap_run_ids',
|
|
||||||
'connection_recently_updated',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'state' => 'array',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $value
|
|
||||||
*/
|
|
||||||
public function setStateAttribute(?array $value): void
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
$this->attributes['state'] = null;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed = array_intersect_key($value, array_flip(self::STATE_ALLOWED_KEYS));
|
|
||||||
|
|
||||||
$encoded = json_encode($allowed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
|
|
||||||
$this->attributes['state'] = $encoded !== false ? $encoded : json_encode([], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function startedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'started_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function updatedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class OperationRunCompleted extends Notification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public OperationRun $run
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
|
||||||
run: $this->run,
|
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($notifiable instanceof PlatformUser) {
|
|
||||||
$notification->actions([
|
|
||||||
\Filament\Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(SystemOperationRunLinks::view($this->run)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $notification->getDatabaseMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class BackupSchedulePolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
|
||||||
{
|
|
||||||
$tenant ??= Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return $this->isTenantMember($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $this->isTenantMember($user, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $schedule->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restore(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forceDelete(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EntraGroupPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EntraGroup $group): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $group->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Audit;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Audit\AuditContextSanitizer;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
|
||||||
{
|
|
||||||
public function log(
|
|
||||||
Workspace $workspace,
|
|
||||||
string $action,
|
|
||||||
array $context = [],
|
|
||||||
?User $actor = null,
|
|
||||||
string $status = 'success',
|
|
||||||
?string $resourceType = null,
|
|
||||||
?string $resourceId = null,
|
|
||||||
?int $actorId = null,
|
|
||||||
?string $actorEmail = null,
|
|
||||||
?string $actorName = null,
|
|
||||||
): AuditLog {
|
|
||||||
$metadata = $context['metadata'] ?? [];
|
|
||||||
unset($context['metadata']);
|
|
||||||
|
|
||||||
$metadata = is_array($metadata) ? $metadata : [];
|
|
||||||
|
|
||||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
|
||||||
|
|
||||||
return AuditLog::create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'actor_id' => $actor?->getKey() ?? $actorId,
|
|
||||||
'actor_email' => $actor?->email ?? $actorEmail,
|
|
||||||
'actor_name' => $actor?->name ?? $actorName,
|
|
||||||
'action' => $action,
|
|
||||||
'resource_type' => $resourceType,
|
|
||||||
'resource_id' => $resourceId,
|
|
||||||
'status' => $status,
|
|
||||||
'metadata' => $sanitizedMetadata,
|
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Baselines\SnapshotRendering;
|
|
||||||
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineSnapshotItem;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class BaselineSnapshotPresenter
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SnapshotTypeRendererRegistry $registry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
|
||||||
{
|
|
||||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
||||||
|
|
||||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
|
||||||
$items = $snapshot->items instanceof EloquentCollection
|
|
||||||
? $snapshot->items->sortBy([
|
|
||||||
['policy_type', 'asc'],
|
|
||||||
['id', 'asc'],
|
|
||||||
])->values()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
$groups = $items
|
|
||||||
->groupBy(static fn (BaselineSnapshotItem $item): string => (string) $item->policy_type)
|
|
||||||
->map(fn (Collection $groupItems, string $policyType): RenderedSnapshotGroup => $this->presentGroup($policyType, $groupItems))
|
|
||||||
->sortBy(static fn (RenderedSnapshotGroup $group): string => mb_strtolower($group->label))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$summaryRows = array_map(
|
|
||||||
static fn (RenderedSnapshotGroup $group): array => [
|
|
||||||
'policyType' => $group->policyType,
|
|
||||||
'label' => $group->label,
|
|
||||||
'itemCount' => $group->itemCount,
|
|
||||||
'fidelity' => $group->fidelity->value,
|
|
||||||
'gapCount' => $group->gapSummary->count,
|
|
||||||
'capturedAt' => $group->capturedAt,
|
|
||||||
'coverageHint' => $group->coverageHint,
|
|
||||||
],
|
|
||||||
$groups,
|
|
||||||
);
|
|
||||||
|
|
||||||
$overallGapCount = $this->summaryGapCount($summary);
|
|
||||||
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
|
||||||
|
|
||||||
return new RenderedSnapshot(
|
|
||||||
snapshotId: (int) $snapshot->getKey(),
|
|
||||||
baselineProfileName: $snapshot->baselineProfile?->name,
|
|
||||||
capturedAt: $snapshot->captured_at?->toIso8601String(),
|
|
||||||
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
|
||||||
? trim($snapshot->snapshot_identity_hash)
|
|
||||||
: null,
|
|
||||||
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
|
||||||
fidelitySummary: $this->fidelitySummary($summary),
|
|
||||||
overallFidelity: $overallFidelity,
|
|
||||||
overallGapCount: $overallGapCount,
|
|
||||||
summaryRows: $summaryRows,
|
|
||||||
groups: $groups,
|
|
||||||
technicalDetail: [
|
|
||||||
'defaultCollapsed' => true,
|
|
||||||
'summaryPayload' => $summary,
|
|
||||||
'groupPayloads' => array_map(
|
|
||||||
static fn (RenderedSnapshotGroup $group): array => [
|
|
||||||
'label' => $group->label,
|
|
||||||
'renderingError' => $group->renderingError,
|
|
||||||
'payload' => $group->technicalPayload,
|
|
||||||
],
|
|
||||||
$groups,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
hasItems: $items->isNotEmpty(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, BaselineSnapshotItem> $items
|
|
||||||
*/
|
|
||||||
private function presentGroup(string $policyType, Collection $items): RenderedSnapshotGroup
|
|
||||||
{
|
|
||||||
$renderer = $this->registry->rendererFor($policyType);
|
|
||||||
$fallbackRenderer = $this->registry->fallbackRenderer();
|
|
||||||
$renderingError = null;
|
|
||||||
$technicalPayload = $this->technicalPayload($items);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$renderedItems = $items
|
|
||||||
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $renderer->render($item))
|
|
||||||
->all();
|
|
||||||
} catch (Throwable) {
|
|
||||||
$renderedItems = $items
|
|
||||||
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<int, RenderedSnapshotItem> $renderedItems */
|
|
||||||
$groupFidelity = FidelityState::aggregate(array_map(
|
|
||||||
static fn (RenderedSnapshotItem $item): FidelityState => $item->fidelity,
|
|
||||||
$renderedItems,
|
|
||||||
));
|
|
||||||
|
|
||||||
$gapSummary = GapSummary::merge(array_map(
|
|
||||||
static fn (RenderedSnapshotItem $item): GapSummary => $item->gapSummary,
|
|
||||||
$renderedItems,
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($renderingError !== null) {
|
|
||||||
$gapSummary = $gapSummary->withMessage($renderingError);
|
|
||||||
}
|
|
||||||
|
|
||||||
$capturedAt = collect($renderedItems)
|
|
||||||
->pluck('observedAt')
|
|
||||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
||||||
->sortDesc()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$coverageHint = $groupFidelity->coverageHint();
|
|
||||||
|
|
||||||
if ($coverageHint === null && $gapSummary->messages !== []) {
|
|
||||||
$coverageHint = $gapSummary->messages[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RenderedSnapshotGroup(
|
|
||||||
policyType: $policyType,
|
|
||||||
label: $this->typeLabel($policyType),
|
|
||||||
itemCount: $items->count(),
|
|
||||||
fidelity: $groupFidelity,
|
|
||||||
gapSummary: $gapSummary,
|
|
||||||
initiallyCollapsed: true,
|
|
||||||
items: $renderedItems,
|
|
||||||
renderingError: $renderingError,
|
|
||||||
coverageHint: $coverageHint,
|
|
||||||
capturedAt: is_string($capturedAt) ? $capturedAt : null,
|
|
||||||
technicalPayload: $technicalPayload,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, BaselineSnapshotItem> $items
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function technicalPayload(Collection $items): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'items' => $items
|
|
||||||
->map(static fn (BaselineSnapshotItem $item): array => [
|
|
||||||
'snapshot_item_id' => (int) $item->getKey(),
|
|
||||||
'policy_type' => (string) $item->policy_type,
|
|
||||||
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
|
||||||
])
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $summary
|
|
||||||
*/
|
|
||||||
private function summaryGapCount(array $summary): int
|
|
||||||
{
|
|
||||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
|
||||||
$count = $gaps['count'] ?? 0;
|
|
||||||
|
|
||||||
return is_numeric($count) ? (int) $count : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $summary
|
|
||||||
*/
|
|
||||||
private function fidelitySummary(array $summary): string
|
|
||||||
{
|
|
||||||
$counts = is_array($summary['fidelity_counts'] ?? null)
|
|
||||||
? $summary['fidelity_counts']
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
|
||||||
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
|
||||||
|
|
||||||
return sprintf('Content %d, Meta %d', $content, $meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function typeLabel(string $policyType): string
|
|
||||||
{
|
|
||||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
|
||||||
?? InventoryPolicyTypeMeta::label($policyType)
|
|
||||||
?? Str::headline($policyType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Intune;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Audit\AuditContextSanitizer;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class AuditLogger
|
|
||||||
{
|
|
||||||
public function log(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $action,
|
|
||||||
array $context = [],
|
|
||||||
?int $actorId = null,
|
|
||||||
?string $actorEmail = null,
|
|
||||||
?string $actorName = null,
|
|
||||||
string $status = 'success',
|
|
||||||
?string $resourceType = null,
|
|
||||||
?string $resourceId = null,
|
|
||||||
): AuditLog {
|
|
||||||
$metadata = $context['metadata'] ?? [];
|
|
||||||
unset($context['metadata']);
|
|
||||||
|
|
||||||
$metadata = is_array($metadata) ? $metadata : [];
|
|
||||||
|
|
||||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
|
||||||
$workspaceId = is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null;
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
throw new InvalidArgumentException('Tenant-scoped audit events require tenant workspace_id.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return AuditLog::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'actor_id' => $actorId,
|
|
||||||
'actor_email' => $actorEmail,
|
|
||||||
'actor_name' => $actorName,
|
|
||||||
'action' => $action,
|
|
||||||
'resource_type' => $resourceType,
|
|
||||||
'resource_id' => $resourceId,
|
|
||||||
'status' => $status,
|
|
||||||
'metadata' => $sanitizedMetadata,
|
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\StoredReport;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\ReviewPackStatus;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
|
|
||||||
class ReviewPackService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private OperationRunService $operationRunService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $options
|
|
||||||
*/
|
|
||||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
|
||||||
{
|
|
||||||
$options = $this->normalizeOptions($options);
|
|
||||||
$fingerprint = $this->computeFingerprint($tenant, $options);
|
|
||||||
|
|
||||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
|
||||||
if ($existing instanceof ReviewPack) {
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun = $this->operationRunService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: OperationRunType::ReviewPackGenerate->value,
|
|
||||||
inputs: [
|
|
||||||
'include_pii' => $options['include_pii'],
|
|
||||||
'include_operations' => $options['include_operations'],
|
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
$reviewPack = ReviewPack::create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'operation_run_id' => (int) $operationRun->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => ReviewPackStatus::Queued->value,
|
|
||||||
'options' => $options,
|
|
||||||
'summary' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
|
||||||
GenerateReviewPackJob::dispatch(
|
|
||||||
reviewPackId: (int) $reviewPack->getKey(),
|
|
||||||
operationRunId: (int) $operationRun->getKey(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $reviewPack;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute a deterministic fingerprint for deduplication.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $options
|
|
||||||
*/
|
|
||||||
public function computeFingerprint(Tenant $tenant, array $options): string
|
|
||||||
{
|
|
||||||
$reportFingerprints = StoredReport::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->whereIn('report_type', [
|
|
||||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
||||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
||||||
])
|
|
||||||
->orderBy('report_type')
|
|
||||||
->pluck('fingerprint')
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
$maxFindingDate = Finding::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->max('updated_at');
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
|
||||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
|
||||||
'report_fingerprints' => $reportFingerprints,
|
|
||||||
'max_finding_date' => $maxFindingDate,
|
|
||||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a signed download URL for a review pack.
|
|
||||||
*/
|
|
||||||
public function generateDownloadUrl(ReviewPack $pack): string
|
|
||||||
{
|
|
||||||
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
|
||||||
|
|
||||||
return URL::signedRoute(
|
|
||||||
'admin.review-packs.download',
|
|
||||||
['reviewPack' => $pack->getKey()],
|
|
||||||
now()->addMinutes($ttlMinutes),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an existing ready, non-expired pack with the same fingerprint.
|
|
||||||
*/
|
|
||||||
public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPack
|
|
||||||
{
|
|
||||||
return ReviewPack::query()
|
|
||||||
->forTenant((int) $tenant->getKey())
|
|
||||||
->ready()
|
|
||||||
->where('fingerprint', $fingerprint)
|
|
||||||
->where('expires_at', '>', now())
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a generation run is currently active for this tenant.
|
|
||||||
*/
|
|
||||||
public function checkActiveRun(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->active()
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $options
|
|
||||||
* @return array{include_pii: bool, include_operations: bool}
|
|
||||||
*/
|
|
||||||
private function normalizeOptions(array $options): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'include_pii' => (bool) ($options['include_pii'] ?? config('tenantpilot.review_pack.include_pii_default', true)),
|
|
||||||
'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\System;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class AllowedTenantUniverse
|
|
||||||
{
|
|
||||||
public const PLATFORM_TENANT_EXTERNAL_ID = 'platform';
|
|
||||||
|
|
||||||
public function query(): Builder
|
|
||||||
{
|
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', '!=', self::PLATFORM_TENANT_EXTERNAL_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAllowed(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
return (string) $tenant->external_id !== self::PLATFORM_TENANT_EXTERNAL_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ensureAllowed(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
if ($this->isAllowed($tenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'tenant_id' => 'This tenant is not eligible for System runbooks.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Audit;
|
|
||||||
|
|
||||||
enum AuditActionId: string
|
|
||||||
{
|
|
||||||
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
|
||||||
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
|
||||||
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
|
||||||
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
|
||||||
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
|
||||||
|
|
||||||
case TenantMembershipAdd = 'tenant_membership.add';
|
|
||||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
|
||||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
|
||||||
case TenantMembershipLastOwnerBlocked = 'tenant_membership.last_owner_blocked';
|
|
||||||
|
|
||||||
// Not part of the v1 contract, but used in codebase.
|
|
||||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
|
||||||
|
|
||||||
// Diagnostics / repair actions.
|
|
||||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
|
||||||
|
|
||||||
// Managed tenant onboarding wizard.
|
|
||||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
|
||||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
|
||||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
|
||||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
|
||||||
case VerificationCompleted = 'verification.completed';
|
|
||||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
|
||||||
|
|
||||||
case AlertDestinationCreated = 'alert_destination.created';
|
|
||||||
case AlertDestinationUpdated = 'alert_destination.updated';
|
|
||||||
case AlertDestinationDeleted = 'alert_destination.deleted';
|
|
||||||
case AlertDestinationEnabled = 'alert_destination.enabled';
|
|
||||||
case AlertDestinationDisabled = 'alert_destination.disabled';
|
|
||||||
case AlertDestinationTestRequested = 'alert_destination.test_requested';
|
|
||||||
|
|
||||||
case AlertRuleCreated = 'alert_rule.created';
|
|
||||||
case AlertRuleUpdated = 'alert_rule.updated';
|
|
||||||
case AlertRuleDeleted = 'alert_rule.deleted';
|
|
||||||
case AlertRuleEnabled = 'alert_rule.enabled';
|
|
||||||
case AlertRuleDisabled = 'alert_rule.disabled';
|
|
||||||
|
|
||||||
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
|
||||||
case WorkspaceSettingReset = 'workspace_setting.reset';
|
|
||||||
|
|
||||||
case BaselineProfileCreated = 'baseline_profile.created';
|
|
||||||
case BaselineProfileUpdated = 'baseline_profile.updated';
|
|
||||||
case BaselineProfileArchived = 'baseline_profile.archived';
|
|
||||||
case BaselineCaptureStarted = 'baseline_capture.started';
|
|
||||||
case BaselineCaptureCompleted = 'baseline_capture.completed';
|
|
||||||
case BaselineCaptureFailed = 'baseline_capture.failed';
|
|
||||||
case BaselineCompareStarted = 'baseline_compare.started';
|
|
||||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
|
||||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
|
||||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
|
||||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
|
||||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
|
||||||
|
|
||||||
// Workspace selection / switch events (Spec 107).
|
|
||||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
|
||||||
case WorkspaceSelected = 'workspace.selected';
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class BadgeSpec
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
private const ALLOWED_COLORS = [
|
|
||||||
'gray',
|
|
||||||
'info',
|
|
||||||
'success',
|
|
||||||
'warning',
|
|
||||||
'danger',
|
|
||||||
'primary',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $label,
|
|
||||||
public readonly string $color,
|
|
||||||
public readonly ?string $icon = null,
|
|
||||||
public readonly ?string $iconColor = null,
|
|
||||||
) {
|
|
||||||
if (trim($this->label) === '') {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($this->color, self::ALLOWED_COLORS, true)) {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec color must be one of: '.implode(', ', self::ALLOWED_COLORS));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->icon !== null && trim($this->icon) === '') {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec icon must be null or a non-empty string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
|
|
||||||
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function allowedColors(): array
|
|
||||||
{
|
|
||||||
return self::ALLOWED_COLORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function unknown(): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: 'Unknown',
|
|
||||||
color: 'gray',
|
|
||||||
icon: 'heroicon-m-question-mark-circle',
|
|
||||||
iconColor: 'gray',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class BaselineSnapshotFidelityBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
|
||||||
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'),
|
|
||||||
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
|
|
||||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
|
||||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
OperationRunOutcome::Blocked->value => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
|
||||||
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
|
|
||||||
final class OperationRunStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
|
||||||
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class ProviderConnectionStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class RestoreCheckSeverityBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class RestoreResultStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
|
||||||
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\RestoreRunStatus;
|
|
||||||
|
|
||||||
final class RestoreRunStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
|
||||||
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
|
||||||
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
|
||||||
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
|
|
||||||
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class TenantStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
|
||||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
|
||||||
'suspended' => new BadgeSpec('Suspended', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Baselines;
|
|
||||||
|
|
||||||
enum BaselineCompareReasonCode: string
|
|
||||||
{
|
|
||||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
|
||||||
case CoverageUnproven = 'coverage_unproven';
|
|
||||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
|
||||||
case RolloutDisabled = 'rollout_disabled';
|
|
||||||
case NoDriftDetected = 'no_drift_detected';
|
|
||||||
|
|
||||||
public function message(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
|
||||||
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
|
||||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
|
||||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
|
||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Baselines;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable reason codes for 422 precondition failures.
|
|
||||||
*
|
|
||||||
* These codes are returned in the response body when a baseline operation
|
|
||||||
* cannot start due to unmet preconditions. No OperationRun is created.
|
|
||||||
*/
|
|
||||||
final class BaselineReasonCodes
|
|
||||||
{
|
|
||||||
public const string CAPTURE_MISSING_SOURCE_TENANT = 'baseline.capture.missing_source_tenant';
|
|
||||||
|
|
||||||
public const string CAPTURE_PROFILE_NOT_ACTIVE = 'baseline.capture.profile_not_active';
|
|
||||||
|
|
||||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
|
||||||
|
|
||||||
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
|
||||||
|
|
||||||
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
|
||||||
|
|
||||||
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
|
|
||||||
|
|
||||||
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
|
||||||
|
|
||||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Inventory;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
|
|
||||||
class InventoryKpiBadges
|
|
||||||
{
|
|
||||||
public static function coverage(int $restorableCount, int $partialCount): string
|
|
||||||
{
|
|
||||||
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 }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'restorableCount' => $restorableCount,
|
|
||||||
'partialCount' => $partialCount,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'dependenciesCount' => $dependenciesCount,
|
|
||||||
'riskCount' => $riskCount,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Inventory;
|
|
||||||
|
|
||||||
class InventoryPolicyTypeMeta
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Canonical inventory policy-type metadata source-of-truth.
|
|
||||||
*
|
|
||||||
* These definitions are used for UI classification (restore/risk) and KPI aggregation.
|
|
||||||
* The authoritative inputs are:
|
|
||||||
* - `inventory_items.policy_type`
|
|
||||||
* - `config('tenantpilot.supported_policy_types')` and `config('tenantpilot.foundation_types')`
|
|
||||||
* meta fields, especially: `restore`, `risk`.
|
|
||||||
*/
|
|
||||||
public static function all(): array
|
|
||||||
{
|
|
||||||
return array_merge(
|
|
||||||
static::supported(),
|
|
||||||
static::foundations(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function supported(): array
|
|
||||||
{
|
|
||||||
$supported = config('tenantpilot.supported_policy_types', []);
|
|
||||||
|
|
||||||
return is_array($supported) ? $supported : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function foundations(): array
|
|
||||||
{
|
|
||||||
$foundations = config('tenantpilot.foundation_types', []);
|
|
||||||
|
|
||||||
return is_array($foundations) ? $foundations : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function baselineSupportedFoundations(): array
|
|
||||||
{
|
|
||||||
return array_values(array_filter(
|
|
||||||
static::foundations(),
|
|
||||||
static fn (array $row): bool => filled($row['type'] ?? null)
|
|
||||||
&& is_array($row['baseline_compare'] ?? null)
|
|
||||||
&& (bool) ($row['baseline_compare']['supported'] ?? false),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function byType(): array
|
|
||||||
{
|
|
||||||
return collect(static::all())
|
|
||||||
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
|
||||||
->keyBy(fn (array $row): string => (string) $row['type'])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function metaFor(?string $type): array
|
|
||||||
{
|
|
||||||
if (! filled($type)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::byType()[(string) $type] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function label(?string $type): ?string
|
|
||||||
{
|
|
||||||
$label = static::metaFor($type)['label'] ?? null;
|
|
||||||
|
|
||||||
return is_string($label) ? $label : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function category(?string $type): ?string
|
|
||||||
{
|
|
||||||
$category = static::metaFor($type)['category'] ?? null;
|
|
||||||
|
|
||||||
return is_string($category) ? $category : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isFoundation(?string $type): bool
|
|
||||||
{
|
|
||||||
if (! filled($type)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect(static::foundations())
|
|
||||||
->pluck('type')
|
|
||||||
->contains((string) $type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function restoreMode(?string $type): ?string
|
|
||||||
{
|
|
||||||
$restore = static::metaFor($type)['restore'] ?? null;
|
|
||||||
|
|
||||||
return is_string($restore) ? $restore : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function riskLevel(?string $type): ?string
|
|
||||||
{
|
|
||||||
$risk = static::metaFor($type)['risk'] ?? null;
|
|
||||||
|
|
||||||
return is_string($risk) ? $risk : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isRestorable(?string $type): bool
|
|
||||||
{
|
|
||||||
return static::restoreMode($type) === 'enabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isPartial(?string $type): bool
|
|
||||||
{
|
|
||||||
$restore = static::restoreMode($type);
|
|
||||||
|
|
||||||
return filled($restore) && $restore !== 'enabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isHighRisk(?string $type): bool
|
|
||||||
{
|
|
||||||
$risk = static::riskLevel($type);
|
|
||||||
|
|
||||||
return is_string($risk) && str_contains($risk, 'high');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function baselineCompareMeta(?string $type): array
|
|
||||||
{
|
|
||||||
$meta = static::metaFor($type)['baseline_compare'] ?? null;
|
|
||||||
|
|
||||||
return is_array($meta) ? $meta : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isBaselineSupportedFoundation(?string $type): bool
|
|
||||||
{
|
|
||||||
if (! static::isFoundation($type)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function baselineCompareIdentityStrategy(?string $type): string
|
|
||||||
{
|
|
||||||
$strategy = static::baselineCompareMeta($type)['identity_strategy'] ?? null;
|
|
||||||
|
|
||||||
return in_array($strategy, ['display_name', 'external_id'], true)
|
|
||||||
? (string) $strategy
|
|
||||||
: 'display_name';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function baselineCompareLabel(?string $type): ?string
|
|
||||||
{
|
|
||||||
$label = static::baselineCompareMeta($type)['label'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($label) && $label !== '') {
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::label($type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\OperateHub;
|
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
final class OperateHubShell
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private WorkspaceContext $workspaceContext,
|
|
||||||
private CapabilityResolver $capabilityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function scopeLabel(?Request $request = null): string
|
|
||||||
{
|
|
||||||
$activeTenant = $this->activeEntitledTenant($request);
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return 'Filtered by tenant: '.$activeTenant->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'All tenants';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}|null
|
|
||||||
*/
|
|
||||||
public function returnAffordance(?Request $request = null): ?array
|
|
||||||
{
|
|
||||||
$activeTenant = $this->activeEntitledTenant($request);
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => 'Back to '.$activeTenant->name,
|
|
||||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
public function headerActions(
|
|
||||||
string $scopeActionName = 'operate_hub_scope',
|
|
||||||
string $returnActionName = 'operate_hub_return',
|
|
||||||
?Request $request = null,
|
|
||||||
): array {
|
|
||||||
$actions = [
|
|
||||||
Action::make($scopeActionName)
|
|
||||||
->label($this->scopeLabel($request))
|
|
||||||
->color('gray')
|
|
||||||
->disabled(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$returnAffordance = $this->returnAffordance($request);
|
|
||||||
|
|
||||||
if (is_array($returnAffordance)) {
|
|
||||||
$actions[] = Action::make($returnActionName)
|
|
||||||
->label($returnAffordance['label'])
|
|
||||||
->icon('heroicon-o-arrow-left')
|
|
||||||
->color('gray')
|
|
||||||
->url($returnAffordance['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
|
||||||
{
|
|
||||||
return $this->resolveActiveTenant($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
|
|
||||||
|
|
||||||
if ($rememberedTenantId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
|
|
||||||
|
|
||||||
if (! $rememberedTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isEntitled($rememberedTenant, $request)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rememberedTenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
|
||||||
{
|
|
||||||
if (! $tenant->isActive()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
|
|
||||||
|
|
||||||
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->capabilityResolver->isMember($user, $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
|
|
||||||
final class OperationRunLinks
|
|
||||||
{
|
|
||||||
public static function index(?Tenant $tenant = null): string
|
|
||||||
{
|
|
||||||
return route('admin.operations.index');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function tenantlessView(OperationRun|int $run): string
|
|
||||||
{
|
|
||||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
|
||||||
|
|
||||||
return route('admin.operations.view', ['run' => $runId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
|
||||||
{
|
|
||||||
return self::tenantlessView($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function related(OperationRun $run, ?Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
|
|
||||||
$links = [];
|
|
||||||
|
|
||||||
$links['Operations'] = self::index($tenant);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return $links;
|
|
||||||
}
|
|
||||||
|
|
||||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
|
||||||
|
|
||||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
|
||||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
|
||||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'inventory_sync') {
|
|
||||||
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
|
||||||
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$policyId = $context['policy_id'] ?? null;
|
|
||||||
if (is_numeric($policyId)) {
|
|
||||||
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'entra_group_sync') {
|
|
||||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'baseline_compare') {
|
|
||||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$backupSetId = $context['backup_set_id'] ?? null;
|
|
||||||
if (is_numeric($backupSetId)) {
|
|
||||||
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
|
|
||||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'restore.execute') {
|
|
||||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
|
||||||
if (is_numeric($restoreRunId)) {
|
|
||||||
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\OpsUx;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
|
|
||||||
final class ActiveRuns
|
|
||||||
{
|
|
||||||
public static function existForTenant(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->active()
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\OpsUx;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\RedactionIntegrity;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
final class OperationUxPresenter
|
|
||||||
{
|
|
||||||
public const int QUEUED_TOAST_DURATION_MS = 4000;
|
|
||||||
|
|
||||||
public const int FAILURE_MESSAGE_MAX_CHARS = 140;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queued intent feedback toast (ephemeral, not persisted).
|
|
||||||
*/
|
|
||||||
public static function queuedToast(string $operationType): FilamentNotification
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label($operationType);
|
|
||||||
|
|
||||||
return FilamentNotification::make()
|
|
||||||
->title("{$operationLabel} queued")
|
|
||||||
->body('Running in the background.')
|
|
||||||
->warning()
|
|
||||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Canonical dedupe feedback when a matching run is already active.
|
|
||||||
*/
|
|
||||||
public static function alreadyQueuedToast(string $operationType): FilamentNotification
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label($operationType);
|
|
||||||
|
|
||||||
return FilamentNotification::make()
|
|
||||||
->title("{$operationLabel} already queued")
|
|
||||||
->body('A matching run is already queued or running.')
|
|
||||||
->info()
|
|
||||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminal DB notification payload.
|
|
||||||
*
|
|
||||||
* Note: We intentionally return the built Filament notification builder to
|
|
||||||
* keep DB formatting consistent with existing Notification classes.
|
|
||||||
*/
|
|
||||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
|
||||||
|
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
|
||||||
|
|
||||||
$titleSuffix = match ($uxStatus) {
|
|
||||||
'succeeded' => 'completed',
|
|
||||||
'partial' => 'completed with warnings',
|
|
||||||
default => 'failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
$body = match ($uxStatus) {
|
|
||||||
'succeeded' => 'Completed successfully.',
|
|
||||||
'partial' => 'Completed with warnings.',
|
|
||||||
default => 'Failed.',
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($uxStatus === 'failed') {
|
|
||||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
|
||||||
|
|
||||||
$failureMessage = self::sanitizeFailureMessage($failureMessage);
|
|
||||||
|
|
||||||
if ($failureMessage !== null) {
|
|
||||||
$body = $body.' '.$failureMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
|
||||||
if ($summary !== null) {
|
|
||||||
$body = $body."\n".$summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
|
||||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
|
||||||
$body = $body."\n".trim($integritySummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = match ($uxStatus) {
|
|
||||||
'succeeded' => 'success',
|
|
||||||
'partial' => 'warning',
|
|
||||||
default => 'danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
$notification = FilamentNotification::make()
|
|
||||||
->title("{$operationLabel} {$titleSuffix}")
|
|
||||||
->body($body)
|
|
||||||
->status($status);
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$notification->actions([
|
|
||||||
\Filament\Actions\Action::make('view')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunUrl::view($run, $tenant)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $notification;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function sanitizeFailureMessage(string $failureMessage): ?string
|
|
||||||
{
|
|
||||||
$failureMessage = trim($failureMessage);
|
|
||||||
|
|
||||||
if ($failureMessage === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureMessage = RunFailureSanitizer::sanitizeMessage($failureMessage);
|
|
||||||
|
|
||||||
if (mb_strlen($failureMessage) > self::FAILURE_MESSAGE_MAX_CHARS) {
|
|
||||||
$failureMessage = mb_substr($failureMessage, 0, self::FAILURE_MESSAGE_MAX_CHARS - 1).'…';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $failureMessage !== '' ? $failureMessage : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Providers;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
|
|
||||||
final class ProviderNextStepsRegistry
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<int, array{label: string, url: string}>
|
|
||||||
*/
|
|
||||||
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing,
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch => [
|
|
||||||
[
|
|
||||||
'label' => 'Manage Provider Connections',
|
|
||||||
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing,
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing => [
|
|
||||||
[
|
|
||||||
'label' => 'Grant admin consent',
|
|
||||||
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections',
|
|
||||||
'url' => $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied,
|
|
||||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
|
||||||
[
|
|
||||||
'label' => 'Open Required Permissions',
|
|
||||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::NetworkUnreachable,
|
|
||||||
ProviderReasonCodes::RateLimited,
|
|
||||||
ProviderReasonCodes::UnknownError => [
|
|
||||||
[
|
|
||||||
'label' => 'Review Provider Connection',
|
|
||||||
'url' => $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
[
|
|
||||||
'label' => 'Manage Provider Connections',
|
|
||||||
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
enum RbacReason: string
|
|
||||||
{
|
|
||||||
case MissingArtifacts = 'missing_artifacts';
|
|
||||||
case ServicePrincipalMissing = 'sp_missing';
|
|
||||||
case GroupMissing = 'group_missing';
|
|
||||||
case ServicePrincipalNotMember = 'sp_not_member';
|
|
||||||
case AssignmentMissing = 'assignment_missing';
|
|
||||||
case RoleMismatch = 'role_mismatch';
|
|
||||||
case ScopeMismatch = 'scope_mismatch';
|
|
||||||
case CanaryFailed = 'canary_failed';
|
|
||||||
case ManualAssignmentRequired = 'manual_assignment_required';
|
|
||||||
case UnsupportedApi = 'unsupported_api';
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\ActionSurface;
|
|
||||||
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
|
|
||||||
final class ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string, ActionSurfaceSlotRequirement>
|
|
||||||
*/
|
|
||||||
private array $slots = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, ActionSurfaceExemption>
|
|
||||||
*/
|
|
||||||
private array $exemptions = [];
|
|
||||||
|
|
||||||
public ActionSurfaceDefaults $defaults;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $version,
|
|
||||||
public readonly ActionSurfaceComponentType $componentType,
|
|
||||||
public readonly ActionSurfaceProfile $profile,
|
|
||||||
?ActionSurfaceDefaults $defaults = null,
|
|
||||||
) {
|
|
||||||
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function make(
|
|
||||||
ActionSurfaceComponentType $componentType,
|
|
||||||
ActionSurfaceProfile $profile,
|
|
||||||
int $version = 1,
|
|
||||||
): self {
|
|
||||||
return new self(
|
|
||||||
version: $version,
|
|
||||||
componentType: $componentType,
|
|
||||||
profile: $profile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
|
|
||||||
{
|
|
||||||
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
|
|
||||||
{
|
|
||||||
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
|
|
||||||
{
|
|
||||||
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withDefaults(ActionSurfaceDefaults $defaults): self
|
|
||||||
{
|
|
||||||
$this->defaults = $defaults;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSlot(ActionSurfaceSlot $slot, ActionSurfaceSlotRequirement $requirement): self
|
|
||||||
{
|
|
||||||
$this->slots[$slot->value] = $requirement;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function satisfy(
|
|
||||||
ActionSurfaceSlot $slot,
|
|
||||||
?string $details = null,
|
|
||||||
bool $requiresTypedConfirmation = false,
|
|
||||||
): self {
|
|
||||||
return $this->setSlot($slot, ActionSurfaceSlotRequirement::satisfied($details, $requiresTypedConfirmation));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exempt(
|
|
||||||
ActionSurfaceSlot $slot,
|
|
||||||
string $reason,
|
|
||||||
?string $trackingRef = null,
|
|
||||||
?string $details = null,
|
|
||||||
): self {
|
|
||||||
$this->setSlot($slot, ActionSurfaceSlotRequirement::exempt($details));
|
|
||||||
$this->exemptions[$slot->value] = new ActionSurfaceExemption($slot, $reason, $trackingRef);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function slot(ActionSurfaceSlot $slot): ?ActionSurfaceSlotRequirement
|
|
||||||
{
|
|
||||||
return $this->slots[$slot->value] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exemption(ActionSurfaceSlot $slot): ?ActionSurfaceExemption
|
|
||||||
{
|
|
||||||
return $this->exemptions[$slot->value] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, ActionSurfaceSlotRequirement>
|
|
||||||
*/
|
|
||||||
public function slots(): array
|
|
||||||
{
|
|
||||||
return $this->slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, ActionSurfaceExemption>
|
|
||||||
*/
|
|
||||||
public function exemptions(): array
|
|
||||||
{
|
|
||||||
return $this->exemptions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\ActionSurface;
|
|
||||||
|
|
||||||
final class ActionSurfaceExemptions
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $componentReasons
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly array $componentReasons,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function baseline(): self
|
|
||||||
{
|
|
||||||
return new self([
|
|
||||||
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
|
||||||
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
|
||||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
|
||||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
|
||||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
|
||||||
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.',
|
|
||||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
|
|
||||||
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
|
|
||||||
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',
|
|
||||||
'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.',
|
|
||||||
'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.',
|
|
||||||
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
|
|
||||||
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
|
||||||
'App\\Filament\\Pages\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.',
|
|
||||||
'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.',
|
|
||||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
|
||||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
|
||||||
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
|
|
||||||
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
|
||||||
'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.',
|
|
||||||
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
|
|
||||||
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return $this->componentReasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reasonForClass(string $className): ?string
|
|
||||||
{
|
|
||||||
return $this->componentReasons[$className] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasClass(string $className): bool
|
|
||||||
{
|
|
||||||
return array_key_exists($className, $this->componentReasons);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\ActionSurface\Enums;
|
|
||||||
|
|
||||||
enum ActionSurfaceInspectAffordance: string
|
|
||||||
{
|
|
||||||
case ClickableRow = 'clickable_row';
|
|
||||||
case ViewAction = 'view_action';
|
|
||||||
case PrimaryLinkColumn = 'primary_link_column';
|
|
||||||
}
|
|
||||||
@ -1,476 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Workspaces;
|
|
||||||
|
|
||||||
use App\Filament\Pages\ChooseTenant;
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
final class WorkspaceOverviewBuilder
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace: array{id: int, name: string, slug: ?string},
|
|
||||||
* accessible_tenant_count: int,
|
|
||||||
* summary_metrics: list<array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* value: int,
|
|
||||||
* description: string,
|
|
||||||
* destination_url: ?string,
|
|
||||||
* color: string
|
|
||||||
* }>,
|
|
||||||
* attention_items: list<array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* url: string,
|
|
||||||
* badge: string,
|
|
||||||
* badge_color: string
|
|
||||||
* }>,
|
|
||||||
* attention_empty_state: array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* action_label: string,
|
|
||||||
* action_url: string
|
|
||||||
* },
|
|
||||||
* recent_operations: list<array{
|
|
||||||
* id: int,
|
|
||||||
* title: string,
|
|
||||||
* tenant_label: ?string,
|
|
||||||
* status_label: string,
|
|
||||||
* status_color: string,
|
|
||||||
* outcome_label: string,
|
|
||||||
* outcome_color: string,
|
|
||||||
* started_at: string,
|
|
||||||
* url: string
|
|
||||||
* }>,
|
|
||||||
* recent_operations_empty_state: array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* action_label: string,
|
|
||||||
* action_url: string
|
|
||||||
* },
|
|
||||||
* quick_actions: list<array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* description: string,
|
|
||||||
* url: string,
|
|
||||||
* icon: string,
|
|
||||||
* color: string
|
|
||||||
* }>,
|
|
||||||
* zero_tenant_state: ?array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* action_label: string,
|
|
||||||
* action_url: string
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function build(Workspace $workspace, User $user): array
|
|
||||||
{
|
|
||||||
$accessibleTenants = $this->accessibleTenants($workspace, $user);
|
|
||||||
$accessibleTenantIds = $accessibleTenants
|
|
||||||
->pluck('id')
|
|
||||||
->map(static fn (mixed $id): int => (int) $id)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
|
||||||
|
|
||||||
$recentOperations = $this->recentOperations((int) $workspace->getKey(), $accessibleTenantIds);
|
|
||||||
$attentionItems = $this->attentionItems((int) $workspace->getKey(), $accessibleTenantIds, $canViewAlerts);
|
|
||||||
$quickActions = $this->quickActions($workspace, $accessibleTenants->count(), $canViewAlerts, $user);
|
|
||||||
|
|
||||||
$zeroTenantState = null;
|
|
||||||
|
|
||||||
if ($accessibleTenants->isEmpty()) {
|
|
||||||
$fallbackAction = collect($quickActions)
|
|
||||||
->first(fn (array $action): bool => in_array($action['key'], ['manage_workspaces', 'switch_workspace'], true));
|
|
||||||
|
|
||||||
$zeroTenantState = [
|
|
||||||
'title' => 'No accessible tenants in this workspace',
|
|
||||||
'body' => 'You can still review workspace-wide operations or switch to another workspace while tenant access is being set up.',
|
|
||||||
'action_label' => is_array($fallbackAction) ? $fallbackAction['label'] : 'Switch workspace',
|
|
||||||
'action_url' => is_array($fallbackAction) ? $fallbackAction['url'] : $this->switchWorkspaceUrl(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => [
|
|
||||||
'id' => (int) $workspace->getKey(),
|
|
||||||
'name' => (string) $workspace->name,
|
|
||||||
'slug' => filled($workspace->slug) ? (string) $workspace->slug : null,
|
|
||||||
],
|
|
||||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
|
||||||
'summary_metrics' => $this->summaryMetrics(
|
|
||||||
workspaceId: (int) $workspace->getKey(),
|
|
||||||
accessibleTenantCount: $accessibleTenants->count(),
|
|
||||||
accessibleTenantIds: $accessibleTenantIds,
|
|
||||||
canViewAlerts: $canViewAlerts,
|
|
||||||
needsAttentionCount: count($attentionItems),
|
|
||||||
),
|
|
||||||
'attention_items' => $attentionItems,
|
|
||||||
'attention_empty_state' => [
|
|
||||||
'title' => 'Nothing urgent in your current scope',
|
|
||||||
'body' => 'Recent operations and alert deliveries look healthy right now.',
|
|
||||||
'action_label' => $canViewAlerts ? 'Open alerts' : 'Open operations',
|
|
||||||
'action_url' => $canViewAlerts ? '/admin/alerts' : route('admin.operations.index'),
|
|
||||||
],
|
|
||||||
'recent_operations' => $recentOperations,
|
|
||||||
'recent_operations_empty_state' => [
|
|
||||||
'title' => 'No recent operations yet',
|
|
||||||
'body' => 'Workspace-wide activity will show up here once syncs, evaluations, or restores start running.',
|
|
||||||
'action_label' => 'Open operations',
|
|
||||||
'action_url' => route('admin.operations.index'),
|
|
||||||
],
|
|
||||||
'quick_actions' => $quickActions,
|
|
||||||
'zero_tenant_state' => $zeroTenantState,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function accessibleTenants(Workspace $workspace, User $user): Collection
|
|
||||||
{
|
|
||||||
return Tenant::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('status', 'active')
|
|
||||||
->whereIn('id', $user->tenantMemberships()->select('tenant_id'))
|
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name', 'external_id', 'workspace_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $accessibleTenantIds
|
|
||||||
* @return list<array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* value: int,
|
|
||||||
* description: string,
|
|
||||||
* destination_url: ?string,
|
|
||||||
* color: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function summaryMetrics(
|
|
||||||
int $workspaceId,
|
|
||||||
int $accessibleTenantCount,
|
|
||||||
array $accessibleTenantIds,
|
|
||||||
bool $canViewAlerts,
|
|
||||||
int $needsAttentionCount,
|
|
||||||
): array {
|
|
||||||
$activeOperationsCount = (int) $this->scopeToAuthorizedTenants(
|
|
||||||
OperationRun::query(),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$metrics = [
|
|
||||||
[
|
|
||||||
'key' => 'accessible_tenants',
|
|
||||||
'label' => 'Accessible tenants',
|
|
||||||
'value' => $accessibleTenantCount,
|
|
||||||
'description' => $accessibleTenantCount > 0
|
|
||||||
? 'Tenant drill-down stays explicit from this workspace home.'
|
|
||||||
: 'No tenant memberships are available in this workspace yet.',
|
|
||||||
'destination_url' => $accessibleTenantCount > 0 ? ChooseTenant::getUrl(panel: 'admin') : null,
|
|
||||||
'color' => 'primary',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'active_operations',
|
|
||||||
'label' => 'Active operations',
|
|
||||||
'value' => $activeOperationsCount,
|
|
||||||
'description' => 'Workspace-wide runs that are still queued or in progress.',
|
|
||||||
'destination_url' => route('admin.operations.index'),
|
|
||||||
'color' => $activeOperationsCount > 0 ? 'warning' : 'gray',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($canViewAlerts) {
|
|
||||||
$failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants(
|
|
||||||
AlertDelivery::query(),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->where('created_at', '>=', now()->subDays(7))
|
|
||||||
->where('status', AlertDelivery::STATUS_FAILED)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$metrics[] = [
|
|
||||||
'key' => 'alerts',
|
|
||||||
'label' => 'Alert failures (7d)',
|
|
||||||
'value' => $failedAlertDeliveriesCount,
|
|
||||||
'description' => 'Failed alert deliveries in the last 7 days.',
|
|
||||||
'destination_url' => AlertDeliveryResource::getUrl(panel: 'admin'),
|
|
||||||
'color' => $failedAlertDeliveriesCount > 0 ? 'danger' : 'gray',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$metrics[] = [
|
|
||||||
'key' => 'needs_attention',
|
|
||||||
'label' => 'Needs attention',
|
|
||||||
'value' => $needsAttentionCount,
|
|
||||||
'description' => 'Urgent workspace-safe items surfaced below.',
|
|
||||||
'destination_url' => $needsAttentionCount > 0 ? route('admin.operations.index') : null,
|
|
||||||
'color' => $needsAttentionCount > 0 ? 'warning' : 'gray',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $metrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $accessibleTenantIds
|
|
||||||
* @return list<array{
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* url: string,
|
|
||||||
* badge: string,
|
|
||||||
* badge_color: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function attentionItems(int $workspaceId, array $accessibleTenantIds, bool $canViewAlerts): array
|
|
||||||
{
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
$latestFailedRun = $this->scopeToAuthorizedTenants(
|
|
||||||
OperationRun::query()->with('tenant'),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereIn('outcome', [
|
|
||||||
OperationRunOutcome::Failed->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
])
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestFailedRun instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => OperationCatalog::label((string) $latestFailedRun->type).' needs review',
|
|
||||||
'body' => 'Latest outcome: '.BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $latestFailedRun->outcome)->label.'.',
|
|
||||||
'url' => route('admin.operations.view', ['run' => (int) $latestFailedRun->getKey()]),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badge_color' => $latestFailedRun->outcome === OperationRunOutcome::Failed->value ? 'danger' : 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRunsCount = (int) $this->scopeToAuthorizedTenants(
|
|
||||||
OperationRun::query(),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRunsCount > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Operations are still running',
|
|
||||||
'body' => $activeRunsCount.' workspace run(s) are active right now.',
|
|
||||||
'url' => route('admin.operations.index'),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badge_color' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($canViewAlerts) {
|
|
||||||
$failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants(
|
|
||||||
AlertDelivery::query(),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->where('created_at', '>=', now()->subDays(7))
|
|
||||||
->where('status', AlertDelivery::STATUS_FAILED)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($failedAlertDeliveriesCount > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Alert deliveries failed',
|
|
||||||
'body' => $failedAlertDeliveriesCount.' alert delivery attempt(s) failed in the last 7 days.',
|
|
||||||
'url' => AlertDeliveryResource::getUrl(panel: 'admin'),
|
|
||||||
'badge' => 'Alerts',
|
|
||||||
'badge_color' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_slice($items, 0, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $accessibleTenantIds
|
|
||||||
* @return list<array{
|
|
||||||
* id: int,
|
|
||||||
* title: string,
|
|
||||||
* tenant_label: ?string,
|
|
||||||
* status_label: string,
|
|
||||||
* status_color: string,
|
|
||||||
* outcome_label: string,
|
|
||||||
* outcome_color: string,
|
|
||||||
* started_at: string,
|
|
||||||
* url: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function recentOperations(int $workspaceId, array $accessibleTenantIds): array
|
|
||||||
{
|
|
||||||
$statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus);
|
|
||||||
$statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus);
|
|
||||||
$outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome);
|
|
||||||
$outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome);
|
|
||||||
|
|
||||||
return $this->scopeToAuthorizedTenants(
|
|
||||||
OperationRun::query()->with('tenant'),
|
|
||||||
$workspaceId,
|
|
||||||
$accessibleTenantIds,
|
|
||||||
)
|
|
||||||
->latest('created_at')
|
|
||||||
->limit(5)
|
|
||||||
->get()
|
|
||||||
->map(function (OperationRun $run) use ($statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array {
|
|
||||||
return [
|
|
||||||
'id' => (int) $run->getKey(),
|
|
||||||
'title' => OperationCatalog::label((string) $run->type),
|
|
||||||
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
|
|
||||||
'status_label' => $statusSpec($run->status),
|
|
||||||
'status_color' => $statusColorSpec($run->status),
|
|
||||||
'outcome_label' => $outcomeSpec($run->outcome),
|
|
||||||
'outcome_color' => $outcomeColorSpec($run->outcome),
|
|
||||||
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
|
||||||
'url' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $accessibleTenantIds
|
|
||||||
*/
|
|
||||||
private function scopeToAuthorizedTenants(Builder $query, int $workspaceId, array $accessibleTenantIds): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where(function (Builder $query) use ($accessibleTenantIds): void {
|
|
||||||
$query->whereNull('tenant_id');
|
|
||||||
|
|
||||||
if ($accessibleTenantIds !== []) {
|
|
||||||
$query->orWhereIn('tenant_id', $accessibleTenantIds);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* description: string,
|
|
||||||
* url: string,
|
|
||||||
* icon: string,
|
|
||||||
* color: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function quickActions(Workspace $workspace, int $accessibleTenantCount, bool $canViewAlerts, User $user): array
|
|
||||||
{
|
|
||||||
$actions = [
|
|
||||||
[
|
|
||||||
'key' => 'choose_tenant',
|
|
||||||
'label' => 'Choose tenant',
|
|
||||||
'description' => 'Deliberately enter tenant context from this workspace.',
|
|
||||||
'url' => ChooseTenant::getUrl(panel: 'admin'),
|
|
||||||
'icon' => 'heroicon-o-building-office-2',
|
|
||||||
'color' => 'primary',
|
|
||||||
'visible' => $accessibleTenantCount > 0,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'operations',
|
|
||||||
'label' => 'Open operations',
|
|
||||||
'description' => 'Review current and recent workspace-wide runs.',
|
|
||||||
'url' => route('admin.operations.index'),
|
|
||||||
'icon' => 'heroicon-o-queue-list',
|
|
||||||
'color' => 'gray',
|
|
||||||
'visible' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'alerts',
|
|
||||||
'label' => 'Open alerts',
|
|
||||||
'description' => 'Inspect alert overview, rules, and deliveries.',
|
|
||||||
'url' => '/admin/alerts',
|
|
||||||
'icon' => 'heroicon-o-bell-alert',
|
|
||||||
'color' => 'gray',
|
|
||||||
'visible' => $canViewAlerts,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'switch_workspace',
|
|
||||||
'label' => 'Switch workspace',
|
|
||||||
'description' => 'Change the active workspace context.',
|
|
||||||
'url' => $this->switchWorkspaceUrl(),
|
|
||||||
'icon' => 'heroicon-o-arrows-right-left',
|
|
||||||
'color' => 'gray',
|
|
||||||
'visible' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'manage_workspaces',
|
|
||||||
'label' => 'Manage workspaces',
|
|
||||||
'description' => 'Open workspace management and memberships.',
|
|
||||||
'url' => route('filament.admin.resources.workspaces.index'),
|
|
||||||
'icon' => 'heroicon-o-squares-2x2',
|
|
||||||
'color' => 'gray',
|
|
||||||
'visible' => $this->canManageWorkspaces($workspace, $user),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return collect($actions)
|
|
||||||
->filter(fn (array $action): bool => (bool) $action['visible'])
|
|
||||||
->map(function (array $action): array {
|
|
||||||
unset($action['visible']);
|
|
||||||
|
|
||||||
return $action;
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageWorkspaces(Workspace $workspace, User $user): bool
|
|
||||||
{
|
|
||||||
if ($this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
|
||||||
|
|
||||||
return $user->workspaceMemberships()
|
|
||||||
->whereIn('role', $roles)
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function switchWorkspaceUrl(): string
|
|
||||||
{
|
|
||||||
return route('filament.admin.pages.choose-workspace').'?choose=1';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,6 +3,8 @@ APP_ENV=local
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
SAIL_FILES=../../docker-compose.yml
|
||||||
|
TENANTATLAS_REPO_ROOT=../..
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@ -21,11 +23,12 @@ LOG_DEPRECATIONS_CHANNEL=null
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=pgsql
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
FORWARD_DB_PORT=55432
|
||||||
DB_DATABASE=tenantatlas
|
DB_DATABASE=tenantatlas
|
||||||
DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
@ -43,7 +46,7 @@ CACHE_STORE=database
|
|||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
REDIS_CLIENT=phpredis
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||||
|
use App\Services\Providers\ProviderConnectionClassifier;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use App\Support\Providers\ProviderCredentialKind;
|
||||||
|
use App\Support\Providers\ProviderCredentialSource;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ClassifyProviderConnections extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:provider-connections:classify
|
||||||
|
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
|
||||||
|
{--connection= : Restrict to a single provider connection id}
|
||||||
|
{--provider=microsoft : Restrict to one provider}
|
||||||
|
{--chunk=100 : Chunk size for large write runs}
|
||||||
|
{--write : Persist the classification results}';
|
||||||
|
|
||||||
|
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
|
||||||
|
|
||||||
|
public function handle(ProviderConnectionClassifier $classifier): int
|
||||||
|
{
|
||||||
|
$query = $this->query();
|
||||||
|
$write = (bool) $this->option('write');
|
||||||
|
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||||
|
|
||||||
|
$candidateCount = (clone $query)->count();
|
||||||
|
|
||||||
|
if ($candidateCount === 0) {
|
||||||
|
$this->info('No provider connections matched the classification scope.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantCounts = (clone $query)
|
||||||
|
->selectRaw('tenant_id, count(*) as aggregate')
|
||||||
|
->groupBy('tenant_id')
|
||||||
|
->pluck('aggregate', 'tenant_id')
|
||||||
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$startedTenants = [];
|
||||||
|
$classifiedCount = 0;
|
||||||
|
$appliedCount = 0;
|
||||||
|
$reviewRequiredCount = 0;
|
||||||
|
|
||||||
|
$query
|
||||||
|
->with(['tenant', 'credential'])
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunkSize, function ($connections) use (
|
||||||
|
$classifier,
|
||||||
|
$write,
|
||||||
|
$tenantCounts,
|
||||||
|
&$startedTenants,
|
||||||
|
&$classifiedCount,
|
||||||
|
&$appliedCount,
|
||||||
|
&$reviewRequiredCount,
|
||||||
|
): void {
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$classifiedCount++;
|
||||||
|
|
||||||
|
$result = $classifier->classify(
|
||||||
|
$connection,
|
||||||
|
source: 'tenantpilot:provider-connections:classify',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->reviewRequired) {
|
||||||
|
$reviewRequiredCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $write) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $connection->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantKey = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
if (! array_key_exists($tenantKey, $startedTenants)) {
|
||||||
|
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
|
||||||
|
$startedTenants[$tenantKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = $this->applyClassification($connection, $result);
|
||||||
|
$this->auditApplied($tenant, $connection, $result);
|
||||||
|
$appliedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($write) {
|
||||||
|
$this->info(sprintf('Applied classifications: %d', $appliedCount));
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
|
||||||
|
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(): Builder
|
||||||
|
{
|
||||||
|
$query = ProviderConnection::query()
|
||||||
|
->where('provider', (string) $this->option('provider'));
|
||||||
|
|
||||||
|
$tenantOption = $this->option('tenant');
|
||||||
|
|
||||||
|
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant(trim($tenantOption))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$query->where('tenant_id', (int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionOption = $this->option('connection');
|
||||||
|
|
||||||
|
if (is_numeric($connectionOption)) {
|
||||||
|
$query->whereKey((int) $connectionOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyClassification(
|
||||||
|
ProviderConnection $connection,
|
||||||
|
ProviderConnectionClassificationResult $result,
|
||||||
|
): ProviderConnection {
|
||||||
|
DB::transaction(function () use ($connection, $result): void {
|
||||||
|
$connection->forceFill(
|
||||||
|
$connection->classificationProjection($result)
|
||||||
|
)->save();
|
||||||
|
|
||||||
|
$credential = $connection->credential;
|
||||||
|
|
||||||
|
if (! $credential instanceof ProviderCredential) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
|
||||||
|
&& $credential->source === null
|
||||||
|
) {
|
||||||
|
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
|
||||||
|
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updates !== []) {
|
||||||
|
$credential->forceFill($updates)->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $connection->fresh(['tenant', 'credential']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||||
|
{
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.migration_classification_started',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'tenantpilot:provider-connections:classify',
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'candidate_count' => $candidateCount,
|
||||||
|
'write' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditApplied(
|
||||||
|
Tenant $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
ProviderConnectionClassificationResult $result,
|
||||||
|
): void {
|
||||||
|
$effectiveApp = $connection->effectiveAppMetadata();
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.migration_classification_applied',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'tenantpilot:provider-connections:classify',
|
||||||
|
'workspace_id' => (int) $connection->workspace_id,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
'connection_type' => $connection->connection_type->value,
|
||||||
|
'migration_review_required' => $connection->migration_review_required,
|
||||||
|
'legacy_identity_result' => $result->suggestedConnectionType->value,
|
||||||
|
'effective_app_id' => $effectiveApp['app_id'],
|
||||||
|
'effective_app_source' => $effectiveApp['source'],
|
||||||
|
'signals' => $result->signals,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $connection->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PurgeLegacyBaselineGapRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||||
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||||
|
{--workspace=* : Limit cleanup to workspace ids}
|
||||||
|
{--limit=500 : Maximum candidate runs to inspect}
|
||||||
|
{--force : Actually delete matched legacy runs}';
|
||||||
|
|
||||||
|
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing'])) {
|
||||||
|
$this->error('This cleanup command is limited to local and testing environments.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = $this->normalizedTypes();
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$dryRun = ! (bool) $this->option('force');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($workspaceIds !== []) {
|
||||||
|
$query->whereIn('workspace_id', $workspaceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIds !== []) {
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get();
|
||||||
|
$matched = $candidates
|
||||||
|
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($matched->isEmpty()) {
|
||||||
|
$this->info('No legacy baseline gap runs matched the current filters.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||||
|
$matched
|
||||||
|
->map(fn (OperationRun $run): array => [
|
||||||
|
'Run' => (string) $run->getKey(),
|
||||||
|
'Type' => (string) $run->type,
|
||||||
|
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
||||||
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||||
|
'Legacy signal' => $this->legacySignal($run),
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
||||||
|
$matched->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationRun::query()
|
||||||
|
->whereKey($matched->modelKeys())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizedTypes(): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_unique(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||||
|
(array) $this->option('type'),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
return ['baseline_compare', 'baseline_capture'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$types,
|
||||||
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacySignal(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
||||||
|
$byReason = is_array($byReason) ? $byReason : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return 'legacy_reason_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'legacy_subject_shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class SeedBackupHealthBrowserFixture extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:backup-health:seed-browser-fixture {--force-refresh : Rebuild the fixture backup basis even if it already exists}';
|
||||||
|
|
||||||
|
protected $description = 'Seed a local/testing browser fixture for the Spec 180 blocked backup drill-through scenario.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing'])) {
|
||||||
|
$this->error('This fixture command is limited to local and testing environments.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||||
|
|
||||||
|
if (! is_array($fixture)) {
|
||||||
|
$this->error('The backup-health browser smoke fixture is not configured.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||||
|
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||||
|
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
|
||||||
|
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->updateOrCreate(
|
||||||
|
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
||||||
|
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 180 Backup Health Smoke')],
|
||||||
|
);
|
||||||
|
|
||||||
|
$password = (string) ($userConfig['password'] ?? 'password');
|
||||||
|
|
||||||
|
$user = User::query()->updateOrCreate(
|
||||||
|
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+180@tenantpilot.local')],
|
||||||
|
[
|
||||||
|
'name' => (string) ($userConfig['name'] ?? 'Spec 180 Requester'),
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->updateOrCreate(
|
||||||
|
['external_id' => $tenantRouteKey],
|
||||||
|
[
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||||
|
'tenant_id' => $tenantRouteKey,
|
||||||
|
'app_certificate_thumbprint' => null,
|
||||||
|
'app_status' => 'ok',
|
||||||
|
'app_notes' => null,
|
||||||
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
|
'environment' => 'dev',
|
||||||
|
'is_current' => false,
|
||||||
|
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||||
|
'rbac_status' => 'ok',
|
||||||
|
'rbac_last_checked_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkspaceMembership::query()->updateOrCreate(
|
||||||
|
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
||||||
|
['role' => 'owner'],
|
||||||
|
);
|
||||||
|
|
||||||
|
TenantMembership::query()->updateOrCreate(
|
||||||
|
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||||
|
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
||||||
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('user_tenant_preferences')) {
|
||||||
|
UserTenantPreference::query()->updateOrCreate(
|
||||||
|
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
|
||||||
|
['last_used_at' => now()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = Policy::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||||
|
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'display_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_synced_at' => now(),
|
||||||
|
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::withTrashed()->firstOrNew([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet->forceFill([
|
||||||
|
'created_by' => (string) $user->email,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||||
|
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||||
|
'deleted_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if (method_exists($backupSet, 'trashed') && $backupSet->trashed()) {
|
||||||
|
$backupSet->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItem = BackupItem::withTrashed()->firstOrNew([
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'policy_identifier' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||||
|
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem->forceFill([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'captured_at' => $backupSet->completed_at,
|
||||||
|
'payload' => [
|
||||||
|
'id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||||
|
'name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'policy_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||||
|
'fixture' => 'spec-180-browser-smoke',
|
||||||
|
],
|
||||||
|
'assignments' => [],
|
||||||
|
'deleted_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if (method_exists($backupItem, 'trashed') && $backupItem->trashed()) {
|
||||||
|
$backupItem->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) $this->option('force-refresh')) {
|
||||||
|
$backupSet->forceFill([
|
||||||
|
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$backupItem->forceFill([
|
||||||
|
'captured_at' => $backupSet->completed_at,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Fixture', 'Value'],
|
||||||
|
[
|
||||||
|
['Workspace', (string) $workspace->name],
|
||||||
|
['User email', (string) $user->email],
|
||||||
|
['User password', $password],
|
||||||
|
['Tenant', (string) $tenant->name],
|
||||||
|
['Tenant external id', (string) $tenant->external_id],
|
||||||
|
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
|
||||||
|
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
|
||||||
|
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
|
||||||
|
['Locally denied capability', 'tenant.view'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('The dashboard remains visible for this fixture user, while backup drill-through routes stay forbidden via a local/testing-only capability deny seam.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user