Compare commits
1 Commits
dev
...
115-baseli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c4432de1 |
@ -1,76 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,104 +0,0 @@
|
||||
---
|
||||
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,12 +1,5 @@
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
coverage/
|
||||
.git/
|
||||
.DS_Store
|
||||
@ -23,19 +16,12 @@ Dockerfile*
|
||||
*.tmp
|
||||
*.swp
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
public/hot/
|
||||
apps/platform/public/hot/
|
||||
public/storage/
|
||||
apps/platform/public/storage/
|
||||
storage/framework/
|
||||
apps/platform/storage/framework/
|
||||
storage/logs/
|
||||
apps/platform/storage/logs/
|
||||
storage/debugbar/
|
||||
apps/platform/storage/debugbar/
|
||||
storage/*.key
|
||||
apps/platform/storage/*.key
|
||||
/references/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@ -3,8 +3,6 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
SAIL_FILES=../../docker-compose.yml
|
||||
TENANTATLAS_REPO_ROOT=../..
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@ -23,12 +21,11 @@ LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
FORWARD_DB_PORT=55432
|
||||
DB_DATABASE=tenantatlas
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=postgres
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@ -46,7 +43,7 @@ CACHE_STORE=database
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
@ -76,10 +73,3 @@ ENTRA_AUTHORITY_TENANT=organizations
|
||||
# System panel break-glass (Platform Operators)
|
||||
BREAK_GLASS_ENABLED=false
|
||||
BREAK_GLASS_TTL_MINUTES=60
|
||||
|
||||
# Baselines (Spec 118: full-content drift detection)
|
||||
TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
|
||||
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90
|
||||
178
.github/agents/copilot-instructions.md
vendored
178
.github/agents/copilot-instructions.md
vendored
@ -2,14 +2,6 @@ # TenantAtlas Development Guidelines
|
||||
|
||||
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
|
||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||
@ -47,185 +39,27 @@ ## Active Technologies
|
||||
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
|
||||
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
|
||||
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
|
||||
- PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail (120-secret-redaction-integrity)
|
||||
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix)
|
||||
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
|
||||
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
|
||||
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
|
||||
- PostgreSQL primary app database (123-operations-auto-refresh)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
|
||||
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
|
||||
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
|
||||
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||
- 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 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
|
||||
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`) (194-governance-friction-hardening)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers (195-action-surface-closure)
|
||||
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers (196-hard-filament-nativity-cleanup)
|
||||
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (202-governance-subject-taxonomy)
|
||||
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy)
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
|
||||
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services (205-compare-job-cleanup)
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` (197-shared-detail-contract)
|
||||
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
|
||||
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
apps/
|
||||
platform/
|
||||
website/
|
||||
docs/
|
||||
specs/
|
||||
scripts/
|
||||
src/
|
||||
tests/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- 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`
|
||||
# Add commands for PHP 8.4.15
|
||||
|
||||
## Code Style
|
||||
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
||||
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
||||
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
|
||||
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- 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:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- 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”
|
||||
|
||||
## Deployment / Ops
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `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”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -291,12 +291,8 @@ ## Application Structure & Architecture
|
||||
- Stick to existing directory structure; don't create new base folders 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
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
@ -376,29 +372,28 @@ ## Enums
|
||||
## Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- 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.
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- 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.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- 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.
|
||||
- If you're creating a generic PHP class, use `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.
|
||||
|
||||
### Database
|
||||
@ -409,7 +404,7 @@ ### Database
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### 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 `cd apps/platform && ./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 `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### 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.
|
||||
@ -433,10 +428,10 @@ ### Configuration
|
||||
### 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.
|
||||
- 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 `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.
|
||||
- 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.
|
||||
|
||||
### Vite Error
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -465,7 +460,7 @@ ### Models
|
||||
## Livewire
|
||||
|
||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||
- Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- Use the `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.
|
||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||
|
||||
@ -509,8 +504,8 @@ ## Testing Livewire
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
|
||||
- You must run `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.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
@ -519,7 +514,7 @@ ### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- All tests must be written using Pest. Use `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.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
@ -532,9 +527,9 @@ ### Pest Tests
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`.
|
||||
- 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: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
||||
- To run all tests in a file: `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).
|
||||
- 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
|
||||
|
||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
@ -1,76 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,105 +0,0 @@
|
||||
---
|
||||
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.
|
||||
8
.github/skills/giteaflow/SKILL.md
vendored
8
.github/skills/giteaflow/SKILL.md
vendored
@ -1,8 +0,0 @@
|
||||
---
|
||||
name: giteaflow
|
||||
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||
---
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@ -15,42 +15,22 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/apps/platform/node_modules
|
||||
/apps/website/node_modules
|
||||
/.pnpm-store
|
||||
/apps/website/.astro
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/apps/platform/public/build
|
||||
/apps/website/dist
|
||||
/public/hot
|
||||
/apps/platform/public/hot
|
||||
/public/storage
|
||||
/apps/platform/public/storage
|
||||
/storage/*.key
|
||||
/apps/platform/storage/*.key
|
||||
/storage/pail
|
||||
/apps/platform/storage/pail
|
||||
/storage/framework
|
||||
/apps/platform/storage/framework
|
||||
/storage/logs
|
||||
/apps/platform/storage/logs
|
||||
/storage/debugbar
|
||||
/apps/platform/storage/debugbar
|
||||
/vendor
|
||||
/apps/platform/vendor
|
||||
/bootstrap/cache
|
||||
/apps/platform/bootstrap/cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/tests/Browser/Screenshots
|
||||
*.tmp
|
||||
*.swp
|
||||
/apps/platform/.env
|
||||
/apps/platform/.env.*
|
||||
/apps/website/.env
|
||||
/apps/website/.env.*
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
dist/
|
||||
build/
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -2,22 +2,12 @@ node_modules/
|
||||
dist/
|
||||
build/
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
public/hot/
|
||||
apps/platform/public/hot/
|
||||
public/storage/
|
||||
apps/platform/public/storage/
|
||||
coverage/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
storage/
|
||||
apps/platform/storage/
|
||||
bootstrap/cache/
|
||||
apps/platform/bootstrap/cache/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,236 +0,0 @@
|
||||
# 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,45 +48,8 @@ ## 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)
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Decision-first operating model (DECIDE-001): each changed
|
||||
operator-facing surface is classified as Primary Decision,
|
||||
Secondary Context, or Tertiary Evidence / Diagnostics; primary
|
||||
surfaces justify the human-in-the-loop moment, default-visible info
|
||||
is limited to first-decision needs, deep proof is progressive
|
||||
disclosed, one governance case stays decidable in one context where
|
||||
practical, navigation follows workflows not storage structures, and
|
||||
automation / alerts reduce attention load instead of adding noise
|
||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||
- UI/UX 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
|
||||
- Action-surface discipline (ACTSURF-001 / HDR-001): every changed
|
||||
surface declares one broad action-surface class; the spec names the
|
||||
one likely next operator action; navigation is separated from
|
||||
mutation; record/detail/edit pages keep at most one visible primary
|
||||
header action; monitoring/workbench surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions; risky or rare
|
||||
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||
type or workflow-hub exception is explicit and justified
|
||||
- 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
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
@ -150,20 +113,9 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects 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,24 +5,6 @@ # Feature Specification: [FEATURE NAME]
|
||||
**Status**: Draft
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
<!-- This section MUST be completed before the spec progresses beyond Draft.
|
||||
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
|
||||
|
||||
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
|
||||
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
|
||||
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
|
||||
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
|
||||
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
|
||||
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
|
||||
- **Why now**: [Warum jetzt wichtiger als später?]
|
||||
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
|
||||
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
|
||||
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
|
||||
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
|
||||
- **Decision**: [approve / shrink / merge / defer / reject]
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: [workspace | tenant | canonical-view]
|
||||
@ -35,59 +17,6 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing surface,
|
||||
fill out one row per affected surface. This role is orthogonal to the
|
||||
Action Surface Class / Surface Type below.
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
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. Declare the broad Action Surface
|
||||
Class first, then the detailed Surface Type. Keep this table in sync
|
||||
with the Decision-First Surface Role section above.
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | List / Table / Bulk | CRUD / List-first Resource | Open policy for review | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds a new operator-facing page or materially refactors
|
||||
one, fill out one row per affected page/surface. The contract MUST show
|
||||
how one governance case or operator task becomes decidable without
|
||||
unnecessary cross-page reconstruction.
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | Decide whether policy state needs follow-up | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
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)*
|
||||
|
||||
<!--
|
||||
@ -165,16 +94,6 @@ ## Requirements *(mandatory)*
|
||||
(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.
|
||||
|
||||
**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:
|
||||
- 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`),
|
||||
@ -200,86 +119,9 @@ ## Requirements *(mandatory)*
|
||||
**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.
|
||||
|
||||
**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 (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||
- whether each affected surface is a Primary Decision Surface,
|
||||
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||
Surface, and why,
|
||||
- which human-in-the-loop moment each primary surface supports,
|
||||
- what MUST be visible immediately for the first decision,
|
||||
- what is preserved but only revealed on demand,
|
||||
- why any new primary surface cannot live inside an existing decision
|
||||
context,
|
||||
- how navigation follows operator workflows rather than storage
|
||||
structures,
|
||||
- how one governance case remains decidable in one focused context,
|
||||
- how any new automation, notifications, or autonomous governance logic
|
||||
reduce search/review/click load,
|
||||
- and how the resulting default experience is calmer and clearer rather
|
||||
than merely larger.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen broad action-surface class and why it is the correct classification,
|
||||
- the chosen detailed surface type and why it is the correct refinement,
|
||||
- the one most likely next operator action,
|
||||
- the one and only primary inspect/open model,
|
||||
- whether row click is required, allowed, or forbidden,
|
||||
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||
- where pure navigation lives and why it is not competing with mutation,
|
||||
- where secondary actions live,
|
||||
- where destructive actions live,
|
||||
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||
- 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 (ACTSURF-001 - action hierarchy):** If this
|
||||
feature adds or materially changes header actions, row actions, bulk
|
||||
actions, or workbench controls, the spec MUST describe:
|
||||
- how navigation, mutation, context signals, selection actions, and
|
||||
dangerous actions are separated,
|
||||
- why any visible secondary action deserves primary-plane placement,
|
||||
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||
- and why any workflow-hub, wizard, system, or other special-type
|
||||
exception is genuine rather than a convenience shortcut.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- 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,
|
||||
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.
|
||||
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,
|
||||
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
|
||||
@ -308,7 +150,7 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
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?),
|
||||
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| 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,95 +32,25 @@ # Tasks: [FEATURE NAME]
|
||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||
- 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:
|
||||
- classifying each affected surface as Primary Decision, Secondary
|
||||
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||
sync with the governing spec,
|
||||
- defining the human-in-the-loop moment and justifying any new Primary
|
||||
Decision Surface against existing decision contexts,
|
||||
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- keeping default-visible content limited to first-decision needs and
|
||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- keeping each governance case decidable in one focused context where
|
||||
practical instead of forcing cross-page reconstruction,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- 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 navigation aligned to operator workflows rather than storage
|
||||
structures,
|
||||
- ensuring new automation, alerts, or autonomous flows reduce
|
||||
search/review/click load instead of adding noise, extra lists, or
|
||||
extra detail work,
|
||||
- preserving a calm, prioritized default state that distinguishes
|
||||
actionable work from worth-watching context and reference-only
|
||||
information,
|
||||
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||
- keeping 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:
|
||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||
- assigning exactly one broad action-surface class to every changed
|
||||
operator-facing surface and keeping the detailed surface type in sync
|
||||
with the spec,
|
||||
- identifying the one likely next operator action for each changed
|
||||
surface and shaping the visible hierarchy around it,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||
- separating navigation from mutation so pure context changes do not
|
||||
compete visually with state-changing actions,
|
||||
- moving additional secondary actions into More or the detail header,
|
||||
- ordering visible actions and grouped actions by meaning, frequency,
|
||||
and risk rather than append order,
|
||||
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||
- ensuring workbench and monitoring surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions instead of mixing
|
||||
them into one flat header zone,
|
||||
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
||||
- grouping bulk actions via BulkActionGroup,
|
||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- 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 workflow-hub, wizard, utility/system, or other
|
||||
special-type exception in the spec/PR and adding dedicated test
|
||||
coverage,
|
||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||
- documenting any 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.
|
||||
**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 all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- enforcing ACTSURF-001 / HDR-001 action discipline: record/detail/edit
|
||||
pages keep at most 1 visible primary header action; pure navigation
|
||||
moves to contextual placement; destructive or governance-changing
|
||||
actions are separated and require friction; monitoring/workbench
|
||||
surfaces use their own layered hierarchy; rare actions live in
|
||||
structured Action Groups; every affected surface passes the few-second
|
||||
scan rule,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
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.
|
||||
|
||||
@ -267,7 +197,6 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
81
Agents.md
81
Agents.md
@ -25,14 +25,12 @@ ## Scope Reference
|
||||
- Tenant-scoped RBAC and audit logs
|
||||
|
||||
## Workflow (Spec Kit)
|
||||
1. Read `.specify/memory/constitution.md`
|
||||
1. Read `.specify/constitution.md`
|
||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||
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.
|
||||
|
||||
## Workflow (SDD in diesem Repo)
|
||||
@ -318,13 +316,12 @@ ## Security
|
||||
## Commands
|
||||
|
||||
### Sail (preferred locally)
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail down`
|
||||
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||
- `./vendor/bin/sail up -d`
|
||||
- `./vendor/bin/sail down`
|
||||
- `./vendor/bin/sail composer install`
|
||||
- `./vendor/bin/sail artisan migrate`
|
||||
- `./vendor/bin/sail artisan test`
|
||||
- `./vendor/bin/sail artisan` (general)
|
||||
|
||||
### Drizzle (local DB tooling, if configured)
|
||||
- Use only for local/dev workflows.
|
||||
@ -336,10 +333,10 @@ ### Drizzle (local DB tooling, if configured)
|
||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||
|
||||
### Non-Docker fallback (only if needed)
|
||||
- `cd apps/platform && composer install`
|
||||
- `cd apps/platform && php artisan serve`
|
||||
- `cd apps/platform && php artisan migrate`
|
||||
- `cd apps/platform && php artisan test`
|
||||
- `composer install`
|
||||
- `php artisan serve`
|
||||
- `php artisan migrate`
|
||||
- `php artisan test`
|
||||
|
||||
### Frontend/assets/tooling (if present)
|
||||
- `pnpm install`
|
||||
@ -353,11 +350,11 @@ ## Where to look first
|
||||
- `.specify/`
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `apps/platform/app/`
|
||||
- `apps/platform/database/`
|
||||
- `apps/platform/routes/`
|
||||
- `apps/platform/resources/`
|
||||
- `apps/platform/config/`
|
||||
- `app/`
|
||||
- `database/`
|
||||
- `routes/`
|
||||
- `resources/`
|
||||
- `config/`
|
||||
|
||||
---
|
||||
|
||||
@ -434,7 +431,7 @@ ## 3) Panel setup defaults
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
@ -671,7 +668,7 @@ ## Testing
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `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”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -684,7 +681,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.
|
||||
|
||||
- php - 8.4.15
|
||||
- php - 8.4.1
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
@ -721,9 +718,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -815,28 +810,28 @@ ## PHPDoc Blocks
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- 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.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- 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.
|
||||
- If you're creating a generic PHP class, use `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.
|
||||
|
||||
## Database
|
||||
@ -849,7 +844,7 @@ ## Database
|
||||
|
||||
### 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 `cd apps/platform && ./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 `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
@ -880,11 +875,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.
|
||||
- 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 `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.
|
||||
- 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.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -915,15 +910,15 @@ ### Models
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- 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 `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.
|
||||
- You must run `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.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- This project uses Pest for testing. Create tests: `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`.
|
||||
- Do NOT delete tests without approval.
|
||||
- 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.
|
||||
|
||||
77
GEMINI.md
77
GEMINI.md
@ -156,13 +156,12 @@ ## Security
|
||||
## Commands
|
||||
|
||||
### Sail (preferred locally)
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail down`
|
||||
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||
- `./vendor/bin/sail up -d`
|
||||
- `./vendor/bin/sail down`
|
||||
- `./vendor/bin/sail composer install`
|
||||
- `./vendor/bin/sail artisan migrate`
|
||||
- `./vendor/bin/sail artisan test`
|
||||
- `./vendor/bin/sail artisan` (general)
|
||||
|
||||
### Drizzle (local DB tooling, if configured)
|
||||
- Use only for local/dev workflows.
|
||||
@ -174,10 +173,10 @@ ### Drizzle (local DB tooling, if configured)
|
||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||
|
||||
### Non-Docker fallback (only if needed)
|
||||
- `cd apps/platform && composer install`
|
||||
- `cd apps/platform && php artisan serve`
|
||||
- `cd apps/platform && php artisan migrate`
|
||||
- `cd apps/platform && php artisan test`
|
||||
- `composer install`
|
||||
- `php artisan serve`
|
||||
- `php artisan migrate`
|
||||
- `php artisan test`
|
||||
|
||||
### Frontend/assets/tooling (if present)
|
||||
- `pnpm install`
|
||||
@ -191,11 +190,11 @@ ## Where to look first
|
||||
- `.specify/`
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `apps/platform/app/`
|
||||
- `apps/platform/database/`
|
||||
- `apps/platform/routes/`
|
||||
- `apps/platform/resources/`
|
||||
- `apps/platform/config/`
|
||||
- `app/`
|
||||
- `database/`
|
||||
- `routes/`
|
||||
- `resources/`
|
||||
- `config/`
|
||||
|
||||
---
|
||||
|
||||
@ -272,7 +271,7 @@ ## 3) Panel setup defaults
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
@ -509,7 +508,7 @@ ## Testing
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `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”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -522,7 +521,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.
|
||||
|
||||
- php - 8.4.15
|
||||
- php - 8.4.1
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
@ -559,9 +558,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -653,28 +650,28 @@ ## PHPDoc Blocks
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- 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.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- 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.
|
||||
- If you're creating a generic PHP class, use `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.
|
||||
|
||||
## Database
|
||||
@ -687,7 +684,7 @@ ## Database
|
||||
|
||||
### 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 `cd apps/platform && ./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 `vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
@ -718,11 +715,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.
|
||||
- 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 `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.
|
||||
- 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.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -753,15 +750,15 @@ ### Models
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- 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 `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.
|
||||
- You must run `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.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- This project uses Pest for testing. Create tests: `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`.
|
||||
- Do NOT delete tests without approval.
|
||||
- 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.
|
||||
|
||||
@ -5,27 +5,21 @@
|
||||
**Overview:**
|
||||
- **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament.
|
||||
- **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations.
|
||||
- **Workspace model:** repo-root pnpm workspace orchestration, Laravel platform in `apps/platform`, and standalone Astro website in `apps/website`.
|
||||
|
||||
**Tech Stack & Key Libraries:**
|
||||
- **Backend:** Laravel 12 (PHP 8.4)
|
||||
- **Admin UI:** Filament v5
|
||||
- **Realtime/UI:** Livewire v4
|
||||
- **Public website:** Astro v6
|
||||
- **Workspace tooling:** pnpm 10 workspaces
|
||||
- **Admin UI:** Filament v4
|
||||
- **Realtime/UI:** Livewire v3
|
||||
- **Testing:** Pest v4, PHPUnit
|
||||
- **Local dev:** Laravel Sail (Docker)
|
||||
- **Styling/tooling:** Tailwind v4, Vite
|
||||
|
||||
**Repository Layout (high level):**
|
||||
- `apps/platform/` — Laravel runtime, Filament panels, tests, Vite assets, and app-local PHP or Node manifests
|
||||
- `apps/website/` — Astro website source, public assets, and static build output
|
||||
- `package.json` + `pnpm-workspace.yaml` — official root workspace command model
|
||||
- `scripts/` — root compatibility helpers such as `platform-sail`
|
||||
- `app/` — application code (Services, Models, Filament resources, Livewire components)
|
||||
- `config/` — runtime configuration (important: `tenantpilot.php`, `graph_contracts.php`)
|
||||
- `specs/` — SpecKit feature specs (feature-by-feature directories, e.g. `011-restore-run-wizard`)
|
||||
- `docs/` — architecture, rollout, and handover notes
|
||||
- `tests/` — Pest tests (Feature / Unit)
|
||||
- `apps/platform/resources/`, `apps/platform/routes/`, `apps/platform/database/` — Laravel application structure
|
||||
- `resources/`, `routes/`, `database/` — standard Laravel layout
|
||||
|
||||
**Core Features (implemented / status):**
|
||||
- **Policy Backup & Versioning:** implemented — captures immutable snapshots (JSONB), tracks metadata (tenant, type, created_by, timestamps). (See `app/Services/Intune/*`, `database/migrations`.)
|
||||
@ -60,11 +54,10 @@
|
||||
- Added `Agents.md` section for a “Solo + Copilot Workflow” and created a small `chore/solo-copilot-workflow` branch/PR for that documentation change.
|
||||
|
||||
**Where to look first (entry points):**
|
||||
- Root workspace entry: `package.json`, `pnpm-workspace.yaml`, and `README.md`
|
||||
- Restore flows: `apps/platform/app/Services/Intune/RestoreService.php`, `apps/platform/app/Services/Intune/RestoreRiskChecker.php`, `apps/platform/app/Services/Intune/RestoreDiffGenerator.php`
|
||||
- Graph contracts: `apps/platform/config/graph_contracts.php` and `apps/platform/app/Services/Graph/GraphContractRegistry.php`
|
||||
- Policy type catalog and UX metadata: `apps/platform/config/tenantpilot.php` and `specs/*` for feature intentions
|
||||
- Filament UI: `apps/platform/app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages)
|
||||
- Restore flows: `app/Services/Intune/RestoreService.php`, `app/Services/Intune/RestoreRiskChecker.php`, `app/Services/Intune/RestoreDiffGenerator.php`.
|
||||
- Graph contracts: `config/graph_contracts.php` and `app/Services/Graph/GraphContractRegistry.php`.
|
||||
- Policy type catalog and UX metadata: `config/tenantpilot.php` and `specs/*` for feature intentions.
|
||||
- Filament UI: `app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages).
|
||||
|
||||
**Short list of known limitations / next work items:**
|
||||
- Convert more `preview-only` types to `enabled` where safe (requires implementation of restore flows and risk mitigation, e.g., Conditional Access, Enrollment subtypes, Security Baselines).
|
||||
134
README.md
134
README.md
@ -1,50 +1,19 @@
|
||||
# TenantPilot Workspace
|
||||
<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 is an Intune management platform built around a stable Laravel application in
|
||||
`apps/platform` and, starting with Spec 183, a standalone public Astro website in
|
||||
`apps/website`. The repository root is now the official JavaScript workspace entry point and
|
||||
orchestrates app-local commands without becoming a runtime itself.
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## 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
|
||||
## TenantPilot setup
|
||||
|
||||
- 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`).
|
||||
- Microsoft Graph (Intune) env vars:
|
||||
- `GRAPH_TENANT_ID`
|
||||
@ -56,17 +25,10 @@ ## Platform Setup Notes
|
||||
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||
- Deployment (Dokploy, staging → production):
|
||||
- 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.
|
||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||
- 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 actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||
@ -77,23 +39,8 @@ ### Troubleshooting
|
||||
|
||||
- **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`.
|
||||
- Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
|
||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./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.
|
||||
- **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
|
||||
|
||||
@ -117,7 +64,7 @@ ## Graph Contract Registry & Drift Guard
|
||||
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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).
|
||||
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
||||
|
||||
## Policy Settings Display
|
||||
@ -142,3 +89,54 @@ ## Policy JSON Viewer (Feature 002)
|
||||
- Scrollable container with max height to prevent page overflow
|
||||
- **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
|
||||
|
||||
## 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).
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
@ -34,7 +33,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
|
||||
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
@ -89,6 +88,10 @@ public function handle(): int
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
@ -147,7 +150,7 @@ private function countsForTenant(Tenant $tenant): array
|
||||
return [
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
@ -161,8 +164,6 @@ private function countsForTenant(Tenant $tenant): array
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
{
|
||||
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
@ -178,16 +179,15 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
'summary_counts' => [
|
||||
'total' => array_sum($deletedRows),
|
||||
'processed' => array_sum($deletedRows),
|
||||
'succeeded' => array_sum($deletedRows),
|
||||
'total' => array_sum($counts),
|
||||
'processed' => array_sum($counts),
|
||||
'succeeded' => array_sum($counts),
|
||||
'failed' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'source' => 'tenantpilot:purge-nonpersistent',
|
||||
'deleted_rows' => $deletedRows,
|
||||
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
|
||||
'deleted_rows' => $counts,
|
||||
],
|
||||
'started_at' => now(),
|
||||
'completed_at' => now(),
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||
): int {
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@ -99,9 +96,31 @@ public function handle(
|
||||
continue;
|
||||
}
|
||||
|
||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'running') {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ($change !== null) {
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
@ -19,13 +18,4 @@ class InventoryCluster extends Cluster
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Items';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,6 @@
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -22,10 +19,6 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
$query = static::getModel()::query();
|
||||
|
||||
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if (! static::isScopedToTenant()) {
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
@ -34,7 +27,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = static::resolveGlobalSearchTenant();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Model) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
@ -48,17 +41,4 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
||||
|
||||
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
||||
}
|
||||
|
||||
protected static function resolveGlobalSearchTenant(): ?Model
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant instanceof Model ? $tenant : null;
|
||||
}
|
||||
}
|
||||
230
app/Filament/Pages/BaselineCompareLanding.php
Normal file
230
app/Filament/Pages/BaselineCompareLanding.php
Normal file
@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineCompareLanding extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baseline Compare';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $title = 'Baseline Compare';
|
||||
|
||||
protected string $view = 'filament.pages.baseline-compare-landing';
|
||||
|
||||
public ?string $state = null;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $profileName = null;
|
||||
|
||||
public ?int $profileId = null;
|
||||
|
||||
public ?int $snapshotId = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $severityCounts = null;
|
||||
|
||||
public ?string $lastComparedAt = null;
|
||||
|
||||
public ?string $lastComparedIso = null;
|
||||
|
||||
public ?string $failureReason = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshStats();
|
||||
}
|
||||
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
||||
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
$this->profileName = $stats->profileName;
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$this->failureReason = $stats->failureReason;
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->compareNowAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function compareNowAction(): Action
|
||||
{
|
||||
return Action::make('compareNow')
|
||||
->label('Compare Now')
|
||||
->icon('heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Start baseline comparison')
|
||||
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
||||
->visible(fn (): bool => $this->canCompare())
|
||||
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
Notification::make()->title('Not authenticated')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()->title('No tenant context')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if ($run instanceof OperationRun) {
|
||||
$this->operationRunId = (int) $run->getKey();
|
||||
}
|
||||
|
||||
$this->state = 'comparing';
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $tenant)),
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function canCompare(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): ?string
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
public function getRunUrl(): ?string
|
||||
{
|
||||
if ($this->operationRunId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
@ -56,10 +52,10 @@ public function getTenants(): Collection
|
||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||
|
||||
if ($tenants instanceof Collection) {
|
||||
return app(TenantOperabilityService::class)->filterSelectable($tenants);
|
||||
return $tenants;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||
return collect($tenants);
|
||||
}
|
||||
|
||||
public function selectTenant(int $tenantId): void
|
||||
@ -70,35 +66,10 @@ public function selectTenant(int $tenantId): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId(request());
|
||||
$tenant = null;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
}
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -108,32 +79,13 @@ public function selectTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
);
|
||||
|
||||
if (! $outcome->allowed) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
|
||||
abort(404);
|
||||
}
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||
{
|
||||
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||
}
|
||||
|
||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||
{
|
||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||
@ -132,9 +132,7 @@ public function selectWorkspace(int $workspaceId): void
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,8 +173,6 @@ public function createWorkspace(array $data): void
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
||||
}
|
||||
}
|
||||
295
app/Filament/Pages/DriftLanding.php
Normal file
295
app/Filament/Pages/DriftLanding.php
Normal file
@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class DriftLanding extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Drift';
|
||||
|
||||
protected string $view = 'filament.pages.drift-landing';
|
||||
|
||||
public ?string $state = null;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $scopeKey = null;
|
||||
|
||||
public ?int $baselineRunId = null;
|
||||
|
||||
public ?int $currentRunId = null;
|
||||
|
||||
public ?string $baselineFinishedAt = null;
|
||||
|
||||
public ?string $currentFinishedAt = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $statusCounts = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return FindingResource::canAccess();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$latestSuccessful = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof OperationRun) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No successful inventory runs found yet.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
|
||||
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
|
||||
|
||||
if ($scopeKey === '') {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->scopeKey = $scopeKey;
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
if ($comparison === null) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$baseline = $comparison['baseline'];
|
||||
$current = $comparison['current'];
|
||||
|
||||
$this->baselineRunId = (int) $baseline->getKey();
|
||||
$this->currentRunId = (int) $current->getKey();
|
||||
|
||||
$this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
|
||||
$this->currentFinishedAt = $current->completed_at?->toDateTimeString();
|
||||
|
||||
$existingOperationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('context->scope_key', $scopeKey)
|
||||
->where('context->baseline_operation_run_id', (int) $baseline->getKey())
|
||||
->where('context->current_operation_run_id', (int) $current->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun) {
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
}
|
||||
|
||||
$exists = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_run_id', $current->getKey())
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->state = 'ready';
|
||||
$newCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingOperationRun?->refresh();
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
|
||||
) {
|
||||
$this->state = 'generating';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& $existingOperationRun->status === 'completed'
|
||||
) {
|
||||
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
|
||||
$created = (int) ($counts['created'] ?? 0);
|
||||
|
||||
if ($existingOperationRun->outcome === 'failed') {
|
||||
$this->state = 'error';
|
||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($created === 0) {
|
||||
$this->state = 'ready';
|
||||
$this->statusCounts = [Finding::STATUS_NEW => 0];
|
||||
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromQuery([
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'drift_generate_findings',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
$this->operationRunId = (int) $opRun->getKey();
|
||||
$this->state = 'generating';
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): string
|
||||
{
|
||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getBaselineRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->baselineRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.operations.view', ['run' => $this->baselineRunId]);
|
||||
}
|
||||
|
||||
public function getCurrentRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->currentRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.operations.view', ['run' => $this->currentRunId]);
|
||||
}
|
||||
|
||||
public function getOperationRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->operationRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
||||
}
|
||||
}
|
||||
86
app/Filament/Pages/InventoryCoverage.php
Normal file
86
app/Filament/Pages/InventoryCoverage.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryCoverage extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Coverage';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
public array $supportedPolicyTypes = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
public array $foundationTypes = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
||||
->map(function (array $row) use ($resolver): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
|
||||
return array_merge($row, [
|
||||
'dependencies' => $type !== '' && $resolver->supportsDependencies($type),
|
||||
]);
|
||||
})
|
||||
->all();
|
||||
|
||||
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
||||
->map(function (array $row): array {
|
||||
return array_merge($row, [
|
||||
'dependencies' => false,
|
||||
]);
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@ -10,12 +10,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -41,16 +36,6 @@ class Alerts extends Page
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header keeps alerts scope and origin navigation quiet on the page-level overview.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The alerts overview is a page-level monitoring summary and does not inspect records inline.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The alerts overview does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The alerts overview does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The overview always renders KPI widgets and downstream drilldown navigation instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
@ -94,23 +79,9 @@ protected function getHeaderWidgets(): array
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
|
||||
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
array_splice($actions, 1, 0, [
|
||||
Action::make('operate_hub_back_to_origin_alerts')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
41
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
41
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?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',
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/Filament/Pages/Monitoring/Operations.php
Normal file
149
app/Filament/Pages/Monitoring/Operations.php
Normal file
@ -0,0 +1,149 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,9 +7,6 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -30,16 +27,6 @@ class NoAccess extends Page
|
||||
|
||||
protected string $view = 'filament.pages.no-access';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
142
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
142
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?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\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
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');
|
||||
}
|
||||
|
||||
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 content(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
EmbeddedSchema::make('infolist'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,11 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
@ -32,8 +30,6 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantTriageArrivalContinuity::class,
|
||||
RecoveryReadiness::class,
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
BaselineCompareNow::class,
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
@ -12,39 +12,24 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public bool $missingOwner = false;
|
||||
|
||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenant = Tenant::current();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
@ -95,7 +80,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
@ -109,7 +94,7 @@ public function bootstrapOwner(): void
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
235
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
235
app/Filament/Pages/TenantRequiredPermissions.php
Normal file
@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
||||
|
||||
protected static ?string $title = 'Required permissions';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||
|
||||
public string $status = 'missing';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $features = [];
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
public ?Tenant $scopedTenant = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||
}
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
{
|
||||
return $this->scopedTenant;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->scopedTenant = $tenant;
|
||||
|
||||
$queryFeatures = request()->query('features', $this->features);
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => request()->query('status', $this->status),
|
||||
'type' => request()->query('type', $this->type),
|
||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||
'search' => request()->query('search', $this->search),
|
||||
]);
|
||||
|
||||
$this->status = $state['status'];
|
||||
$this->type = $state['type'];
|
||||
$this->features = $state['features'];
|
||||
$this->search = $state['search'];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedStatus(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedType(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedFeatures(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function applyFeatureFilter(string $feature): void
|
||||
{
|
||||
$feature = trim($feature);
|
||||
|
||||
if ($feature === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($feature, $this->features, true)) {
|
||||
$this->features = array_values(array_filter(
|
||||
$this->features,
|
||||
static fn (string $value): bool => $value !== $feature,
|
||||
));
|
||||
} else {
|
||||
$this->features[] = $feature;
|
||||
}
|
||||
|
||||
$this->features = array_values(array_unique($this->features));
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function clearFeatureFilter(): void
|
||||
{
|
||||
$this->features = [];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->status = 'missing';
|
||||
$this->type = 'all';
|
||||
$this->features = [];
|
||||
$this->search = '';
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||
|
||||
$this->viewModel = $builder->build($tenant, [
|
||||
'status' => $this->status,
|
||||
'type' => $this->type,
|
||||
'features' => $this->features,
|
||||
'search' => $this->search,
|
||||
]);
|
||||
|
||||
$filters = $this->viewModel['filters'] ?? null;
|
||||
|
||||
if (is_array($filters)) {
|
||||
$this->status = (string) ($filters['status'] ?? $this->status);
|
||||
$this->type = (string) ($filters['type'] ?? $this->type);
|
||||
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
||||
$this->search = (string) ($filters['search'] ?? $this->search);
|
||||
}
|
||||
}
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
}
|
||||
|
||||
return route('admin.onboarding');
|
||||
}
|
||||
|
||||
public function manageProviderConnectionUrl(): ?string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
{
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if (is_string($routeTenant) && $routeTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $routeTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isWorkspaceMember = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
|
||||
if (! $isWorkspaceMember) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
}
|
||||
2266
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
2266
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,14 +5,10 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@ -58,25 +54,11 @@ public function getTenants(): Collection
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('tenant_id');
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
return $user->tenants()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function (Tenant $tenant) use ($user): bool {
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
actor: $user,
|
||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
)->allowed;
|
||||
})
|
||||
->values();
|
||||
->get();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
@ -93,7 +75,7 @@ public function openTenant(int $tenantId): void
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->withTrashed()
|
||||
->where('status', 'active')
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
@ -106,6 +88,6 @@ public function openTenant(int $tenantId): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
}
|
||||
@ -12,17 +12,13 @@
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -91,30 +87,19 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Guided empty state links to View alert rules.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
|
||||
}
|
||||
|
||||
public static function makeViewAlertRulesAction(): Action
|
||||
{
|
||||
return Action::make('view_alert_rules')
|
||||
->label('View alert rules')
|
||||
->icon('heroicon-o-funnel')
|
||||
->color('primary')
|
||||
->button()
|
||||
->url(AlertRuleResource::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'rule', 'destination'])
|
||||
@ -138,8 +123,8 @@ public static function getEloquentQuery(): Builder
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
Filament::getTenant() instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
@ -217,18 +202,13 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
->sortable(),
|
||||
->since(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
@ -243,8 +223,7 @@ public static function table(Table $table): Table
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus))
|
||||
->sortable(),
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||
TextColumn::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
@ -252,51 +231,18 @@ public static function table(Table $table): Table
|
||||
->label('Destination')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('attempt_count')
|
||||
->label('Attempts')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
->label('Attempts'),
|
||||
])
|
||||
->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(request());
|
||||
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||
->options([
|
||||
AlertDelivery::STATUS_QUEUED => 'Queued',
|
||||
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
||||
AlertDelivery::STATUS_SENT => 'Sent',
|
||||
AlertDelivery::STATUS_FAILED => 'Failed',
|
||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
||||
]),
|
||||
SelectFilter::make('event_type')
|
||||
->label('Event type')
|
||||
->options(function (): array {
|
||||
@ -320,16 +266,11 @@ public static function table(Table $table): Table
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}),
|
||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No alert deliveries')
|
||||
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
||||
->emptyStateIcon('heroicon-o-bell-alert')
|
||||
->emptyStateActions([
|
||||
static::makeViewAlertRulesAction(),
|
||||
]);
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDeliveries extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,8 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
@ -169,14 +171,12 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->searchable(),
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
||||
@ -189,6 +189,9 @@ public static function table(Table $table): Table
|
||||
->since(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
@ -248,14 +251,14 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateActions([
|
||||
\Filament\Actions\CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! static::canCreate()),
|
||||
])
|
||||
->emptyStateHeading('No alert destinations')
|
||||
->emptyStateDescription('Create a destination so alert rules have somewhere to deliver notifications.')
|
||||
->emptyStateIcon('heroicon-o-paper-airplane');
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -20,6 +20,8 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -220,14 +222,12 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
@ -246,6 +246,9 @@ public static function table(Table $table): Table
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
@ -306,9 +309,9 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No alert rules')
|
||||
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||
->emptyStateIcon('heroicon-o-bell');
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -3,8 +3,6 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
@ -22,7 +20,6 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -32,15 +29,13 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use BackedEnum;
|
||||
use DateTimeZone;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -65,9 +60,6 @@
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -76,18 +68,9 @@ class BackupScheduleResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -103,7 +86,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -127,7 +110,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -143,7 +126,7 @@ public static function canCreate(): bool
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -159,7 +142,7 @@ public static function canEdit(Model $record): bool
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -175,7 +158,7 @@ public static function canDelete(Model $record): bool
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -191,11 +174,11 @@ public static function canDeleteAny(): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit, ActionSurfaceType::CrudListFirstResource)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
|
||||
}
|
||||
@ -270,24 +253,10 @@ public static function form(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
public static function makeCreateAction(): CreateAction
|
||||
{
|
||||
return CreateAction::make()
|
||||
->label('New backup schedule')
|
||||
->disabled(fn (): bool => ! static::canCreate())
|
||||
->tooltip(fn (): ?string => static::canCreate()
|
||||
? null
|
||||
: 'You do not have permission to create backup schedules.');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('next_run_at', 'asc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
@ -303,7 +272,6 @@ public static function table(Table $table): Table
|
||||
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->label('Schedule'),
|
||||
|
||||
TextColumn::make('frequency')
|
||||
@ -317,8 +285,7 @@ public static function table(Table $table): Table
|
||||
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
|
||||
|
||||
TextColumn::make('timezone')
|
||||
->label('Timezone')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
->label('Timezone'),
|
||||
|
||||
TextColumn::make('policy_types')
|
||||
->label('Policy types')
|
||||
@ -327,8 +294,7 @@ public static function table(Table $table): Table
|
||||
|
||||
TextColumn::make('retention_keep_last')
|
||||
->label('Retention')
|
||||
->suffix(' sets')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
->suffix(' sets'),
|
||||
|
||||
TextColumn::make('last_run_status')
|
||||
->label('Last run status')
|
||||
@ -370,8 +336,7 @@ public static function table(Table $table): Table
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
}),
|
||||
|
||||
TextColumn::make('last_run_at')
|
||||
->label('Last run')
|
||||
@ -395,7 +360,6 @@ public static function table(Table $table): Table
|
||||
return $nextRun->format('M j, Y H:i:s');
|
||||
}
|
||||
})
|
||||
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
@ -437,7 +401,7 @@ public static function table(Table $table): Table
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -492,7 +456,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $operationRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -508,7 +472,7 @@ public static function table(Table $table): Table
|
||||
->color('warning')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -563,7 +527,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $operationRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -573,45 +537,8 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('restore', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.restored',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule restored')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
EditAction::make()
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
@ -619,11 +546,8 @@ public static function table(Table $table): Table
|
||||
->label('Archive')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('delete', $record);
|
||||
|
||||
if ($record->trashed()) {
|
||||
@ -658,16 +582,53 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
Gate::authorize('restore', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.restored',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule restored')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('forceDelete', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
@ -725,7 +686,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -740,7 +701,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -822,7 +783,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -837,7 +798,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -914,43 +875,22 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No schedules configured')
|
||||
->emptyStateDescription('Set up automated backups.')
|
||||
->emptyStateIcon('heroicon-o-clock')
|
||||
->emptyStateActions([
|
||||
static::makeCreateAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
}
|
||||
|
||||
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
|
||||
{
|
||||
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
|
||||
if (! $resolvedRecord instanceof BackupSchedule) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
return static::getEloquentQuery()->withTrashed();
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
@ -1061,7 +1001,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
@ -1150,31 +1090,4 @@ protected static function dayOfWeekOptions(): array
|
||||
7 => 'Sunday',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function scheduleFollowUpDescription(BackupSchedule $record): ?string
|
||||
{
|
||||
if (! $record->is_enabled || $record->trashed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graceCutoff = now('UTC')->subMinutes(max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)));
|
||||
$lastRunStatus = strtolower(trim((string) $record->last_run_status));
|
||||
$isOverdue = $record->next_run_at?->lessThan($graceCutoff) ?? false;
|
||||
$neverSuccessful = $record->last_run_at === null
|
||||
&& ($isOverdue || ($record->created_at?->lessThan($graceCutoff) ?? false));
|
||||
|
||||
if ($neverSuccessful) {
|
||||
return 'No successful run has been recorded yet.';
|
||||
}
|
||||
|
||||
if ($isOverdue) {
|
||||
return 'This schedule looks overdue.';
|
||||
}
|
||||
|
||||
if (in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true)) {
|
||||
return 'The last run needs follow-up.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
@ -12,7 +13,15 @@ class EditBackupSchedule extends EditRecord
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
||||
$record = BackupScheduleResource::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->find($key);
|
||||
|
||||
if ($record === null) {
|
||||
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSchedules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [$this->makeHeaderCreateAction()];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [$this->makeEmptyStateCreateAction()];
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
|
||||
private function makeHeaderCreateAction(): Actions\CreateAction
|
||||
{
|
||||
return $this->makeCreateAction()
|
||||
->visible(fn (): bool => $this->tableHasRecords());
|
||||
}
|
||||
|
||||
private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||
{
|
||||
return $this->makeCreateAction();
|
||||
}
|
||||
|
||||
private function makeCreateAction(): Actions\CreateAction
|
||||
{
|
||||
return Actions\CreateAction::make()
|
||||
->label('New backup schedule')
|
||||
->disabled(fn (): bool => ! BackupScheduleResource::canCreate())
|
||||
->tooltip(fn (): ?string => BackupScheduleResource::canCreate()
|
||||
? null
|
||||
: 'You do not have permission to create backup schedules.');
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Closure;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -28,8 +28,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||
}
|
||||
@ -39,22 +39,14 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->recordUrl(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Enqueued')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
->dateTime(),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
||||
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
@ -88,37 +80,17 @@ public function table(Table $table): Table
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No schedule runs yet')
|
||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||
}
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(function (OperationRun $record): string {
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
{
|
||||
$recordId = $record instanceof OperationRun
|
||||
? (int) $record->getKey()
|
||||
: (is_numeric($record) ? (int) $record : 0);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedRecord = $this->getOwnerRecord()
|
||||
->operationRuns()
|
||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof OperationRun) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
public static function formatOperationType(?string $state): string
|
||||
{
|
||||
return OperationCatalog::label($state);
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
@ -18,26 +16,11 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -55,14 +38,10 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -71,29 +50,9 @@ class BackupSetResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -109,7 +68,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -125,12 +84,13 @@ public static function canCreate(): bool
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery();
|
||||
}
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -144,54 +104,21 @@ public static function form(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
public static function makeCreateAction(): Actions\CreateAction
|
||||
{
|
||||
$action = Actions\CreateAction::make()
|
||||
->label('Create backup set');
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
|
||||
'items' => fn ($itemQuery) => $itemQuery->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')->searchable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->state(fn (BackupSet $record): string => static::backupQualitySummary($record)->compactSummary)
|
||||
->description(fn (BackupSet $record): string => static::backupQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('item_count')->label('Items'),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
|
||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
||||
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make()
|
||||
@ -200,9 +127,10 @@ public static function table(Table $table): Table
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->actions([
|
||||
static::primaryRelatedAction(),
|
||||
Actions\ViewAction::make()
|
||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
@ -212,7 +140,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
@ -245,7 +173,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$record->delete();
|
||||
|
||||
@ -277,7 +205,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
@ -347,7 +275,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -390,7 +318,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -417,7 +345,7 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -460,7 +388,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -502,7 +430,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -545,7 +473,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -555,12 +483,6 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No backup sets')
|
||||
->emptyStateDescription('Create a backup set to start protecting your configurations.')
|
||||
->emptyStateIcon('heroicon-o-archive-box')
|
||||
->emptyStateActions([
|
||||
static::makeCreateAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -568,11 +490,21 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('name'),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
Infolists\Components\TextEntry::make('item_count')->label('Items'),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('metadata')
|
||||
->label('Metadata')
|
||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
||||
->copyable()
|
||||
->copyMessage('Metadata copied'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -610,49 +542,13 @@ private static function typeMeta(?string $type): array
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public static function relatedContextEntries(BackupSet $record): array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup set via the domain service instead of direct model mass-assignment.
|
||||
*/
|
||||
public static function createBackupSet(array $data): BackupSet
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
/** @var BackupService $service */
|
||||
$service = app(BackupService::class);
|
||||
@ -667,204 +563,4 @@ public static function createBackupSet(array $data): BackupSet
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
|
||||
{
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
|
||||
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||
$metadataKeyCount = count($metadata);
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$isArchived = $record->trashed();
|
||||
$qualitySummary = static::backupQualitySummary($record);
|
||||
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
|
||||
$qualityBadge = match (true) {
|
||||
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
|
||||
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
|
||||
};
|
||||
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->statusBadge(
|
||||
static::backupHealthContinuityLabel($backupHealthAssessment),
|
||||
$backupHealthAssessment->tone(),
|
||||
'heroicon-m-exclamation-triangle',
|
||||
)
|
||||
: null;
|
||||
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
|
||||
: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.';
|
||||
|
||||
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
title: (string) $record->name,
|
||||
subtitle: 'Backup set #'.$record->getKey(),
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
...array_filter([$backupHealthBadge]),
|
||||
$qualityBadge,
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
...array_filter([
|
||||
$backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
|
||||
: null,
|
||||
]),
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: $descriptionHint,
|
||||
))
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
|
||||
: null,
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
|
||||
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
|
||||
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
|
||||
$qualitySummary->unknownQualityCount > 0
|
||||
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$qualitySummary->nextAction,
|
||||
'Backup quality',
|
||||
),
|
||||
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
|
||||
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
|
||||
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
|
||||
title: 'Backup quality',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'lifecycle_overview',
|
||||
kind: 'core_details',
|
||||
title: 'Lifecycle overview',
|
||||
items: [
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => $relatedContext],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Backup quality counts',
|
||||
items: [
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
|
||||
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Technical detail',
|
||||
entries: [
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
|
||||
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
|
||||
viewData: ['payload' => $metadata],
|
||||
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
|
||||
),
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof Carbon) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
if ($record->trashed()) {
|
||||
$record->setRelation('items', $record->items()->withTrashed()->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
])->get());
|
||||
} elseif (! $record->relationLoaded('items')) {
|
||||
$record->loadMissing([
|
||||
'items' => fn ($query) => $query->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
|
||||
}
|
||||
|
||||
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
|
||||
{
|
||||
$requestedReason = request()->string('backup_health_reason')->toString();
|
||||
|
||||
if (! in_array($requestedReason, [
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
$assessment = $resolver->assess((int) $record->tenant_id);
|
||||
|
||||
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($assessment->primaryReason !== $requestedReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $assessment;
|
||||
}
|
||||
|
||||
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
|
||||
{
|
||||
return match ($assessment->primaryReason) {
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
|
||||
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
|
||||
default => ucfirst($assessment->posture),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
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
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
$create = Actions\CreateAction::make();
|
||||
UiEnforcement::forAction($create)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
return [
|
||||
$create,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?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;
|
||||
}
|
||||
@ -3,34 +3,23 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -49,37 +38,6 @@ public function closeAddPoliciesModal(): void
|
||||
$this->unmountAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true) {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
|
||||
}
|
||||
|
||||
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
|
||||
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$refreshTable = Actions\Action::make('refreshTable')
|
||||
@ -114,7 +72,7 @@ public function table(Table $table): Table
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (mixed $record): void {
|
||||
->action(function (BackupItem $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
@ -131,7 +89,7 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
||||
$backupItemIds = [(int) $record->getKey()];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -151,7 +109,7 @@ public function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -172,7 +130,7 @@ public function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -210,7 +168,14 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
$backupItemIds = $records
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
@ -234,7 +199,7 @@ public function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -255,7 +220,7 @@ public function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -267,45 +232,18 @@ public function table(Table $table): Table
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy']))
|
||||
->defaultSort('policy.display_name')
|
||||
->paginated(TablePaginationProfiles::relationManager())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Item')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->badge()
|
||||
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('policyVersion.version_number')
|
||||
->label('Version')
|
||||
->badge()
|
||||
->default('—')
|
||||
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->compactSummary)
|
||||
->description(function (BackupItem $record): string {
|
||||
$summary = $this->backupItemQualitySummary($record);
|
||||
|
||||
if ($summary->assignmentCaptureReason === 'separate_role_assignments') {
|
||||
return 'Assignments are captured separately for this item type.';
|
||||
}
|
||||
|
||||
return $summary->nextAction;
|
||||
})
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
@ -329,8 +267,7 @@ public function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
Tables\Columns\TextColumn::make('policy_identifier')
|
||||
->label('Policy ID')
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
@ -372,30 +309,30 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
return '—';
|
||||
})
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
SelectFilter::make('restore_mode')
|
||||
->label('Restore')
|
||||
->options(static::restoreModeOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('platform')
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
$refreshTable,
|
||||
$addPolicies,
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\ViewAction::make()
|
||||
->label('View policy')
|
||||
->url(function (BackupItem $record): ?string {
|
||||
if (! $record->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
$removeItem,
|
||||
])
|
||||
->label('More')
|
||||
@ -406,11 +343,6 @@ public function table(Table $table): Table
|
||||
Actions\BulkActionGroup::make([
|
||||
$bulkRemove,
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No policies in this backup set')
|
||||
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
|
||||
->emptyStateActions([
|
||||
$addPolicies->name('addPoliciesEmpty'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -431,143 +363,4 @@ private static function typeMeta(?string $type): array
|
||||
return collect($types)
|
||||
->firstWhere('type', $type) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function restoreModeOptions(): array
|
||||
{
|
||||
return collect(InventoryPolicyTypeMeta::all())
|
||||
->pluck('restore')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(fn (string $value): array => [
|
||||
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$types = collect(InventoryPolicyTypeMeta::all())
|
||||
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
|
||||
->pluck('type')
|
||||
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||
->map(fn (string $type): string => trim($type))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($types === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn('policy_type', $types);
|
||||
}
|
||||
|
||||
private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
{
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
$resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record);
|
||||
|
||||
$resolvedRecord = $backupSet->items()
|
||||
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereKey($resolvedId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof BackupItem) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($resolvedRecord->policy_version_id) {
|
||||
return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
if (! $resolvedRecord->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
private function backupItemQualitySummary(BackupItem $record): \App\Support\BackupQuality\BackupQualitySummary
|
||||
{
|
||||
return app(BackupQualityResolver::class)->forBackupItem($record);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedId = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereKey($recordId)
|
||||
->value('id');
|
||||
|
||||
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return (int) $resolvedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array
|
||||
{
|
||||
$requestedIds = collect($recordKeys)
|
||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($requestedIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolvedIds = $backupSet->items()
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereIn('id', $requestedIds)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (count($resolvedIds) !== count($requestedIds)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $resolvedIds;
|
||||
}
|
||||
|
||||
private function normalizeBackupItemKey(mixed $record): int
|
||||
{
|
||||
if ($record instanceof BackupItem) {
|
||||
return (int) $record->getKey();
|
||||
}
|
||||
|
||||
return is_numeric($record) ? (int) $record : 0;
|
||||
}
|
||||
}
|
||||
412
app/Filament/Resources/BaselineProfileResource.php
Normal file
412
app/Filament/Resources/BaselineProfileResource.php
Normal file
@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineProfileResource extends Resource
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BaselineProfile::class;
|
||||
|
||||
protected static ?string $slug = 'baseline-profiles';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baselines';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return self::hasManageCapability();
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return self::hasManageCapability();
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return self::hasManageCapability();
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
return self::canViewAny();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['activeSnapshot', 'createdByUser'])
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Profile')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('A descriptive name for this baseline profile.'),
|
||||
Textarea::make('description')
|
||||
->rows(3)
|
||||
->maxLength(1000)
|
||||
->helperText('Explain the purpose and scope of this baseline.'),
|
||||
TextInput::make('version_label')
|
||||
->label('Version label')
|
||||
->maxLength(50)
|
||||
->placeholder('e.g. v2.1 — February rollout')
|
||||
->helperText('Optional label to identify this version.'),
|
||||
Select::make('status')
|
||||
->required()
|
||||
->options([
|
||||
BaselineProfile::STATUS_DRAFT => 'Draft',
|
||||
BaselineProfile::STATUS_ACTIVE => 'Active',
|
||||
BaselineProfile::STATUS_ARCHIVED => 'Archived',
|
||||
])
|
||||
->default(BaselineProfile::STATUS_DRAFT)
|
||||
->native(false)
|
||||
->helperText('Only active baselines are enforced during compliance checks.'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Scope')
|
||||
->schema([
|
||||
Select::make('scope_jsonb.policy_types')
|
||||
->label('Policy type scope')
|
||||
->multiple()
|
||||
->options(self::policyTypeOptions())
|
||||
->helperText('Leave empty to include all policy types.')
|
||||
->native(false),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Profile')
|
||||
->schema([
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
|
||||
TextEntry::make('version_label')
|
||||
->label('Version')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('description')
|
||||
->placeholder('No description')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Scope')
|
||||
->schema([
|
||||
TextEntry::make('scope_jsonb.policy_types')
|
||||
->label('Policy type scope')
|
||||
->badge()
|
||||
->formatStateUsing(function (string $state): string {
|
||||
$options = self::policyTypeOptions();
|
||||
|
||||
return $options[$state] ?? $state;
|
||||
})
|
||||
->placeholder('All policy types'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('createdByUser.name')
|
||||
->label('Created by')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot yet'),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
|
||||
->sortable(),
|
||||
TextColumn::make('version_label')
|
||||
->label('Version')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->label('View')
|
||||
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->icon('heroicon-o-eye'),
|
||||
ActionGroup::make([
|
||||
Action::make('edit')
|
||||
->label('Edit')
|
||||
->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->visible(fn (): bool => self::hasManageCapability()),
|
||||
self::archiveTableAction($workspace),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No baseline profiles')
|
||||
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
||||
->emptyStateActions([
|
||||
Action::make('create')
|
||||
->label('Create baseline profile')
|
||||
->url(fn (): string => static::getUrl('create'))
|
||||
->icon('heroicon-o-plus')
|
||||
->visible(fn (): bool => self::hasManageCapability()),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBaselineProfiles::route('/'),
|
||||
'create' => Pages\CreateBaselineProfile::route('/create'),
|
||||
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
||||
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function policyTypeOptions(): array
|
||||
{
|
||||
return collect(InventoryPolicyTypeMeta::all())
|
||||
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
|
||||
])
|
||||
->sort()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
|
||||
{
|
||||
$workspace = $record->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: $actionId->value,
|
||||
context: ['metadata' => $metadata],
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
resourceType: 'baseline_profile',
|
||||
resourceId: (string) $record->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
private static function hasManageCapability(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private static function archiveTableAction(?Workspace $workspace): Action
|
||||
{
|
||||
$action = Action::make('archive')
|
||||
->label('Archive')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Archive baseline profile')
|
||||
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
|
||||
->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability())
|
||||
->action(function (BaselineProfile $record): void {
|
||||
if (! self::hasManageCapability()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
|
||||
|
||||
self::audit($record, AuditActionId::BaselineProfileArchived, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Baseline profile archived')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
|
||||
if ($workspace instanceof Workspace) {
|
||||
$action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->destructive()
|
||||
->apply();
|
||||
}
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,9 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateBaselineProfile extends CreateRecord
|
||||
{
|
||||
@ -32,15 +28,8 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$user = auth()->user();
|
||||
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||
|
||||
if (isset($data['scope_jsonb'])) {
|
||||
try {
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope_jsonb.policy_types' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||
|
||||
return $data;
|
||||
}
|
||||
@ -56,9 +45,7 @@ protected function afterCreate(): void
|
||||
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'status' => $record->status instanceof BaselineProfileStatus
|
||||
? $record->status->value
|
||||
: (string) $record->status,
|
||||
'status' => (string) $record->status,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBaselineProfile extends EditRecord
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof BaselineProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'status' => (string) $record->status,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Baseline profile updated')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -21,13 +21,4 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create baseline profile')
|
||||
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBaselineProfile extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->captureAction(),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => $this->hasManageCapability()),
|
||||
];
|
||||
}
|
||||
|
||||
private function captureAction(): Action
|
||||
{
|
||||
return Action::make('capture')
|
||||
->label('Capture Snapshot')
|
||||
->icon('heroicon-o-camera')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->hasManageCapability())
|
||||
->disabled(fn (): bool => ! $this->hasManageCapability())
|
||||
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture Baseline Snapshot')
|
||||
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
|
||||
->form([
|
||||
Select::make('source_tenant_id')
|
||||
->label('Source Tenant')
|
||||
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||
Notification::make()
|
||||
->title('Permission denied')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
||||
|
||||
if (! $sourceTenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
->title('Source tenant not found')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineCaptureService::class);
|
||||
$result = $service->startCapture($profile, $sourceTenant, $user);
|
||||
|
||||
if (! $result['ok']) {
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof \App\Models\OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $sourceTenant));
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getWorkspaceTenantOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
private function hasManageCapability(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
}
|
||||
@ -29,11 +29,6 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager
|
||||
|
||||
protected static ?string $title = 'Tenant assignments';
|
||||
|
||||
/**
|
||||
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
|
||||
*/
|
||||
protected ?array $tenantAssignmentSummaries = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
@ -47,8 +42,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
@ -62,8 +55,7 @@ public function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->headerActions([
|
||||
$this->assignTenantAction()
|
||||
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
|
||||
$this->assignTenantAction(),
|
||||
])
|
||||
->actions([
|
||||
$this->removeAssignmentAction(),
|
||||
@ -71,7 +63,7 @@ public function table(Table $table): Table
|
||||
->emptyStateHeading('No tenants assigned')
|
||||
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
||||
->emptyStateActions([
|
||||
$this->assignTenantAction()->name('assignEmpty'),
|
||||
$this->assignTenantAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -84,8 +76,7 @@ private function assignTenantAction(): Action
|
||||
->form([
|
||||
Select::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->getTenantOptions())
|
||||
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
|
||||
->options(fn (): array => $this->getAvailableTenantOptions())
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
@ -111,13 +102,9 @@ private function assignTenantAction(): Action
|
||||
->first();
|
||||
|
||||
if ($existing instanceof BaselineTenantAssignment) {
|
||||
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant already assigned')
|
||||
->body($assignedBaselineName === null
|
||||
? 'This tenant already has a baseline assignment in this workspace.'
|
||||
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
|
||||
->body('This tenant already has a baseline assignment in this workspace.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -137,8 +124,6 @@ private function assignTenantAction(): Action
|
||||
->title('Tenant assigned')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->forgetTenantAssignmentSummaries();
|
||||
});
|
||||
}
|
||||
|
||||
@ -175,99 +160,31 @@ private function removeAssignmentAction(): Action
|
||||
->title('Assignment removed')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->forgetTenantAssignmentSummaries();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getTenantOptions(): array
|
||||
private function getAvailableTenantOptions(): array
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getOwnerRecord();
|
||||
|
||||
$assignmentSummaries = $this->getTenantAssignmentSummaries();
|
||||
|
||||
return Tenant::query()
|
||||
$assignedTenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
|
||||
|
||||
return [
|
||||
$tenantId => $this->formatTenantOptionLabel($tenant, $assignmentSummary),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{baseline_profile_id:int, baseline_profile_name:string}>
|
||||
*/
|
||||
private function getTenantAssignmentSummaries(): array
|
||||
{
|
||||
if (is_array($this->tenantAssignmentSummaries)) {
|
||||
return $this->tenantAssignmentSummaries;
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getOwnerRecord();
|
||||
|
||||
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->with('baselineProfile:id,name')
|
||||
->get(['tenant_id', 'baseline_profile_id'])
|
||||
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
|
||||
$baselineProfile = $assignment->baselineProfile;
|
||||
|
||||
return [
|
||||
(int) $assignment->tenant_id => [
|
||||
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
|
||||
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
|
||||
? (string) $baselineProfile->name
|
||||
: 'another baseline profile',
|
||||
],
|
||||
];
|
||||
})
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
return $this->tenantAssignmentSummaries;
|
||||
}
|
||||
$query = Tenant::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->orderBy('name');
|
||||
|
||||
/**
|
||||
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
|
||||
*/
|
||||
private function formatTenantOptionLabel(
|
||||
Tenant $tenant,
|
||||
?array $assignmentSummary,
|
||||
): string {
|
||||
$tenantName = (string) $tenant->name;
|
||||
|
||||
if ($assignmentSummary === null) {
|
||||
return $tenantName;
|
||||
if (! empty($assignedTenantIds)) {
|
||||
$query->whereNotIn('id', $assignedTenantIds);
|
||||
}
|
||||
|
||||
return "{$tenantName} (assigned to baseline: {$assignmentSummary['baseline_profile_name']})";
|
||||
}
|
||||
|
||||
private function getAssignedBaselineNameForTenant(int $tenantId): ?string
|
||||
{
|
||||
$assignmentSummary = $this->getTenantAssignmentSummaries()[$tenantId] ?? null;
|
||||
|
||||
if ($assignmentSummary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $assignmentSummary['baseline_profile_name'];
|
||||
}
|
||||
|
||||
private function forgetTenantAssignmentSummaries(): void
|
||||
{
|
||||
$this->tenantAssignmentSummaries = null;
|
||||
return $query->pluck('name', 'id')->all();
|
||||
}
|
||||
|
||||
private function auditAssignment(
|
||||
246
app/Filament/Resources/EntraGroupResource.php
Normal file
246
app/Filament/Resources/EntraGroupResource.php
Normal file
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use 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\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class EntraGroupResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = EntraGroup::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Groups';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Group')
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Name'),
|
||||
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
||||
TextEntry::make('security_enabled')
|
||||
->label('Security')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('mail_enabled')
|
||||
->label('Mail')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Raw groupTypes')
|
||||
->schema([
|
||||
ViewEntry::make('group_types')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EntraGroup $record) => $record->group_types ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('display_name')
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('entra_id')
|
||||
->label('Entra ID')
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
|
||||
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
|
||||
Tables\Columns\TextColumn::make('last_seen_at')
|
||||
->label('Last seen')
|
||||
->since(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('stale')
|
||||
->label('Stale')
|
||||
->options([
|
||||
'1' => 'Stale',
|
||||
'0' => 'Fresh',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
|
||||
if ((string) $value === '1') {
|
||||
return $query->where(function (Builder $q) use ($cutoff): void {
|
||||
$q->whereNull('last_seen_at')
|
||||
->orWhere('last_seen_at', '<', $cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where('last_seen_at', '>=', $cutoff);
|
||||
}),
|
||||
|
||||
SelectFilter::make('group_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'security' => 'Security',
|
||||
'microsoft365' => 'Microsoft 365',
|
||||
'mail' => 'Mail-enabled',
|
||||
'unknown' => 'Unknown',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = (string) ($data['value'] ?? '');
|
||||
|
||||
if ($value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
'microsoft365' => $query->whereJsonContains('group_types', 'Unified'),
|
||||
'security' => $query
|
||||
->where('security_enabled', true)
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
}),
|
||||
'mail' => $query
|
||||
->where('mail_enabled', true)
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
}),
|
||||
'unknown' => $query
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
})
|
||||
->where('security_enabled', false)
|
||||
->where('mail_enabled', false),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->latest('id');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEntraGroups::route('/'),
|
||||
'view' => Pages\ViewEntraGroup::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function groupType(EntraGroup $record): string
|
||||
{
|
||||
$groupTypes = $record->group_types;
|
||||
|
||||
if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) {
|
||||
return 'microsoft365';
|
||||
}
|
||||
|
||||
if ($record->security_enabled) {
|
||||
return 'security';
|
||||
}
|
||||
|
||||
if ($record->mail_enabled) {
|
||||
return 'mail';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private static function groupTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'Microsoft 365',
|
||||
'security' => 'Security',
|
||||
'mail' => 'Mail-enabled',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private static function groupTypeColor(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'info',
|
||||
'security' => 'success',
|
||||
'mail' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -9,47 +9,25 @@
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
{
|
||||
protected static string $resource = EntraGroupResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
|
||||
return [
|
||||
Action::make('view_operations')
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||
->visible(fn (): bool => $tenant instanceof Tenant),
|
||||
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
|
||||
->visible(fn (): bool => (bool) Tenant::current()),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
@ -57,7 +35,7 @@ protected function getHeaderActions(): array
|
||||
->color('primary')
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$tenant = EntraGroupResource::panelTenantContext();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -84,7 +62,7 @@ protected function getHeaderActions(): array
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -105,7 +83,7 @@ protected function getHeaderActions(): array
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -0,0 +1,11 @@
|
||||
<?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;
|
||||
}
|
||||
1111
app/Filament/Resources/FindingResource.php
Normal file
1111
app/Filament/Resources/FindingResource.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,10 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
@ -13,7 +10,6 @@
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -23,86 +19,14 @@
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
|
||||
try {
|
||||
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
$this->applyRequestedDashboardPrefilter();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
BaselineCompareCoverageBanner::class,
|
||||
FindingStatsOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Tab>
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
'all' => Tab::make('All')
|
||||
->icon('heroicon-m-list-bullet'),
|
||||
'needs_action' => Tab::make('Needs action')
|
||||
->icon('heroicon-m-exclamation-triangle')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery()))
|
||||
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||
->badgeColor('warning'),
|
||||
'overdue' => Tab::make('Overdue')
|
||||
->icon('heroicon-m-clock')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now()))
|
||||
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||
->badgeColor('danger'),
|
||||
'risk_accepted' => Tab::make('Risk accepted')
|
||||
->icon('heroicon-m-shield-check')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||
'resolved' => Tab::make('Resolved')
|
||||
->icon('heroicon-m-archive-box')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
@ -123,7 +47,7 @@ protected function getHeaderActions(): array
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -151,7 +75,7 @@ protected function getHeaderActions(): array
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -173,7 +97,7 @@ protected function getHeaderActions(): array
|
||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -229,7 +153,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
@ -298,7 +222,15 @@ protected function getHeaderActions(): array
|
||||
|
||||
protected function buildAllMatchingQuery(): Builder
|
||||
{
|
||||
$query = FindingResource::getEloquentQuery();
|
||||
$query = Finding::query();
|
||||
|
||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('tenant_id', (int) $tenantId);
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
@ -348,71 +280,6 @@ protected function buildAllMatchingQuery(): Builder
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function applyRequestedDashboardPrefilter(): void
|
||||
{
|
||||
$requestedTab = request()->query('tab');
|
||||
$requestedStatus = request()->query('status');
|
||||
$requestedFindingType = request()->query('finding_type');
|
||||
$requestedGovernanceValidity = request()->query('governance_validity');
|
||||
$requestedHighSeverity = request()->query('high_severity');
|
||||
|
||||
$hasDashboardPrefilter = $requestedTab !== null
|
||||
|| $requestedStatus !== null
|
||||
|| $requestedFindingType !== null
|
||||
|| $requestedGovernanceValidity !== null
|
||||
|| $requestedHighSeverity !== null;
|
||||
|
||||
if (! $hasDashboardPrefilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['status', 'finding_type', 'workflow_family', 'governance_validity'] as $filterName) {
|
||||
data_forget($this->tableFilters, $filterName);
|
||||
data_forget($this->tableDeferredFilters, $filterName);
|
||||
}
|
||||
|
||||
foreach (['high_severity', 'overdue', 'my_assigned'] as $filterName) {
|
||||
data_forget($this->tableFilters, "{$filterName}.isActive");
|
||||
data_forget($this->tableDeferredFilters, "{$filterName}.isActive");
|
||||
}
|
||||
|
||||
if (in_array($requestedTab, array_keys($this->getTabs()), true)) {
|
||||
$this->activeTab = (string) $requestedTab;
|
||||
}
|
||||
|
||||
if (is_string($requestedStatus) && $requestedStatus !== '') {
|
||||
$this->tableFilters['status']['value'] = $requestedStatus;
|
||||
$this->tableDeferredFilters['status']['value'] = $requestedStatus;
|
||||
}
|
||||
|
||||
if (is_string($requestedFindingType) && $requestedFindingType !== '') {
|
||||
$this->tableFilters['finding_type']['value'] = $requestedFindingType;
|
||||
$this->tableDeferredFilters['finding_type']['value'] = $requestedFindingType;
|
||||
}
|
||||
|
||||
if (is_string($requestedGovernanceValidity) && $requestedGovernanceValidity !== '') {
|
||||
$this->tableFilters['governance_validity']['value'] = $requestedGovernanceValidity;
|
||||
$this->tableDeferredFilters['governance_validity']['value'] = $requestedGovernanceValidity;
|
||||
}
|
||||
|
||||
$highSeverity = filter_var($requestedHighSeverity, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
if ($highSeverity === true) {
|
||||
$this->tableFilters['high_severity']['isActive'] = true;
|
||||
$this->tableDeferredFilters['high_severity']['isActive'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function filterIsActive(string $filterName): bool
|
||||
{
|
||||
$state = $this->getTableFilterState($filterName);
|
||||
22
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
22
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,26 +3,25 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
@ -36,9 +35,6 @@
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -51,15 +47,6 @@ class InventoryItemResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -72,7 +59,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -87,7 +74,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -176,6 +163,29 @@ public static function infolist(Schema $schema): Schema
|
||||
ViewEntry::make('dependencies')
|
||||
->label('')
|
||||
->view('filament.components.dependency-edges')
|
||||
->state(function (InventoryItem $record) {
|
||||
$direction = request()->query('direction', 'all');
|
||||
$relationshipType = request()->query('relationship_type', 'all');
|
||||
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
|
||||
|
||||
$relationshipType = $relationshipType === 'all'
|
||||
? null
|
||||
: RelationshipType::tryFrom($relationshipType)?->value;
|
||||
|
||||
$service = app(DependencyQueryService::class);
|
||||
$resolver = app(DependencyTargetResolver::class);
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
|
||||
}
|
||||
if ($direction === 'outbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
|
||||
}
|
||||
|
||||
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
@ -194,7 +204,10 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$typeOptions = FilterOptionCatalog::policyTypes();
|
||||
$typeOptions = collect(static::allTypeMeta())
|
||||
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
|
||||
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
|
||||
->all();
|
||||
|
||||
$categoryOptions = collect(static::allTypeMeta())
|
||||
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
||||
@ -203,15 +216,10 @@ public static function table(Table $table): Table
|
||||
|
||||
return $table
|
||||
->defaultSort('last_seen_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
@ -269,54 +277,21 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('category')
|
||||
->options($categoryOptions)
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('platform')
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('stale')
|
||||
->label('Freshness')
|
||||
->options([
|
||||
'0' => 'Fresh',
|
||||
'1' => 'Stale',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$cutoff = now()->subHours(max(1, (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24)));
|
||||
|
||||
if ((string) $value === '1') {
|
||||
return $query->where(function (Builder $staleQuery) use ($cutoff): void {
|
||||
$staleQuery
|
||||
->whereNull('last_seen_at')
|
||||
->orWhere('last_seen_at', '<', $cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where('last_seen_at', '>=', $cutoff);
|
||||
}),
|
||||
])
|
||||
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No inventory items')
|
||||
->emptyStateDescription('Run an inventory sync to capture policy state for this tenant.')
|
||||
->emptyStateIcon('heroicon-o-clipboard-document-list');
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with('lastSeenRun');
|
||||
}
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->with('lastSeenRun');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
@ -12,7 +11,6 @@
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -30,21 +28,8 @@
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
@ -105,7 +90,7 @@ protected function getHeaderActions(): array
|
||||
->columnSpanFull(),
|
||||
Toggle::make('include_foundations')
|
||||
->label('Include foundation types')
|
||||
->helperText('Include scope tags, assignment filters, notification templates, and Intune RBAC role definitions and assignments.')
|
||||
->helperText('Include scope tags, assignment filters, and notification templates.')
|
||||
->default(true)
|
||||
->dehydrated()
|
||||
->rules(['boolean'])
|
||||
@ -118,7 +103,7 @@ protected function getHeaderActions(): array
|
||||
->rules(['boolean'])
|
||||
->columnSpanFull(),
|
||||
Hidden::make('tenant_id')
|
||||
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||
->dehydrated(),
|
||||
])
|
||||
->visible(function (): bool {
|
||||
@ -127,7 +112,7 @@ protected function getHeaderActions(): array
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
@ -135,7 +120,7 @@ protected function getHeaderActions(): array
|
||||
return $user->canAccessTenant($tenant);
|
||||
})
|
||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -174,8 +159,6 @@ protected function getHeaderActions(): array
|
||||
],
|
||||
context: array_merge($computed['selection'], [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
'execution_authority_mode' => 'actor_bound',
|
||||
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
],
|
||||
@ -187,7 +170,7 @@ protected function getHeaderActions(): array
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -224,7 +207,7 @@ protected function getHeaderActions(): array
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -4,14 +4,8 @@
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewInventoryItem extends ViewRecord
|
||||
{
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user