Compare commits
125 Commits
068-worksp
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ce0615a9c1 | |||
| 6f8eb28ca2 | |||
| e840007127 | |||
| a107e7e41b | |||
| 1142d283eb | |||
| f52d52540c | |||
| dc46c4fa58 | |||
| 98be510362 | |||
| 44898a98ac | |||
| 3a2a06e8d7 | |||
| 671abbed53 | |||
| 1b88d28739 | |||
| fdd3a85b64 | |||
| 37c6d0622c | |||
| 807d574d31 | |||
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 | |||
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 | |||
| d4fb886de0 | |||
| 8ee1174c8d | |||
| b15d1950b4 | |||
| 4b3113498c | |||
| 3c445709af | |||
| 0c709df54e | |||
| ef41c9193a | |||
| c6e7591d19 | |||
| a490261eca | |||
| 02028be7e4 | |||
| a4f5c4f122 | |||
| 3971c315d8 | |||
| bfc483e9b8 | |||
| 4db73e872e | |||
| 73a3a62451 | |||
| 891f177311 | |||
| cd811cff4f | |||
| da1adbdeb5 | |||
| 92704a2f7e | |||
| f08924525d | |||
| 7620144ab6 | |||
| fdfb781144 | |||
| 0cf612826f | |||
| 200498fa8e | |||
| 32c3a64147 | |||
| 7ac53f4cc4 | |||
| f13a4ce409 | |||
| 9f5c99317b | |||
| 0dc79520a4 | |||
| e15eee8f26 | |||
| 8bee824966 | |||
| 33a2b1a242 | |||
| 6a15fe978a | |||
| ef380b67d1 | |||
| d32b2115a8 | |||
| 558b5d3807 | |||
| 8f8bc24d1d | |||
| a30be84084 | |||
| d49d33ac27 | |||
| 3ed275cef3 | |||
| c57f680f39 | |||
| e241e27853 | |||
| 521fb6baaf | |||
| ef5c223172 | |||
| 9d0c884251 | |||
| 03127a670b | |||
| eec93b510a | |||
| bda1d90fc4 | |||
| 92a36ab89e | |||
| 3ddf8c3fd6 | |||
| 5770c7b76b | |||
| 1c098441aa | |||
| 90bfe1516e | |||
| fb4de17c63 | |||
| d6e7de597a | |||
| 1acbf8cc54 | |||
| 57f3e3934c | |||
| 2bf5de4663 | |||
| 0e2adeab71 | |||
| 55166cf9b8 | |||
| a770b32e87 | |||
| 11c73abd1d | |||
| 4db8030f2a | |||
| 3f09fd50f6 | |||
| ff671d8d4a | |||
| d56ba85755 | |||
| fb1046c97a | |||
| 05a604cfb6 | |||
| 53dc89e6ef | |||
| 8e34b6084f | |||
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 |
167
.agents/skills/pest-testing/SKILL.md
Normal file
167
.agents/skills/pest-testing/SKILL.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pest Testing 4
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating new tests (unit, feature, or browser)
|
||||||
|
- Modifying existing tests
|
||||||
|
- Debugging test failures
|
||||||
|
- Working with browser testing or smoke testing
|
||||||
|
- Writing architecture tests or visual regression tests
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Browser tests: `tests/Browser/` directory.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
<!-- Basic Pest Test Example -->
|
||||||
|
```php
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||||
|
- Run all tests: `php artisan test --compact`.
|
||||||
|
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||||
|
|
||||||
|
<!-- Pest Response Assertion -->
|
||||||
|
```php
|
||||||
|
it('returns all', function () {
|
||||||
|
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Use | Instead of |
|
||||||
|
|-----|------------|
|
||||||
|
| `assertSuccessful()` | `assertStatus(200)` |
|
||||||
|
| `assertNotFound()` | `assertStatus(404)` |
|
||||||
|
| `assertForbidden()` | `assertStatus(403)` |
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||||
|
|
||||||
|
## Datasets
|
||||||
|
|
||||||
|
Use datasets for repetitive tests (validation rules, etc.):
|
||||||
|
|
||||||
|
<!-- Pest Dataset Example -->
|
||||||
|
```php
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pest 4 Features
|
||||||
|
|
||||||
|
| Feature | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Browser Testing | Full integration tests in real browsers |
|
||||||
|
| Smoke Testing | Validate multiple pages quickly |
|
||||||
|
| Visual Regression | Compare screenshots for visual changes |
|
||||||
|
| Test Sharding | Parallel CI runs |
|
||||||
|
| Architecture Testing | Enforce code conventions |
|
||||||
|
|
||||||
|
### Browser Test Example
|
||||||
|
|
||||||
|
Browser tests run in real browsers for full integration testing:
|
||||||
|
|
||||||
|
- Browser tests live in `tests/Browser/`.
|
||||||
|
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||||
|
- Use `RefreshDatabase` for clean state per test.
|
||||||
|
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||||
|
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||||
|
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging.
|
||||||
|
|
||||||
|
<!-- Pest Browser Test Example -->
|
||||||
|
```php
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in');
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!');
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smoke Testing
|
||||||
|
|
||||||
|
Quickly validate multiple pages have no JavaScript errors:
|
||||||
|
|
||||||
|
<!-- Pest Smoke Testing Example -->
|
||||||
|
```php
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Regression Testing
|
||||||
|
|
||||||
|
Capture and compare screenshots to detect visual changes.
|
||||||
|
|
||||||
|
### Test Sharding
|
||||||
|
|
||||||
|
Split tests across parallel processes for faster CI runs.
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
Pest 4 includes architecture testing (from Pest 3):
|
||||||
|
|
||||||
|
<!-- Architecture Test Example -->
|
||||||
|
```php
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||||
|
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||||
|
- Forgetting datasets for repetitive validation tests
|
||||||
|
- Deleting tests without approval
|
||||||
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
129
.agents/skills/tailwindcss-development/SKILL.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Adding styles to components or pages
|
||||||
|
- Working with responsive design
|
||||||
|
- Implementing dark mode
|
||||||
|
- Extracting repeated patterns into components
|
||||||
|
- Debugging spacing or layout issues
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
4
.codex/config.toml
Normal file
4
.codex/config.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[mcp_servers.laravel-boost]
|
||||||
|
command = "vendor/bin/sail"
|
||||||
|
args = ["artisan", "boost:mcp"]
|
||||||
|
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
|
||||||
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
@ -1,23 +1,38 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
|
*.log*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
storage/framework/
|
storage/framework/
|
||||||
|
apps/platform/storage/framework/
|
||||||
storage/logs/
|
storage/logs/
|
||||||
|
apps/platform/storage/logs/
|
||||||
storage/debugbar/
|
storage/debugbar/
|
||||||
|
apps/platform/storage/debugbar/
|
||||||
storage/*.key
|
storage/*.key
|
||||||
|
apps/platform/storage/*.key
|
||||||
/references/
|
/references/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
143
.github/agents/copilot-instructions.md
vendored
143
.github/agents/copilot-instructions.md
vendored
@ -2,6 +2,12 @@ # TenantAtlas Development Guidelines
|
|||||||
|
|
||||||
Auto-generated from all feature plans. Last updated: 2025-12-22
|
Auto-generated from all feature plans. Last updated: 2025-12-22
|
||||||
|
|
||||||
|
## Relocation override
|
||||||
|
- The authoritative Laravel application root is `apps/platform`.
|
||||||
|
- Human-facing commands should use `cd apps/platform && ...`.
|
||||||
|
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
|
||||||
|
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||||
@ -14,6 +20,135 @@ ## Active Technologies
|
|||||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||||
|
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||||
|
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
|
||||||
|
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
|
||||||
|
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
|
||||||
|
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
||||||
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
|
||||||
|
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
|
||||||
|
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
|
||||||
|
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||||
|
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
||||||
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
|
||||||
|
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
|
||||||
|
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
|
||||||
|
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
|
||||||
|
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
|
||||||
|
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
|
||||||
|
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -33,10 +168,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
- 182-platform-relocation: Added 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
|
||||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
- 180-tenant-backup-health: Added 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
|
||||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
- 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
49
.github/copilot-instructions.md
vendored
49
.github/copilot-instructions.md
vendored
@ -40,7 +40,7 @@ ## 3) Panel setup defaults
|
|||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
@ -254,7 +254,7 @@ ## Testing
|
|||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
@ -292,7 +292,7 @@ ## Application Structure & Architecture
|
|||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Replies
|
## Replies
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
@ -372,28 +372,29 @@ ## Enums
|
|||||||
## Laravel Sail
|
## Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- The canonical application working directory is `apps/platform`. Repo-root launchers such as MCP or VS Code tasks may use `./scripts/platform-sail`, but that helper is compatibility-only.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
@ -404,7 +405,7 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
@ -428,10 +429,10 @@ ### Configuration
|
|||||||
### Testing
|
### Testing
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
### Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
@ -460,7 +461,7 @@ ### Models
|
|||||||
## Livewire
|
## Livewire
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
- Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||||
- State should live on the server, with the UI reflecting it.
|
- State should live on the server, with the UI reflecting it.
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||||
|
|
||||||
@ -504,8 +505,8 @@ ## Testing Livewire
|
|||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
@ -514,7 +515,7 @@ ### Testing
|
|||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
### Pest Tests
|
### Pest Tests
|
||||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
- All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
@ -527,9 +528,9 @@ ### Pest Tests
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
- To run all tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`.
|
||||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||||
|
|
||||||
### Pest Assertions
|
### Pest Assertions
|
||||||
|
|||||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
agent: speckit.specify
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
167
.github/skills/pest-testing/SKILL.md
vendored
Normal file
167
.github/skills/pest-testing/SKILL.md
vendored
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pest Testing 4
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating new tests (unit, feature, or browser)
|
||||||
|
- Modifying existing tests
|
||||||
|
- Debugging test failures
|
||||||
|
- Working with browser testing or smoke testing
|
||||||
|
- Writing architecture tests or visual regression tests
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Browser tests: `tests/Browser/` directory.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
<!-- Basic Pest Test Example -->
|
||||||
|
```php
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||||
|
- Run all tests: `php artisan test --compact`.
|
||||||
|
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||||
|
|
||||||
|
<!-- Pest Response Assertion -->
|
||||||
|
```php
|
||||||
|
it('returns all', function () {
|
||||||
|
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Use | Instead of |
|
||||||
|
|-----|------------|
|
||||||
|
| `assertSuccessful()` | `assertStatus(200)` |
|
||||||
|
| `assertNotFound()` | `assertStatus(404)` |
|
||||||
|
| `assertForbidden()` | `assertStatus(403)` |
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||||
|
|
||||||
|
## Datasets
|
||||||
|
|
||||||
|
Use datasets for repetitive tests (validation rules, etc.):
|
||||||
|
|
||||||
|
<!-- Pest Dataset Example -->
|
||||||
|
```php
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pest 4 Features
|
||||||
|
|
||||||
|
| Feature | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Browser Testing | Full integration tests in real browsers |
|
||||||
|
| Smoke Testing | Validate multiple pages quickly |
|
||||||
|
| Visual Regression | Compare screenshots for visual changes |
|
||||||
|
| Test Sharding | Parallel CI runs |
|
||||||
|
| Architecture Testing | Enforce code conventions |
|
||||||
|
|
||||||
|
### Browser Test Example
|
||||||
|
|
||||||
|
Browser tests run in real browsers for full integration testing:
|
||||||
|
|
||||||
|
- Browser tests live in `tests/Browser/`.
|
||||||
|
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||||
|
- Use `RefreshDatabase` for clean state per test.
|
||||||
|
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||||
|
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||||
|
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging.
|
||||||
|
|
||||||
|
<!-- Pest Browser Test Example -->
|
||||||
|
```php
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in');
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!');
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smoke Testing
|
||||||
|
|
||||||
|
Quickly validate multiple pages have no JavaScript errors:
|
||||||
|
|
||||||
|
<!-- Pest Smoke Testing Example -->
|
||||||
|
```php
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Regression Testing
|
||||||
|
|
||||||
|
Capture and compare screenshots to detect visual changes.
|
||||||
|
|
||||||
|
### Test Sharding
|
||||||
|
|
||||||
|
Split tests across parallel processes for faster CI runs.
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
Pest 4 includes architecture testing (from Pest 3):
|
||||||
|
|
||||||
|
<!-- Architecture Test Example -->
|
||||||
|
```php
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||||
|
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||||
|
- Forgetting datasets for repetitive validation tests
|
||||||
|
- Deleting tests without approval
|
||||||
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
129
.github/skills/tailwindcss-development/SKILL.md
vendored
Normal file
129
.github/skills/tailwindcss-development/SKILL.md
vendored
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Adding styles to components or pages
|
||||||
|
- Working with responsive design
|
||||||
|
- Implementing dark mode
|
||||||
|
- Extracting repeated patterns into components
|
||||||
|
- Debugging spacing or layout issues
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
*.cache
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
@ -14,21 +15,36 @@
|
|||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/apps/platform/node_modules
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
/public/build
|
/public/build
|
||||||
|
/apps/platform/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
|
/apps/platform/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/apps/platform/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/apps/platform/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/apps/platform/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
|
/apps/platform/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/apps/platform/storage/logs
|
||||||
|
/storage/debugbar
|
||||||
|
/apps/platform/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
|
/apps/platform/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
|
/apps/platform/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/references
|
/references
|
||||||
|
/tests/Browser/Screenshots
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
/apps/platform/.env
|
||||||
|
/apps/platform/.env.*
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
apps/platform/node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -2,12 +2,19 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
|
apps/platform/public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
apps/platform/public/hot/
|
||||||
public/storage/
|
public/storage/
|
||||||
|
apps/platform/public/storage/
|
||||||
coverage/
|
coverage/
|
||||||
vendor/
|
vendor/
|
||||||
|
apps/platform/vendor/
|
||||||
|
apps/platform/node_modules/
|
||||||
storage/
|
storage/
|
||||||
|
apps/platform/storage/
|
||||||
bootstrap/cache/
|
bootstrap/cache/
|
||||||
|
apps/platform/bootstrap/cache/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.5.0 → 1.6.0
|
- Version change: 2.0.0 -> 2.1.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
|
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||||
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
|
with cross-reference to new HDR-001
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- RBAC & UI Enforcement Standards (RBAC-UX)
|
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||||
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||||
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
||||||
- Follow-up TODOs: None
|
UI/UX Surface Classification and Operator Surface Contract tables already
|
||||||
|
cover header action placement implicitly)
|
||||||
|
- Commands checked:
|
||||||
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
|
- Follow-up TODOs:
|
||||||
|
- None.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
@ -40,18 +45,110 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Proportionality First (PROP-001)
|
||||||
|
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||||
|
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||||
|
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||||
|
|
||||||
|
### No Premature Abstraction (ABSTR-001)
|
||||||
|
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||||
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
|
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||||
|
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||||
|
|
||||||
|
### No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||||
|
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||||
|
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||||
|
|
||||||
|
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||||
|
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||||
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
- The burden of proof is always on the broader abstraction.
|
||||||
|
|
||||||
|
### One Truth, Few Layers (LAYER-001)
|
||||||
|
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||||
|
- Prefer one canonical truth with thin adapters.
|
||||||
|
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||||
|
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||||
|
|
||||||
|
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||||
|
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||||
|
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||||
|
|
||||||
|
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||||
|
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||||
|
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||||
|
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||||
|
|
||||||
|
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||||
|
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||||
|
|
||||||
|
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||||
|
- That review MUST answer:
|
||||||
|
1. What current operator problem does this solve?
|
||||||
|
2. Why is existing structure insufficient?
|
||||||
|
3. Why is this the narrowest correct implementation?
|
||||||
|
4. What ownership cost does this create?
|
||||||
|
5. What alternative was intentionally rejected?
|
||||||
|
6. Is this current-release truth or future-release preparation?
|
||||||
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### Default Bias (BIAS-001)
|
||||||
|
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||||
|
|
||||||
|
### Workspace Isolation is Non-negotiable
|
||||||
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
|
deny-as-not-found (404).
|
||||||
|
- Workspace is the primary session context. Tenant-scoped routes/resources MUST require an established workspace context.
|
||||||
|
- Workspace context switching is separate from Filament Tenancy (Managed Tenant switching).
|
||||||
|
|
||||||
### Tenant Isolation is Non-negotiable
|
### Tenant Isolation is Non-negotiable
|
||||||
- Every read/write MUST be tenant-scoped.
|
- Every tenant-plane read/write MUST be tenant-scoped.
|
||||||
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||||
|
- Tenantless canonical views (e.g., Monitoring/Operations) MUST enforce tenant entitlement before revealing records.
|
||||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||||
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
|
|
||||||
|
Scope & Ownership Clarification (SCOPE-001)
|
||||||
|
|
||||||
|
- The system MUST enforce a strict ownership model:
|
||||||
|
- Workspace-owned objects define standards, templates, and global configuration (e.g., Baseline Profiles, Notification Targets, Alert Routing Rules, Framework/Control catalogs).
|
||||||
|
- Tenant-owned objects represent observed state, evidence, and operational artifacts for a specific tenant (e.g., Inventory, Backups/Snapshots, OperationRuns for tenant operations, Drift/Findings, Exceptions/Risk Acceptance, EvidenceItems, StoredReports/Exports).
|
||||||
|
- Workspace-owned objects MUST NOT directly embed or persist tenant-owned records (no “copying tenant data into templates”).
|
||||||
|
- Tenant-owned objects MUST always be bound to an established workspace + tenant scope at authorization time.
|
||||||
|
|
||||||
|
Database convention:
|
||||||
|
|
||||||
|
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
|
||||||
|
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||||
|
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||||
|
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||||
|
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||||
|
|
||||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
RBAC Context — Planes, Roles, and Auditability
|
RBAC Context — Planes, Roles, and Auditability
|
||||||
- The platform MUST maintain two strictly separated authorization planes:
|
- The platform MUST maintain two strictly separated authorization planes:
|
||||||
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
- Tenant/Admin plane (`/admin`): authenticated Entra users (`users`).
|
||||||
|
- Tenant-context routes (`/admin/t/{tenant}/...`) are tenant-scoped.
|
||||||
|
- Workspace-context canonical routes (`/admin/...`, e.g. Monitoring/Operations) are tenantless by URL but MUST still enforce workspace + tenant entitlement before revealing tenant-owned records.
|
||||||
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
||||||
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
||||||
- Tenant role semantics MUST remain least-privilege:
|
- Tenant role semantics MUST remain least-privilege:
|
||||||
@ -69,15 +166,15 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
|
|||||||
- Any missing server-side authorization is a P0 security bug.
|
- Any missing server-side authorization is a P0 security bug.
|
||||||
|
|
||||||
RBAC-UX-002 — Deny-as-not-found for non-members
|
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||||
- Tenant membership (and plane membership) is an isolation boundary.
|
- Tenant and workspace membership (and plane membership) are isolation boundaries.
|
||||||
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
- If the current actor is not a member of the current workspace OR the current tenant (or otherwise not entitled to the
|
||||||
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
workspace/tenant scope), the system MUST respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||||
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||||
action endpoints (Livewire calls included).
|
action endpoints (Livewire calls included).
|
||||||
|
|
||||||
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||||
- Within an established tenant scope, missing permissions are authorization failures.
|
- Within an established workspace + tenant scope, missing permissions are authorization failures.
|
||||||
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
- If the actor is a workspace + tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
||||||
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
||||||
|
|
||||||
RBAC-UX-004 — Visible vs disabled UX rule
|
RBAC-UX-004 — Visible vs disabled UX rule
|
||||||
@ -99,9 +196,12 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
|
|||||||
- CI MUST fail if unknown/unregistered capabilities are used.
|
- CI MUST fail if unknown/unregistered capabilities are used.
|
||||||
|
|
||||||
RBAC-UX-007 — Global search must be tenant-safe
|
RBAC-UX-007 — Global search must be tenant-safe
|
||||||
- Global search results MUST be scoped to the current tenant.
|
- Global search MUST be context-safe (workspace-context vs tenant-context).
|
||||||
- Non-members MUST never learn about resources in other tenants (no results, no hints).
|
- Non-members MUST never learn about resources in other tenants (no results, no hints).
|
||||||
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
|
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
|
||||||
|
- In workspace-context (no active tenant selected), Global Search MUST NOT return tenant-owned results.
|
||||||
|
- It MAY search workspace-owned objects only (e.g., Tenants list entries, Baseline Profiles, Alert Rules/Targets, workspace settings).
|
||||||
|
- If tenant-context is active, Global Search MUST be scoped to the current tenant only (existing rule remains).
|
||||||
|
|
||||||
RBAC-UX-008 — Regression guards are mandatory
|
RBAC-UX-008 — Regression guards are mandatory
|
||||||
- The repo MUST include RBAC regression tests asserting at least:
|
- The repo MUST include RBAC regression tests asserting at least:
|
||||||
@ -131,6 +231,72 @@ ### Operations / Run Observability Standard
|
|||||||
- Monitoring pages MUST be DB-only at render time (no external calls).
|
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||||
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||||
confirm + “View run”.
|
confirm + “View run”.
|
||||||
|
|
||||||
|
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
|
||||||
|
|
||||||
|
1) Toast (intent only / queued-only)
|
||||||
|
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
||||||
|
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
||||||
|
- Feature code MUST NOT craft ad-hoc operation toasts.
|
||||||
|
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
|
||||||
|
|
||||||
|
2) Progress (active awareness only)
|
||||||
|
- Live progress MUST exist only in:
|
||||||
|
- the global active-ops widget, and
|
||||||
|
- Monitoring → Operation Run Detail.
|
||||||
|
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
|
||||||
|
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
|
||||||
|
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
||||||
|
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
|
||||||
|
|
||||||
|
3) Terminal DB Notification (audit outcome only)
|
||||||
|
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
|
||||||
|
- Delivery MUST be initiator-only (no tenant-wide fan-out).
|
||||||
|
- Completion notifications MUST be `OperationRunCompleted` only.
|
||||||
|
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
|
||||||
|
|
||||||
|
Canonical navigation:
|
||||||
|
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
|
||||||
|
|
||||||
|
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
||||||
|
|
||||||
|
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
|
||||||
|
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
|
||||||
|
|
||||||
|
Forbidden outside `OperationRunService`:
|
||||||
|
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
|
||||||
|
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
|
||||||
|
- Query-based updates that transition `status`/`outcome`
|
||||||
|
|
||||||
|
Allowed outside the service:
|
||||||
|
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
|
||||||
|
|
||||||
|
### Summary counts contract (OPS-UX-SUM-001)
|
||||||
|
|
||||||
|
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
|
||||||
|
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
|
||||||
|
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
|
||||||
|
- Producers MUST NOT introduce new keys without:
|
||||||
|
1) updating `OperationSummaryKeys::all()`,
|
||||||
|
2) updating the spec canonical list,
|
||||||
|
3) adding/adjusting tests.
|
||||||
|
|
||||||
|
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||||
|
|
||||||
|
The repo MUST include automated guards (Pest) that fail CI if:
|
||||||
|
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
|
||||||
|
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
|
||||||
|
- deprecated legacy operation notification classes are referenced again.
|
||||||
|
|
||||||
|
These guards MUST fail with actionable output (file + snippet).
|
||||||
|
|
||||||
|
### Scheduled/system runs (OPS-UX-SYS-001)
|
||||||
|
|
||||||
|
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
|
||||||
|
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
|
||||||
|
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
|
||||||
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
||||||
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
||||||
in failures or notifications.
|
in failures or notifications.
|
||||||
@ -139,6 +305,516 @@ ### Operations / Run Observability Standard
|
|||||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||||
|
|
||||||
|
### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||||
|
|
||||||
|
Purpose and scope
|
||||||
|
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
||||||
|
- It defines allowed surface types, allowed interaction models, primary/secondary/destructive action hierarchy, list/detail/queue semantics, scope and context signals, canonical navigation and naming rules, visibility of critical operational truth, scanability and density rules, exception handling, and review and enforcement requirements.
|
||||||
|
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
||||||
|
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
||||||
|
|
||||||
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
|
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||||
|
|
||||||
|
##### CRUD / List-first Resource
|
||||||
|
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||||
|
- Primary behavior: Browse -> Open -> Decide / Mutate.
|
||||||
|
- Primary model: one-click inspect/open. Full-row click is the default; identifier click is allowed only when full-row click conflicts with another dominant row mechanism.
|
||||||
|
- Secondary actions: at most one inline non-destructive shortcut; everything else belongs in overflow.
|
||||||
|
- Destructive actions: never inline beside inspect; only in overflow or the detail header; confirmation is mandatory.
|
||||||
|
- Explicit View/Inspect: forbidden when row click or identifier click already opens the same destination.
|
||||||
|
|
||||||
|
##### Queue / Review Surface
|
||||||
|
- Purpose: triage items, inspect them in context, decide, and continue working through the queue.
|
||||||
|
- Primary behavior: Inspect in context -> Decide -> Continue.
|
||||||
|
- Primary model: explicit Inspect using a slide-over, inline detail pane, or same-page inspect.
|
||||||
|
- Secondary actions: only queue-relevant actions belong in the row.
|
||||||
|
- Destructive actions: inline is allowed only when the destructive decision is part of the real queue work; irreversibility or high risk still requires confirmation.
|
||||||
|
- Row click: forbidden by default.
|
||||||
|
- Explicit View/Inspect: required unless the detail is already visible inline.
|
||||||
|
|
||||||
|
##### History / Audit Surface
|
||||||
|
- Purpose: inspect immutable history, events, and evidence without losing chronology.
|
||||||
|
- Primary behavior: Inspect event -> Follow trace -> Return to history context.
|
||||||
|
- Primary model: explicit Inspect, preferably in a slide-over or same-page detail.
|
||||||
|
- Secondary actions: related navigation only.
|
||||||
|
- Destructive actions: normally none.
|
||||||
|
- Row click: forbidden.
|
||||||
|
- Explicit View/Inspect: required.
|
||||||
|
|
||||||
|
##### Config-lite Resource
|
||||||
|
- Purpose: manage small, low-cardinality configuration where edit is effectively the detail surface.
|
||||||
|
- Primary behavior: Open config -> Adjust.
|
||||||
|
- Primary model: edit-as-inspect.
|
||||||
|
- Secondary actions: minimal and usually limited to Edit or overflow.
|
||||||
|
- Destructive actions: overflow or detail header only.
|
||||||
|
- Row click: allowed when it opens Edit directly and no separate View surface exists.
|
||||||
|
- Explicit View/Inspect: forbidden.
|
||||||
|
|
||||||
|
##### Read-only Registry / Report Surface
|
||||||
|
- Purpose: inspect, compare, reference, and export immutable or mostly read-only artifacts.
|
||||||
|
- Primary behavior: Scan -> Open detail -> Reference / Export.
|
||||||
|
- Primary model: row click or identifier click to detail.
|
||||||
|
- Secondary actions: optional single inline non-destructive shortcut when it serves the operator flow.
|
||||||
|
- Destructive actions: normally none; if they exist they belong in detail only.
|
||||||
|
- Explicit View/Inspect: forbidden when a functional one-click open already exists.
|
||||||
|
|
||||||
|
##### Detail-first Operational Surface
|
||||||
|
- Purpose: fully understand one operational record, including state, truth, context, and next steps.
|
||||||
|
- Primary behavior: Read -> Understand -> Act / Navigate.
|
||||||
|
- Primary model: dedicated detail page or dedicated operational page.
|
||||||
|
- Secondary actions: header actions and related-link groups.
|
||||||
|
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||||
|
- Row click and explicit View/Inspect: not applicable.
|
||||||
|
|
||||||
|
#### Hard Rules (UI-HARD-001)
|
||||||
|
|
||||||
|
##### Primary inspect model
|
||||||
|
- Every list surface MUST expose exactly one primary inspect/open model.
|
||||||
|
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
||||||
|
- CRUD / List-first and Read-only Registry / Report surfaces MUST provide an obvious one-click open path.
|
||||||
|
- Queue / Review and History / Audit surfaces MUST use explicit Inspect rather than row-click navigation.
|
||||||
|
|
||||||
|
##### Row-click semantics
|
||||||
|
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
|
||||||
|
- Identifier-only click is allowed only when full-row click would conflict with another dominant row behavior such as selection-heavy interaction, expand/collapse, drag/sort, or another primary row mechanism.
|
||||||
|
- When row click is enabled, the row MUST feel consistent. Silent split behavior inside the same row is forbidden.
|
||||||
|
- Edit-as-inspect is allowed only for Config-lite resources.
|
||||||
|
|
||||||
|
##### View and Inspect actions
|
||||||
|
- Explicit View MUST NOT exist when the same destination is already opened through row click or identifier click.
|
||||||
|
- Explicit Inspect is the default only for Queue / Review, History / Audit, and explicitly catalogued exceptions.
|
||||||
|
- View and Inspect MUST NOT be treated as interchangeable labels. If the interaction preserves context and behaves unlike ordinary navigation, it is Inspect, not View.
|
||||||
|
|
||||||
|
##### Action hierarchy
|
||||||
|
- Every surface MUST distinguish between the primary inspect/open action, secondary safe actions, destructive actions, and long-running workflow launches.
|
||||||
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed the primary open interaction plus one inline safe shortcut.
|
||||||
|
- All other secondary actions MUST move to overflow.
|
||||||
|
- Long-running workflow launches such as sync, compare, verify, generate, consent, setup, or retry SHOULD live in list headers or detail headers rather than in every row.
|
||||||
|
|
||||||
|
##### Destructive actions
|
||||||
|
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces.
|
||||||
|
- Destructive actions MUST live in overflow or the detail header.
|
||||||
|
- Destructive actions MUST use confirmation.
|
||||||
|
- High-risk or high-volume destructive bulk actions SHOULD use typed confirmation.
|
||||||
|
- The Queue Decision exception applies only when the destructive decision is part of the actual queue work.
|
||||||
|
|
||||||
|
##### Overflow and More
|
||||||
|
- Overflow actions MUST follow one product-wide pattern per surface class.
|
||||||
|
- Mixed labeled-overflow versus icon-only overflow patterns inside the same surface class are forbidden unless an approved exception documents why.
|
||||||
|
- Empty `ActionGroup` and empty `BulkActionGroup` are forbidden.
|
||||||
|
- Placeholder UI added only to satisfy a contract or slot is forbidden.
|
||||||
|
|
||||||
|
##### Bulk actions
|
||||||
|
- Bulk actions are allowed only when they are safe enough, materially faster than row-by-row execution, and genuinely fit the surface.
|
||||||
|
- A surface with no real bulk need MUST NOT render bulk UI.
|
||||||
|
- Bulk destructive actions follow the same protection rules as row destructive actions, with stricter confirmation and review expectations.
|
||||||
|
|
||||||
|
##### Row label length and action budget
|
||||||
|
- Inline row action labels MUST stay short and SHOULD be one or two words.
|
||||||
|
- Long workflow labels belong in overflow, headers, or detail surfaces.
|
||||||
|
- Standard list rows MUST NOT become control centers for onboarding recovery, provider management, consent flows, RBAC setup, diagnostics, and destructive lifecycle actions all at once.
|
||||||
|
|
||||||
|
##### Scope and context semantics
|
||||||
|
- Scope chips, tenant pills, and similar context signals MUST correspond to real scoping behavior.
|
||||||
|
- A scope signal MUST NOT be shown when it neither scopes the displayed data nor materially changes the action targets.
|
||||||
|
- Remembered context is allowed only when labeled clearly as reference context rather than active scope.
|
||||||
|
- Cross-panel navigation MUST NOT imply that the operator remains inside the same logical scope when that is not true.
|
||||||
|
|
||||||
|
##### Canonical navigation and terminology
|
||||||
|
- Every domain object MUST have one canonical collection noun and one canonical singular noun.
|
||||||
|
- The same domain object MUST NOT use competing primary nouns across shells.
|
||||||
|
- The Operations domain MUST use one canonical collection noun. Parallel primary nouns such as Runs beside Operations are forbidden.
|
||||||
|
- Cross-panel navigation is allowed only when it lands on a canonical surface, uses stable nouns, and keeps back navigation clear.
|
||||||
|
|
||||||
|
##### Visibility of critical operational truth
|
||||||
|
- Critical operational truth MUST be visible by default.
|
||||||
|
- It MUST NOT be hidden only in default-off columns, tooltips, helper text, overflow menus, or detail pages when list decisions depend on it.
|
||||||
|
- Lifecycle truth, operability truth, health truth, execution outcome, trust/confidence, and next action MUST remain separate semantic dimensions.
|
||||||
|
- One badge, column, or label MUST NOT collapse multiple truth dimensions into a generic status.
|
||||||
|
|
||||||
|
##### Row density and scanability
|
||||||
|
- Standard CRUD lists MUST remain scanable.
|
||||||
|
- Outside Queue / Review and History / Audit exceptions, each row MAY contain at most one multi-line explanatory column and at most one prose-heavy explanatory context.
|
||||||
|
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose.
|
||||||
|
- Next-step prose belongs in detail, inspect, or queue surfaces, not in ordinary CRUD rows.
|
||||||
|
|
||||||
|
##### Custom abstractions
|
||||||
|
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior.
|
||||||
|
- Contract systems MUST NOT force placeholder UI.
|
||||||
|
- Behavior matters more than declaration. If declared conformance and rendered behavior differ, the surface is non-conformant.
|
||||||
|
- A feature MUST NOT ship when its implemented interaction semantics contradict its declared surface type.
|
||||||
|
|
||||||
|
#### Exception Model (UI-EX-001)
|
||||||
|
|
||||||
|
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
||||||
|
|
||||||
|
##### Queue Decision Exception
|
||||||
|
- Allowed when per-item decision-making is the real queue work.
|
||||||
|
- Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
|
||||||
|
|
||||||
|
##### History In-place Inspect Exception
|
||||||
|
- Allowed when leaving the page would break chronology or traceability.
|
||||||
|
- Guardrails: explicit Inspect is mandatory; row click is forbidden; generic mutation rails are forbidden.
|
||||||
|
|
||||||
|
##### Config-lite Edit-as-Inspect Exception
|
||||||
|
- Allowed when a separate View surface would add no value.
|
||||||
|
- Guardrails: no parallel View surface; no high-risk destructive flow as the default entry point.
|
||||||
|
|
||||||
|
##### Read-only Shortcut Exception
|
||||||
|
- Allowed for exactly one dominant non-destructive shortcut.
|
||||||
|
- Guardrails: inspect/open remains dominant; only one shortcut exists; the shortcut does not compete with the primary open path.
|
||||||
|
|
||||||
|
##### Cross-panel Canonical Route Exception
|
||||||
|
- Allowed when only one canonical surface makes sense.
|
||||||
|
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful.
|
||||||
|
|
||||||
|
#### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
For every new or modified Filament Resource, RelationManager, or Page:
|
||||||
|
|
||||||
|
Required surfaces
|
||||||
|
- List/Table MUST define Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||||
|
- Every table MUST provide a record inspection affordance that matches its surface type.
|
||||||
|
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
||||||
|
- CRUD / List-first, Config-lite, and Read-only Registry surfaces MUST NOT render a redundant View action when the same destination is already available through row click or identifier click.
|
||||||
|
- Queue / Review and History / Audit surfaces MAY use a lone explicit Inspect action because context-preserving inspect is the primary interaction.
|
||||||
|
- View/Detail MUST define header actions and MUST keep destructive actions grouped and confirmed.
|
||||||
|
- View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
|
||||||
|
- Create/Edit MUST provide consistent Save and Cancel UX.
|
||||||
|
|
||||||
|
Grouping and safety
|
||||||
|
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
||||||
|
- Queue / Review rows MAY expose inline decision actions only when allowed by UI-EX-001.
|
||||||
|
- Everything else MUST move to `ActionGroup::make()` or the detail header.
|
||||||
|
- Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
|
||||||
|
- Empty `ActionGroup` and `BulkActionGroup` are forbidden.
|
||||||
|
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
|
||||||
|
- Relevant mutations MUST write an audit log entry.
|
||||||
|
|
||||||
|
RBAC enforcement
|
||||||
|
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
||||||
|
- Members without capability MAY see disabled actions with helper text, but server-side execution MUST still abort(403).
|
||||||
|
- Central tenant and workspace UI enforcement helpers MUST be used for gating.
|
||||||
|
|
||||||
|
Behavior over declaration
|
||||||
|
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||||
|
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||||
|
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||||
|
|
||||||
|
#### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||||
|
|
||||||
|
Goal: operator-facing Filament screens MUST feel enterprise-grade, legible, and decisive.
|
||||||
|
|
||||||
|
Page layout
|
||||||
|
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
||||||
|
- All fields MUST live inside Sections or Cards. Naked root-level inputs are forbidden.
|
||||||
|
- Main content carries domain definition and working content. Aside carries status and meta such as scope, owner, timestamps, or version labels.
|
||||||
|
- Related data MUST render as separate sections, tabs, or subordinate surfaces rather than as one long unstructured form or detail page.
|
||||||
|
|
||||||
|
View pages
|
||||||
|
- View/Detail MUST be a read-only surface built with Infolists or an equivalent read-first structure, not disabled edit forms.
|
||||||
|
- Status-like values MUST render via BADGE-001 semantics.
|
||||||
|
- Long text MUST read like prose, not like disabled textarea output.
|
||||||
|
|
||||||
|
Empty states
|
||||||
|
- Empty lists and tables MUST show a specific title, a one-sentence explanation, and exactly one primary CTA.
|
||||||
|
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||||
|
|
||||||
|
Actions and flows
|
||||||
|
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
||||||
|
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||||
|
- Destructive actions remain non-primary and confirmed.
|
||||||
|
|
||||||
|
Table defaults
|
||||||
|
- Tables SHOULD provide search when the dataset can grow, a meaningful default sort, and filters for core dimensions.
|
||||||
|
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
||||||
|
- Critical operational truth that informs list decisions MUST be default-visible.
|
||||||
|
|
||||||
|
Enforcement
|
||||||
|
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||||
|
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||||
|
|
||||||
|
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
||||||
|
|
||||||
|
Goal: record and detail pages MUST be comprehensible within seconds.
|
||||||
|
Header actions are reserved for the primary workflow of the current page
|
||||||
|
and MUST NOT become a dumping ground for every available action or
|
||||||
|
navigation jump.
|
||||||
|
|
||||||
|
##### Core rule
|
||||||
|
|
||||||
|
Header actions MUST contain only workflow-critical actions of the
|
||||||
|
currently displayed record. Pure navigation, relational jumps, and
|
||||||
|
contextual references do not belong in the header; they belong directly
|
||||||
|
at the affected field, status indicator, or relation.
|
||||||
|
|
||||||
|
##### Maximum one primary visible header action
|
||||||
|
|
||||||
|
- Each record/detail page MUST expose at most one clearly prioritized
|
||||||
|
primary visible header action.
|
||||||
|
- That action MUST represent the most obvious next operator step on
|
||||||
|
exactly this page.
|
||||||
|
|
||||||
|
##### Navigation does not belong in headers
|
||||||
|
|
||||||
|
- Actions such as "Open finding", "Open queue", "View related run",
|
||||||
|
"Open tenant", or similar jumps are navigation actions, not primary
|
||||||
|
object actions.
|
||||||
|
- They MUST be placed as contextual navigation at fields, badges,
|
||||||
|
relation entries, or status displays — never in the header.
|
||||||
|
|
||||||
|
##### Destructive or governance-changing actions require friction
|
||||||
|
|
||||||
|
- Actions with operational, security-relevant, or governance-changing
|
||||||
|
effect MUST NOT stand at the same visual level as the primary action.
|
||||||
|
- They MUST either:
|
||||||
|
- be rendered as a clearly separated danger action, or
|
||||||
|
- be placed in an Action Group / More Actions.
|
||||||
|
- They MUST always require explicit confirmation
|
||||||
|
(`->requiresConfirmation()`).
|
||||||
|
- If an action changes governance truth, compliance status, risk
|
||||||
|
acceptance, exception validity, or equivalent system truths,
|
||||||
|
additional friction is mandatory (e.g., typed confirmation, reason
|
||||||
|
field, or staged flow).
|
||||||
|
|
||||||
|
##### Rare secondary actions belong in an Action Group
|
||||||
|
|
||||||
|
- Actions that are not part of the expected core workflow of the page
|
||||||
|
or are only occasionally needed MUST NOT appear as equally weighted
|
||||||
|
visible header buttons.
|
||||||
|
- They MUST be placed in an Action Group.
|
||||||
|
|
||||||
|
##### Header clarity over implementation convenience
|
||||||
|
|
||||||
|
- The fact that a framework makes header actions easy to add is not a
|
||||||
|
reason to place actions there.
|
||||||
|
- Information architecture, scanability, and operator clarity take
|
||||||
|
precedence over implementation convenience.
|
||||||
|
|
||||||
|
##### 5-second scan rule
|
||||||
|
|
||||||
|
Every record/detail page MUST pass the 5-second scan rule:
|
||||||
|
|
||||||
|
1. The operator instantly recognizes where they are.
|
||||||
|
2. The operator instantly sees the status of the object.
|
||||||
|
3. The operator instantly identifies the one central next action.
|
||||||
|
4. The operator immediately understands where secondary or dangerous
|
||||||
|
actions live.
|
||||||
|
|
||||||
|
If multiple equally weighted header buttons degrade this readability,
|
||||||
|
it is a constitution violation.
|
||||||
|
|
||||||
|
##### Placement rules
|
||||||
|
|
||||||
|
Allowed in the header:
|
||||||
|
- One primary workflow action.
|
||||||
|
- Optionally one clearly justified secondary action.
|
||||||
|
- Rare or administrative actions only when grouped.
|
||||||
|
- Critical/destructive actions only when separated and with friction.
|
||||||
|
|
||||||
|
Forbidden in the header:
|
||||||
|
- Pure navigation to related objects.
|
||||||
|
- Relational jumps without immediate workflow relevance.
|
||||||
|
- Collections of technically available standard actions.
|
||||||
|
- Multiple equally weighted buttons without clear prioritization.
|
||||||
|
|
||||||
|
##### Preferred pattern
|
||||||
|
|
||||||
|
| Slot | Placement |
|
||||||
|
|---|---|
|
||||||
|
| Primary visible | Exactly 1 |
|
||||||
|
| Danger | Separated or grouped, never casual beside Primary |
|
||||||
|
| Navigation | Inline at context (field, badge, relation) |
|
||||||
|
| Rare actions | More / Action Group |
|
||||||
|
|
||||||
|
##### Binding decision — Exception / Approval surfaces
|
||||||
|
|
||||||
|
For exception detail pages specifically:
|
||||||
|
- **Renew exception** MAY appear as the primary visible header action.
|
||||||
|
- **Revoke exception** is a governance-changing danger action and MUST
|
||||||
|
require friction (separated + confirmation).
|
||||||
|
- **Open finding** MUST be placed as a link at the Finding field, not
|
||||||
|
in the header.
|
||||||
|
- **Open approval queue** MUST be placed as a contextual link at
|
||||||
|
approval / status context, not in the header.
|
||||||
|
|
||||||
|
##### Reviewer heuristics
|
||||||
|
|
||||||
|
A page violates HDR-001 if any of the following are true:
|
||||||
|
- Multiple equally weighted header actions without clear workflow
|
||||||
|
priority.
|
||||||
|
- Pure navigation buttons in the header.
|
||||||
|
- Danger actions beside normal actions without clear separation.
|
||||||
|
- Rarely used administrative actions as visible standard buttons.
|
||||||
|
- The header resembles an action stockpile instead of a focused
|
||||||
|
workflow entry point.
|
||||||
|
|
||||||
|
#### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||||
|
|
||||||
|
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
|
||||||
|
|
||||||
|
Naming model
|
||||||
|
- Operator-facing copy MUST distinguish Scope, Source/Domain, Operation, and Target Object.
|
||||||
|
- Scope terms such as Workspace and Tenant describe execution context and MUST NOT become the primary action label unless they are the actual target object.
|
||||||
|
- Source/domain terms such as Intune or Entra are secondary and lead only when same-screen disambiguation genuinely requires them.
|
||||||
|
|
||||||
|
Primary labels
|
||||||
|
- Primary buttons, header actions, and menu actions MUST use Verb + Object.
|
||||||
|
- Preferred examples are `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, and `Export review pack`.
|
||||||
|
- Implementation-first labels such as `Sync from tenant`, `Sync from Intune`, `Run tenant sync now`, or `Start inventory refresh from provider` are forbidden.
|
||||||
|
|
||||||
|
Canonical nouns and routes
|
||||||
|
- Every domain object MUST keep one canonical collection noun and one canonical singular noun.
|
||||||
|
- Cross-shell or cross-panel navigation MUST preserve the same noun.
|
||||||
|
- Operations is the canonical collection noun for run records. Runs MUST NOT appear as a competing primary collection noun.
|
||||||
|
|
||||||
|
Run, notification, and audit semantics
|
||||||
|
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD remain concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||||
|
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short.
|
||||||
|
- Audit prose MUST use the same operator-facing language as the initiating action.
|
||||||
|
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, audit prose, and related navigation.
|
||||||
|
|
||||||
|
Verb standard
|
||||||
|
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||||
|
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided unless the domain specifically requires them.
|
||||||
|
- `Run` MAY be used only when the object is itself run-like, such as `Run review`; it MUST NOT become the fallback verb for everything.
|
||||||
|
|
||||||
|
Current binding decision
|
||||||
|
- The Policies screen primary action MUST be `Sync policies`.
|
||||||
|
- The Policies screen modal title MUST be `Sync policies`.
|
||||||
|
- The Policies screen success toast MUST be `Policy sync queued`.
|
||||||
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
|
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||||
|
|
||||||
|
#### Operator Surface Principles (OPSURF-001)
|
||||||
|
|
||||||
|
Goal: operator-facing surfaces MUST optimize for the operator's working question instead of raw implementation visibility.
|
||||||
|
|
||||||
|
Operator-first default surfaces
|
||||||
|
- `/admin` is operator-first.
|
||||||
|
- Default-visible content MUST use operator language, clear scope, and actionable status communication.
|
||||||
|
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||||
|
|
||||||
|
Progressive disclosure for diagnostics
|
||||||
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
|
|
||||||
|
Distinct truth dimensions
|
||||||
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
|
|
||||||
|
Explicit mutation scope
|
||||||
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
|
|
||||||
|
Safe execution
|
||||||
|
- Dangerous actions MUST follow a consistent safety flow: configuration, safety checks or simulation, preview, hard confirmation where required, then execution.
|
||||||
|
- One-click high-blast-radius actions are forbidden unless an approved exception documents replacement safeguards.
|
||||||
|
|
||||||
|
Explicit workspace and tenant context
|
||||||
|
- Workspace and tenant context MUST remain explicit in navigation, action copy, and page semantics.
|
||||||
|
- Tenant surfaces MUST NOT silently expose workspace-wide actions.
|
||||||
|
- Canonical workspace views that operate on tenant-owned records MUST make both workspace and tenant context legible before the operator acts.
|
||||||
|
|
||||||
|
Critical truth visibility and scanability
|
||||||
|
- Critical operational truth MUST be default-visible wherever the list or summary surface is used to prepare decisions.
|
||||||
|
- Standard CRUD surfaces MUST preserve scanability and MUST avoid collapsing multiple truth dimensions into one generic badge or one prose-heavy row.
|
||||||
|
|
||||||
|
Page contract requirement
|
||||||
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
|
|
||||||
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
|
- Every feature spec MUST declare Scope, Primary Routes, Data Ownership, and RBAC requirements.
|
||||||
|
- Canonical-view specs MUST define the default filter behavior when tenant context is active and the entitlement checks that prevent cross-tenant leakage.
|
||||||
|
|
||||||
|
#### Enforcement Model (UI-REVIEW-001)
|
||||||
|
|
||||||
|
Spec review requirements
|
||||||
|
- Every spec that changes an operator-facing surface MUST answer: surface type, primary inspect/open model, row-click rule, whether explicit View/Inspect exists or is forbidden, where secondary actions live, where destructive actions live, canonical collection route, canonical detail route, scope signals and their exact meaning, canonical noun, critical truth visible by default, and whether an exception type is used.
|
||||||
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
|
PR review requirements
|
||||||
|
- A PR MUST NOT pass when it introduces more than one primary inspect model, redundant View beside row click, destructive inline actions beside inspect on standard lists, empty overflow or bulk groups, long workflow labels in dense rows, misleading scope chips, drifting domain nouns, hidden critical operational truth, or undocumented exceptions without dedicated tests.
|
||||||
|
|
||||||
|
Guard tests
|
||||||
|
- Repository guards SHOULD validate: declared surface type, conformant primary inspect model, absence of redundant View actions, presence of explicit Inspect on Queue / Review and History / Audit surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, correct placement of destructive actions, truthful scope signals, stable canonical nouns across shells, and dedicated tests for every approved exception.
|
||||||
|
|
||||||
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
|
Wave 1 - Interaction normalization
|
||||||
|
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
||||||
|
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||||
|
- Wave 1 is done only when each surface has exactly one primary inspect model, destructive actions are protected, and placeholder groups are gone.
|
||||||
|
|
||||||
|
Wave 2 - Scope, nouns, and truth
|
||||||
|
- Then fix scope and context leaks, stabilize canonical nouns, make cross-panel transitions explicit, move critical operational truth to default-visible regions, and reduce prose-heavy dense rows.
|
||||||
|
|
||||||
|
Wave 3 - Enforcement
|
||||||
|
- Then move the constitution into repo enforcement, require the PR checklist, anchor guard tests, and trim old declaration-only action-surface checks until behavior is the governing truth.
|
||||||
|
|
||||||
|
#### Appendix A - One-page Condensed Constitution
|
||||||
|
|
||||||
|
- Every admin surface has one surface type.
|
||||||
|
- Every list has exactly one primary inspect/open model.
|
||||||
|
- CRUD and Registry surfaces use one-click open.
|
||||||
|
- Queue and Audit surfaces use explicit Inspect.
|
||||||
|
- Edit-as-inspect exists only for Config-lite resources.
|
||||||
|
- Standard lists expose at most one inline safe shortcut.
|
||||||
|
- Destructive actions never sit openly beside inspect on standard lists.
|
||||||
|
- Overflow is standardized per surface class and is never empty.
|
||||||
|
- Bulk exists only when it is genuinely useful.
|
||||||
|
- Scope chips must be truthful.
|
||||||
|
- Domain nouns are canonical and stable.
|
||||||
|
- Critical operational truth is default-visible.
|
||||||
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
|
- Standard lists stay scanable.
|
||||||
|
- Exceptions are catalogued, justified, and tested.
|
||||||
|
- Features with ambiguous interaction semantics do not ship.
|
||||||
|
- Header actions on record/detail pages expose at most one primary action; navigation belongs at context, not in the header.
|
||||||
|
|
||||||
|
#### Appendix B - Feature Review Checklist
|
||||||
|
|
||||||
|
- Surface type is declared.
|
||||||
|
- Primary inspect/open model is defined.
|
||||||
|
- Row-click rule is decided.
|
||||||
|
- View/Inspect is correctly present or correctly forbidden.
|
||||||
|
- Edit-as-inspect is used only when allowed.
|
||||||
|
- Secondary actions are grouped correctly.
|
||||||
|
- Destructive actions are placed correctly.
|
||||||
|
- Overflow is not empty.
|
||||||
|
- Bulk is justified.
|
||||||
|
- Inline labels are short.
|
||||||
|
- Scope signals are truthful.
|
||||||
|
- Canonical nouns stay consistent.
|
||||||
|
- Critical truth is visible.
|
||||||
|
- Scanability is preserved.
|
||||||
|
- Exceptions are documented and tested.
|
||||||
|
- Header passes the 5-second scan rule (HDR-001).
|
||||||
|
- No pure navigation in the header.
|
||||||
|
- Governance-changing actions have extra friction.
|
||||||
|
|
||||||
|
#### Appendix C - Red Flags for Future PRs
|
||||||
|
|
||||||
|
- Row click and View open the same destination.
|
||||||
|
- A row becomes a control center.
|
||||||
|
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||||
|
- More menus or bulk menus are empty.
|
||||||
|
- Scope chips have no real scope effect.
|
||||||
|
- Runs and Operations are used as competing primary collection nouns.
|
||||||
|
- Long workflow labels live in dense tables.
|
||||||
|
- Edit is used as default inspect even though a true View surface exists.
|
||||||
|
- Queue surfaces throw the operator out of context through row click.
|
||||||
|
- Critical health or operability truth is hidden by default.
|
||||||
|
- A contract claims conformance while the rendered UI behaves differently.
|
||||||
|
- Header has multiple equally weighted buttons without clear prioritization.
|
||||||
|
- "Open X" navigation links placed in the header instead of at the related field.
|
||||||
|
- Governance-changing actions sit casually beside the primary action without friction.
|
||||||
|
|
||||||
### Data Minimization & Safe Logging
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
||||||
@ -150,6 +826,50 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
|
- Deviations MUST be explicit and justified in the spec or PR.
|
||||||
|
- Canonical standards live in `docs/product/standards/` and are the source of truth for:
|
||||||
|
- Table UX (column tiers, sort, search, toggle, pagination, persistence, empty states)
|
||||||
|
- Filter UX (persistence, soft-delete, date range, enum sourcing, defaults)
|
||||||
|
- Actions UX (row/bulk/header actions, grouping, destructive safety)
|
||||||
|
- Guard tests enforce critical constraints automatically; the list surface review checklist catches the rest.
|
||||||
|
- A new spec that adds or modifies a list surface MUST reference the review checklist (`docs/product/standards/list-surface-review-checklist.md`).
|
||||||
|
|
||||||
### Spec-First Workflow
|
### Spec-First Workflow
|
||||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||||
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
||||||
@ -160,9 +880,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### Scope, Compliance, and Review Expectations
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
|
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||||
|
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||||
|
|
||||||
### Amendment Procedure
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
@ -174,4 +897,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||||
|
|||||||
@ -35,15 +35,43 @@ ## Constitution Check
|
|||||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||||
|
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
|
||||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||||
|
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||||
|
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||||
|
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||||
|
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||||
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
- UI/UX placeholder ban (UI-HARD-001): empty `ActionGroup` / `BulkActionGroup` placeholders and declaration-only UI conformance are forbidden
|
||||||
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
|
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||||
|
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||||
|
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||||
|
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||||
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
|
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
|
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
@ -107,9 +135,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -5,6 +5,56 @@ # Feature Specification: [FEATURE NAME]
|
|||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: [workspace | tenant | canonical-view]
|
||||||
|
- **Primary Routes**: [List the primary routes/pages affected]
|
||||||
|
- **Data Ownership**: [workspace-owned vs tenant-owned tables/records impacted]
|
||||||
|
- **RBAC**: [membership requirements + capability requirements]
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
|
fill out one row per affected surface.
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | CRUD / List-first Resource | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -82,11 +132,28 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
|
- 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`),
|
||||||
|
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||||
|
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||||
|
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||||
- ensure any cross-plane access is deny-as-not-found (404),
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
- explicitly define 404 vs 403 semantics:
|
- explicitly define 404 vs 403 semantics:
|
||||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||||
- member but missing capability → 403
|
- member but missing capability → 403
|
||||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
@ -100,6 +167,60 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
|
- the target object,
|
||||||
|
- the operator verb,
|
||||||
|
- whether source/domain disambiguation is actually needed,
|
||||||
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||||
|
- the chosen surface type and why it is the correct classification,
|
||||||
|
- the one and only primary inspect/open model,
|
||||||
|
- whether row click is required, allowed, or forbidden,
|
||||||
|
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||||
|
- where secondary actions live,
|
||||||
|
- where destructive actions live,
|
||||||
|
- the canonical collection route and canonical detail route,
|
||||||
|
- the scope signals shown to the operator and what real effect each one has,
|
||||||
|
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||||
|
- which critical operational truth is visible by default,
|
||||||
|
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
|
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
|
||||||
|
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||||
|
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||||
<!--
|
<!--
|
||||||
ACTION REQUIRED: The content in this section represents placeholders.
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
Fill them out with the right functional requirements.
|
Fill them out with the right functional requirements.
|
||||||
@ -118,6 +239,17 @@ ### Functional Requirements
|
|||||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||||
|
|||||||
@ -14,18 +14,76 @@ # Tasks: [FEATURE NAME]
|
|||||||
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||||
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||||
without an `OperationRun`.
|
without an `OperationRun`.
|
||||||
|
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
||||||
|
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
|
||||||
|
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
||||||
|
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||||
|
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
||||||
|
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
|
||||||
|
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
|
||||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
- explicit 404 vs 403 semantics:
|
- explicit 404 vs 403 semantics:
|
||||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||||
- member but missing capability → 403,
|
- member but missing capability → 403,
|
||||||
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- stating which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||||
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
||||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- at least one positive + one negative authorization test.
|
||||||
|
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
|
||||||
|
- aligning primary action labels to `Verb + Object`,
|
||||||
|
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
|
||||||
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
|
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||||
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
|
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||||
|
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||||
|
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||||
|
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||||
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
|
- 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,
|
||||||
|
- moving additional secondary actions into More or the detail header,
|
||||||
|
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||||
|
- grouping bulk actions via BulkActionGroup,
|
||||||
|
- 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 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,
|
||||||
|
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||||
|
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
|
||||||
|
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||||
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -172,6 +230,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
422
Agents.md
422
Agents.md
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
@ -316,12 +318,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -333,10 +336,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -350,11 +353,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -389,6 +392,7 @@ ## Reference Materials
|
|||||||
=== .ai/filament-v5-blueprint rules ===
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
|
|
||||||
If any Filament behavior is uncertain, lookup the exact section in:
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
- docs/research/filament-v5-notes.md
|
- docs/research/filament-v5-notes.md
|
||||||
and prefer that over guesses.
|
and prefer that over guesses.
|
||||||
@ -398,6 +402,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
|||||||
# Filament Blueprint (v5)
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
## 1) Non-negotiables
|
## 1) Non-negotiables
|
||||||
|
|
||||||
- Filament v5 requires Livewire v4.0+.
|
- Filament v5 requires Livewire v4.0+.
|
||||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||||
@ -413,6 +418,7 @@ ## 1) Non-negotiables
|
|||||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
## 2) Directory & naming conventions
|
## 2) Directory & naming conventions
|
||||||
|
|
||||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||||
|
|
||||||
@ -421,19 +427,21 @@ ## 2) Directory & naming conventions
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
## 3) Panel setup defaults
|
## 3) Panel setup defaults
|
||||||
|
|
||||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 4) Navigation & information architecture
|
## 4) Navigation & information architecture
|
||||||
|
|
||||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
@ -447,6 +455,7 @@ ## 4) Navigation & information architecture
|
|||||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
## 5) Resource patterns
|
## 5) Resource patterns
|
||||||
|
|
||||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
- Global search:
|
- Global search:
|
||||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
@ -459,6 +468,7 @@ ## 5) Resource patterns
|
|||||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
## 6) Page lifecycle & query rules
|
## 6) Page lifecycle & query rules
|
||||||
|
|
||||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||||
|
|
||||||
@ -467,6 +477,7 @@ ## 6) Page lifecycle & query rules
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 7) Infolists vs RelationManagers (decision tree)
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
|
||||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
- Inline CRUD inside owner form → Repeater.
|
- Inline CRUD inside owner form → Repeater.
|
||||||
@ -477,6 +488,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
|
|||||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
## 8) Form patterns (validation, reactivity, state)
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
|
||||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||||
- Custom field views must obey state binding modifiers.
|
- Custom field views must obey state binding modifiers.
|
||||||
@ -486,6 +498,7 @@ ## 8) Form patterns (validation, reactivity, state)
|
|||||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
## 9) Table & action patterns
|
## 9) Table & action patterns
|
||||||
|
|
||||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Actions:
|
- Actions:
|
||||||
- Execution actions use `->action(...)`.
|
- Execution actions use `->action(...)`.
|
||||||
@ -498,6 +511,7 @@ ## 9) Table & action patterns
|
|||||||
- https://filamentphp.com/docs/5.x/actions/modals
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
## 10) Authorization & security
|
## 10) Authorization & security
|
||||||
|
|
||||||
- Enforce panel access in non-local environments as documented.
|
- Enforce panel access in non-local environments as documented.
|
||||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||||
@ -507,6 +521,7 @@ ## 10) Authorization & security
|
|||||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
## 11) Notifications & UX feedback
|
## 11) Notifications & UX feedback
|
||||||
|
|
||||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
@ -515,6 +530,7 @@ ## 11) Notifications & UX feedback
|
|||||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
## 12) Performance defaults
|
## 12) Performance defaults
|
||||||
|
|
||||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||||
|
|
||||||
@ -524,6 +540,7 @@ ## 12) Performance defaults
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 13) Testing requirements
|
## 13) Testing requirements
|
||||||
|
|
||||||
- Test pages/relation managers/widgets as Livewire components.
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
- Test actions using Filament’s action testing guidance.
|
- Test actions using Filament’s action testing guidance.
|
||||||
- Do not mount non-Livewire classes in Livewire tests.
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
@ -533,6 +550,7 @@ ## 13) Testing requirements
|
|||||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
## 14) Forbidden patterns
|
## 14) Forbidden patterns
|
||||||
|
|
||||||
- Mixing Filament v3/v4 APIs into v5 code.
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
- Any mention of Livewire v3 for Filament v5.
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
@ -547,6 +565,7 @@ ## 14) Forbidden patterns
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 15) Agent output contract
|
## 15) Agent output contract
|
||||||
|
|
||||||
For any implementation request, the agent must explicitly state:
|
For any implementation request, the agent must explicitly state:
|
||||||
1) Livewire v4.0+ compliance.
|
1) Livewire v4.0+ compliance.
|
||||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
@ -567,6 +586,7 @@ ## 15) Agent output contract
|
|||||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
## Version Safety
|
## Version Safety
|
||||||
|
|
||||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||||
@ -574,6 +594,7 @@ ## Version Safety
|
|||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
## Panel & Navigation
|
## Panel & Navigation
|
||||||
|
|
||||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
@ -588,6 +609,7 @@ ## Panel & Navigation
|
|||||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
## Resource Structure
|
## Resource Structure
|
||||||
|
|
||||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||||
@ -596,18 +618,21 @@ ## Resource Structure
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
## Infolists & Relations
|
## Infolists & Relations
|
||||||
|
|
||||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||||
|
|
||||||
## Tables & Actions
|
## Tables & Actions
|
||||||
|
|
||||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
@ -616,6 +641,7 @@ ## Tables & Actions
|
|||||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
## Authorization & Security
|
## Authorization & Security
|
||||||
|
|
||||||
- [ ] Panel access is enforced for non-local environments as documented.
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||||
@ -623,34 +649,39 @@ ## Authorization & Security
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
## UX & Notifications
|
## UX & Notifications
|
||||||
|
|
||||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
|
||||||
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.15
|
- php - 8.4.15
|
||||||
@ -666,56 +697,73 @@ ## Foundational Context
|
|||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Skills Activation
|
||||||
|
|
||||||
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
|
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||||
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## Verification Scripts
|
||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||||
|
|
||||||
## Application Structure & Architecture
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
|
||||||
|
|
||||||
## Replies
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
# Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
## Tinker / Debugging
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
- Use the `database-query` tool when you only need to read from the database.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## Searching Documentation (Critically Important)
|
||||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
|
||||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
### Available Search Syntax
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
|
||||||
|
|
||||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
@ -725,65 +773,72 @@ ### Available Search Syntax
|
|||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
# PHP
|
||||||
|
|
||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- `public function __construct(public GitHub $github) { }`
|
||||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
### Type Declarations
|
## Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
<!-- Explicit Return Types and Method Params -->
|
||||||
|
```php
|
||||||
protected function isAccessible(User $user, ?string $path = null): bool
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
</code-snippet>
|
```
|
||||||
|
|
||||||
## Comments
|
|
||||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
|
||||||
|
|
||||||
## PHPDoc Blocks
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== sail rules ===
|
=== sail rules ===
|
||||||
|
|
||||||
## Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@ -791,43 +846,53 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
### Controllers & Validation
|
## Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
## Authentication & Authorization
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
## URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
### Testing
|
## Testing
|
||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
## Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
# Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version-specific documentation.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
## Laravel 12 Structure
|
||||||
|
|
||||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
@ -835,224 +900,39 @@ ### Laravel 12 Structure
|
|||||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
=== livewire/core rules ===
|
|
||||||
|
|
||||||
## Livewire
|
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Livewire Best Practices
|
|
||||||
- Livewire components require a single root element.
|
|
||||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
|
||||||
- Add `wire:key` in loops:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@foreach ($items as $item)
|
|
||||||
<div wire:key="item-{{ $item->id }}">
|
|
||||||
{{ $item->name }}
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
|
||||||
|
|
||||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
|
||||||
public function mount(User $user) { $this->user = $user; }
|
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
## Testing Livewire
|
|
||||||
|
|
||||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('count', 0)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 1)
|
|
||||||
->assertSee(1)
|
|
||||||
->assertStatus(200);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
### Testing
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
|
||||||
|
|
||||||
### Pest Tests
|
- This project uses Pest for testing. Create tests: `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}`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- 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.
|
- Do NOT delete tests without approval.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
- Pest tests look and behave like this:
|
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
|
||||||
it('is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
|
||||||
- To run all tests 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
|
|
||||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
|
||||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
|
||||||
it('returns all', function () {
|
|
||||||
$response = $this->postJson('/api/docs', []);
|
|
||||||
|
|
||||||
$response->assertSuccessful();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Mocking
|
|
||||||
- Mocking can be very helpful when appropriate.
|
|
||||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
|
||||||
- You can also create partial mocks using the same import or self method.
|
|
||||||
|
|
||||||
### Datasets
|
|
||||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
|
||||||
|
|
||||||
<code-snippet name="Pest Dataset Example" lang="php">
|
|
||||||
it('has emails', function (string $email) {
|
|
||||||
expect($email)->not->toBeEmpty();
|
|
||||||
})->with([
|
|
||||||
'james' => 'james@laravel.com',
|
|
||||||
'taylor' => 'taylor@laravel.com',
|
|
||||||
]);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
|
||||||
|
|
||||||
## Pest 4
|
|
||||||
|
|
||||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
|
||||||
- Browser testing is incredibly powerful and useful for this project.
|
|
||||||
- Browser tests should live in `tests/Browser/`.
|
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
|
||||||
|
|
||||||
### Browser Testing
|
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
|
||||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
|
||||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
|
||||||
- Switch color schemes (light/dark mode) when appropriate.
|
|
||||||
- Take screenshots or pause tests for debugging when appropriate.
|
|
||||||
|
|
||||||
### Example Tests
|
|
||||||
|
|
||||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
|
||||||
it('may reset the password', function () {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$page = visit('/sign-in'); // Visit on a real browser...
|
|
||||||
|
|
||||||
$page->assertSee('Sign In')
|
|
||||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
|
||||||
->click('Forgot Password?')
|
|
||||||
->fill('email', 'nuno@laravel.com')
|
|
||||||
->click('Send Reset Link')
|
|
||||||
->assertSee('We have emailed your password reset link!')
|
|
||||||
|
|
||||||
Notification::assertSent(ResetPassword::class);
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
|
||||||
$pages = visit(['/', '/about', '/contact']);
|
|
||||||
|
|
||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind CSS
|
# Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
- When listing items, use gap utilities for spacing; don't use margins.
|
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Superior</div>
|
|
||||||
<div>Michigan</div>
|
|
||||||
<div>Erie</div>
|
|
||||||
</div>
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
|
||||||
|
|
||||||
=== tailwindcss/v4 rules ===
|
|
||||||
|
|
||||||
## Tailwind CSS 4
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
|
||||||
|
|
||||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
|
||||||
- Opacity values are still numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------+--------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
|
|||||||
418
GEMINI.md
418
GEMINI.md
@ -156,12 +156,13 @@ ## Security
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Sail (preferred locally)
|
### Sail (preferred locally)
|
||||||
- `./vendor/bin/sail up -d`
|
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
- `./vendor/bin/sail down`
|
- `cd apps/platform && ./vendor/bin/sail down`
|
||||||
- `./vendor/bin/sail composer install`
|
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- `./vendor/bin/sail artisan migrate`
|
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- `./vendor/bin/sail artisan test`
|
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||||
- `./vendor/bin/sail artisan` (general)
|
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||||
|
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||||
|
|
||||||
### Drizzle (local DB tooling, if configured)
|
### Drizzle (local DB tooling, if configured)
|
||||||
- Use only for local/dev workflows.
|
- Use only for local/dev workflows.
|
||||||
@ -173,10 +174,10 @@ ### Drizzle (local DB tooling, if configured)
|
|||||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||||
|
|
||||||
### Non-Docker fallback (only if needed)
|
### Non-Docker fallback (only if needed)
|
||||||
- `composer install`
|
- `cd apps/platform && composer install`
|
||||||
- `php artisan serve`
|
- `cd apps/platform && php artisan serve`
|
||||||
- `php artisan migrate`
|
- `cd apps/platform && php artisan migrate`
|
||||||
- `php artisan test`
|
- `cd apps/platform && php artisan test`
|
||||||
|
|
||||||
### Frontend/assets/tooling (if present)
|
### Frontend/assets/tooling (if present)
|
||||||
- `pnpm install`
|
- `pnpm install`
|
||||||
@ -190,11 +191,11 @@ ## Where to look first
|
|||||||
- `.specify/`
|
- `.specify/`
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/`
|
- `apps/platform/app/`
|
||||||
- `database/`
|
- `apps/platform/database/`
|
||||||
- `routes/`
|
- `apps/platform/routes/`
|
||||||
- `resources/`
|
- `apps/platform/resources/`
|
||||||
- `config/`
|
- `apps/platform/config/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -229,6 +230,7 @@ ## Reference Materials
|
|||||||
=== .ai/filament-v5-blueprint rules ===
|
=== .ai/filament-v5-blueprint rules ===
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
|
|
||||||
If any Filament behavior is uncertain, lookup the exact section in:
|
If any Filament behavior is uncertain, lookup the exact section in:
|
||||||
- docs/research/filament-v5-notes.md
|
- docs/research/filament-v5-notes.md
|
||||||
and prefer that over guesses.
|
and prefer that over guesses.
|
||||||
@ -238,6 +240,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
|
|||||||
# Filament Blueprint (v5)
|
# Filament Blueprint (v5)
|
||||||
|
|
||||||
## 1) Non-negotiables
|
## 1) Non-negotiables
|
||||||
|
|
||||||
- Filament v5 requires Livewire v4.0+.
|
- Filament v5 requires Livewire v4.0+.
|
||||||
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
|
||||||
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
|
||||||
@ -253,6 +256,7 @@ ## 1) Non-negotiables
|
|||||||
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
- https://filamentphp.com/docs/5.x/styling/css-hooks
|
||||||
|
|
||||||
## 2) Directory & naming conventions
|
## 2) Directory & naming conventions
|
||||||
|
|
||||||
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
|
||||||
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
|
||||||
|
|
||||||
@ -261,19 +265,21 @@ ## 2) Directory & naming conventions
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
|
||||||
|
|
||||||
## 3) Panel setup defaults
|
## 3) Panel setup defaults
|
||||||
|
|
||||||
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
|
||||||
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
|
||||||
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
|
||||||
- Assets policy:
|
- Assets policy:
|
||||||
- Panel-only assets: register via panel config.
|
- Panel-only assets: register via panel config.
|
||||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||||
- Deployment must include `php artisan filament:assets`.
|
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 4) Navigation & information architecture
|
## 4) Navigation & information architecture
|
||||||
|
|
||||||
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
|
||||||
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
|
||||||
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
|
||||||
@ -287,6 +293,7 @@ ## 4) Navigation & information architecture
|
|||||||
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
- https://filamentphp.com/docs/5.x/navigation/user-menu
|
||||||
|
|
||||||
## 5) Resource patterns
|
## 5) Resource patterns
|
||||||
|
|
||||||
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
|
||||||
- Global search:
|
- Global search:
|
||||||
- If a resource is intended for global search: ensure Edit/View page exists.
|
- If a resource is intended for global search: ensure Edit/View page exists.
|
||||||
@ -299,6 +306,7 @@ ## 5) Resource patterns
|
|||||||
- https://filamentphp.com/docs/5.x/resources/global-search
|
- https://filamentphp.com/docs/5.x/resources/global-search
|
||||||
|
|
||||||
## 6) Page lifecycle & query rules
|
## 6) Page lifecycle & query rules
|
||||||
|
|
||||||
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
|
||||||
- Prefer render hooks for layout injection; avoid publishing internal views.
|
- Prefer render hooks for layout injection; avoid publishing internal views.
|
||||||
|
|
||||||
@ -307,6 +315,7 @@ ## 6) Page lifecycle & query rules
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 7) Infolists vs RelationManagers (decision tree)
|
## 7) Infolists vs RelationManagers (decision tree)
|
||||||
|
|
||||||
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
- Interactive CRUD / attach / detach under owner record → RelationManager.
|
||||||
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
|
||||||
- Inline CRUD inside owner form → Repeater.
|
- Inline CRUD inside owner form → Repeater.
|
||||||
@ -317,6 +326,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
|
|||||||
- https://filamentphp.com/docs/5.x/infolists/overview
|
- https://filamentphp.com/docs/5.x/infolists/overview
|
||||||
|
|
||||||
## 8) Form patterns (validation, reactivity, state)
|
## 8) Form patterns (validation, reactivity, state)
|
||||||
|
|
||||||
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
|
||||||
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
|
||||||
- Custom field views must obey state binding modifiers.
|
- Custom field views must obey state binding modifiers.
|
||||||
@ -326,6 +336,7 @@ ## 8) Form patterns (validation, reactivity, state)
|
|||||||
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
- https://filamentphp.com/docs/5.x/forms/custom-fields
|
||||||
|
|
||||||
## 9) Table & action patterns
|
## 9) Table & action patterns
|
||||||
|
|
||||||
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Actions:
|
- Actions:
|
||||||
- Execution actions use `->action(...)`.
|
- Execution actions use `->action(...)`.
|
||||||
@ -338,6 +349,7 @@ ## 9) Table & action patterns
|
|||||||
- https://filamentphp.com/docs/5.x/actions/modals
|
- https://filamentphp.com/docs/5.x/actions/modals
|
||||||
|
|
||||||
## 10) Authorization & security
|
## 10) Authorization & security
|
||||||
|
|
||||||
- Enforce panel access in non-local environments as documented.
|
- Enforce panel access in non-local environments as documented.
|
||||||
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
|
||||||
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
|
||||||
@ -347,6 +359,7 @@ ## 10) Authorization & security
|
|||||||
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
- https://filamentphp.com/docs/5.x/resources/deleting-records
|
||||||
|
|
||||||
## 11) Notifications & UX feedback
|
## 11) Notifications & UX feedback
|
||||||
|
|
||||||
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
|
||||||
- Treat polling as a cost; set intervals intentionally where polling is used.
|
- Treat polling as a cost; set intervals intentionally where polling is used.
|
||||||
|
|
||||||
@ -355,6 +368,7 @@ ## 11) Notifications & UX feedback
|
|||||||
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
- https://filamentphp.com/docs/5.x/widgets/stats-overview
|
||||||
|
|
||||||
## 12) Performance defaults
|
## 12) Performance defaults
|
||||||
|
|
||||||
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
|
||||||
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
|
||||||
|
|
||||||
@ -364,6 +378,7 @@ ## 12) Performance defaults
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
- https://filamentphp.com/docs/5.x/advanced/render-hooks
|
||||||
|
|
||||||
## 13) Testing requirements
|
## 13) Testing requirements
|
||||||
|
|
||||||
- Test pages/relation managers/widgets as Livewire components.
|
- Test pages/relation managers/widgets as Livewire components.
|
||||||
- Test actions using Filament’s action testing guidance.
|
- Test actions using Filament’s action testing guidance.
|
||||||
- Do not mount non-Livewire classes in Livewire tests.
|
- Do not mount non-Livewire classes in Livewire tests.
|
||||||
@ -373,6 +388,7 @@ ## 13) Testing requirements
|
|||||||
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
- https://filamentphp.com/docs/5.x/testing/testing-actions
|
||||||
|
|
||||||
## 14) Forbidden patterns
|
## 14) Forbidden patterns
|
||||||
|
|
||||||
- Mixing Filament v3/v4 APIs into v5 code.
|
- Mixing Filament v3/v4 APIs into v5 code.
|
||||||
- Any mention of Livewire v3 for Filament v5.
|
- Any mention of Livewire v3 for Filament v5.
|
||||||
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
|
||||||
@ -387,6 +403,7 @@ ## 14) Forbidden patterns
|
|||||||
- https://filamentphp.com/docs/5.x/advanced/assets
|
- https://filamentphp.com/docs/5.x/advanced/assets
|
||||||
|
|
||||||
## 15) Agent output contract
|
## 15) Agent output contract
|
||||||
|
|
||||||
For any implementation request, the agent must explicitly state:
|
For any implementation request, the agent must explicitly state:
|
||||||
1) Livewire v4.0+ compliance.
|
1) Livewire v4.0+ compliance.
|
||||||
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
|
||||||
@ -407,6 +424,7 @@ ## 15) Agent output contract
|
|||||||
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
|
||||||
|
|
||||||
## Version Safety
|
## Version Safety
|
||||||
|
|
||||||
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
|
||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
|
||||||
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
|
||||||
@ -414,6 +432,7 @@ ## Version Safety
|
|||||||
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
|
||||||
|
|
||||||
## Panel & Navigation
|
## Panel & Navigation
|
||||||
|
|
||||||
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
|
||||||
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
|
||||||
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
|
||||||
@ -428,6 +447,7 @@ ## Panel & Navigation
|
|||||||
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
|
||||||
|
|
||||||
## Resource Structure
|
## Resource Structure
|
||||||
|
|
||||||
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
|
||||||
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
|
||||||
@ -436,18 +456,21 @@ ## Resource Structure
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
|
||||||
|
|
||||||
## Infolists & Relations
|
## Infolists & Relations
|
||||||
|
|
||||||
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
|
||||||
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
|
||||||
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
|
||||||
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
|
||||||
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
|
||||||
|
|
||||||
## Tables & Actions
|
## Tables & Actions
|
||||||
|
|
||||||
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
|
||||||
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
|
||||||
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
|
||||||
@ -456,6 +479,7 @@ ## Tables & Actions
|
|||||||
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
|
||||||
|
|
||||||
## Authorization & Security
|
## Authorization & Security
|
||||||
|
|
||||||
- [ ] Panel access is enforced for non-local environments as documented.
|
- [ ] Panel access is enforced for non-local environments as documented.
|
||||||
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
|
||||||
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
|
||||||
@ -463,34 +487,39 @@ ## Authorization & Security
|
|||||||
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
|
||||||
|
|
||||||
## UX & Notifications
|
## UX & Notifications
|
||||||
|
|
||||||
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
|
||||||
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
|
||||||
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
|
||||||
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
|
||||||
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
|
||||||
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
|
||||||
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
|
||||||
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
|
||||||
|
|
||||||
## Deployment / Ops
|
## Deployment / Ops
|
||||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
|
||||||
|
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.15
|
- php - 8.4.15
|
||||||
@ -506,56 +535,73 @@ ## Foundational Context
|
|||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Skills Activation
|
||||||
|
|
||||||
|
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
|
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||||
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## Verification Scripts
|
||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||||
|
|
||||||
## Application Structure & Architecture
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
|
||||||
|
|
||||||
## Replies
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
# Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
## Tinker / Debugging
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
- Use the `database-query` tool when you only need to read from the database.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## Searching Documentation (Critically Important)
|
||||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
|
||||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
### Available Search Syntax
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
|
||||||
|
|
||||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
@ -565,65 +611,72 @@ ### Available Search Syntax
|
|||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
# PHP
|
||||||
|
|
||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- `public function __construct(public GitHub $github) { }`
|
||||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
### Type Declarations
|
## Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
<!-- Explicit Return Types and Method Params -->
|
||||||
|
```php
|
||||||
protected function isAccessible(User $user, ?string $path = null): bool
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
</code-snippet>
|
```
|
||||||
|
|
||||||
## Comments
|
|
||||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
|
||||||
|
|
||||||
## PHPDoc Blocks
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
|
||||||
|
- Add useful array shape type definitions when appropriate.
|
||||||
|
|
||||||
=== sail rules ===
|
=== sail rules ===
|
||||||
|
|
||||||
## Laravel Sail
|
# Laravel Sail
|
||||||
|
|
||||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
- Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
|
||||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
|
||||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||||
- Install Composer packages: `vendor/bin/sail composer install`
|
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@ -631,43 +684,53 @@ ### Database
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### Model Creation
|
||||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||||
|
|
||||||
### APIs & Eloquent Resources
|
### APIs & Eloquent Resources
|
||||||
|
|
||||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
### Controllers & Validation
|
## Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
## Authentication & Authorization
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
## URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
## Queues
|
||||||
|
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
### Testing
|
## Testing
|
||||||
|
|
||||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
### Vite Error
|
## Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
# Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version-specific documentation.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
## Laravel 12 Structure
|
||||||
|
|
||||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
@ -675,224 +738,39 @@ ### Laravel 12 Structure
|
|||||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
## Database
|
||||||
|
|
||||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
=== livewire/core rules ===
|
|
||||||
|
|
||||||
## Livewire
|
|
||||||
|
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
|
||||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Livewire Best Practices
|
|
||||||
- Livewire components require a single root element.
|
|
||||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
|
||||||
- Add `wire:key` in loops:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@foreach ($items as $item)
|
|
||||||
<div wire:key="item-{{ $item->id }}">
|
|
||||||
{{ $item->name }}
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
```
|
|
||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
|
||||||
|
|
||||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
|
||||||
public function mount(User $user) { $this->user = $user; }
|
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
## Testing Livewire
|
|
||||||
|
|
||||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('count', 0)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 1)
|
|
||||||
->assertSee(1)
|
|
||||||
->assertStatus(200);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
|
||||||
->assertSeeLivewire(CreatePost::class);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
### Testing
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
|
||||||
|
|
||||||
### Pest Tests
|
- This project uses Pest for testing. Create tests: `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}`.
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
|
||||||
- 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.
|
- Do NOT delete tests without approval.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||||
- Pest tests look and behave like this:
|
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
|
||||||
it('is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
|
||||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
|
||||||
- To run all tests 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
|
|
||||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
|
||||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
|
||||||
it('returns all', function () {
|
|
||||||
$response = $this->postJson('/api/docs', []);
|
|
||||||
|
|
||||||
$response->assertSuccessful();
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Mocking
|
|
||||||
- Mocking can be very helpful when appropriate.
|
|
||||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
|
||||||
- You can also create partial mocks using the same import or self method.
|
|
||||||
|
|
||||||
### Datasets
|
|
||||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
|
||||||
|
|
||||||
<code-snippet name="Pest Dataset Example" lang="php">
|
|
||||||
it('has emails', function (string $email) {
|
|
||||||
expect($email)->not->toBeEmpty();
|
|
||||||
})->with([
|
|
||||||
'james' => 'james@laravel.com',
|
|
||||||
'taylor' => 'taylor@laravel.com',
|
|
||||||
]);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== pest/v4 rules ===
|
|
||||||
|
|
||||||
## Pest 4
|
|
||||||
|
|
||||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
|
||||||
- Browser testing is incredibly powerful and useful for this project.
|
|
||||||
- Browser tests should live in `tests/Browser/`.
|
|
||||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
|
||||||
|
|
||||||
### Browser Testing
|
|
||||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
|
||||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
|
||||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
|
||||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
|
||||||
- Switch color schemes (light/dark mode) when appropriate.
|
|
||||||
- Take screenshots or pause tests for debugging when appropriate.
|
|
||||||
|
|
||||||
### Example Tests
|
|
||||||
|
|
||||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
|
||||||
it('may reset the password', function () {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$page = visit('/sign-in'); // Visit on a real browser...
|
|
||||||
|
|
||||||
$page->assertSee('Sign In')
|
|
||||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
|
||||||
->click('Forgot Password?')
|
|
||||||
->fill('email', 'nuno@laravel.com')
|
|
||||||
->click('Send Reset Link')
|
|
||||||
->assertSee('We have emailed your password reset link!')
|
|
||||||
|
|
||||||
Notification::assertSent(ResetPassword::class);
|
|
||||||
});
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
|
||||||
$pages = visit(['/', '/about', '/contact']);
|
|
||||||
|
|
||||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind CSS
|
# Tailwind CSS
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
- When listing items, use gap utilities for spacing; don't use margins.
|
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Superior</div>
|
|
||||||
<div>Michigan</div>
|
|
||||||
<div>Erie</div>
|
|
||||||
</div>
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
|
||||||
|
|
||||||
=== tailwindcss/v4 rules ===
|
|
||||||
|
|
||||||
## Tailwind CSS 4
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
|
||||||
|
|
||||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
|
||||||
- Opacity values are still numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------+--------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|||||||
43
README.md
43
README.md
@ -9,11 +9,18 @@
|
|||||||
|
|
||||||
## TenantPilot setup
|
## TenantPilot setup
|
||||||
|
|
||||||
- Local dev (Sail-first):
|
- Platform app root: `apps/platform`
|
||||||
- Start stack: `./vendor/bin/sail up -d`
|
- Repo-root ownership: specs, docs, scripts, editor config, agent config, orchestration, and `docker-compose.yml`
|
||||||
- Init DB: `./vendor/bin/sail artisan migrate --seed`
|
- App-root ownership: Laravel runtime, tests, Vite assets, public entrypoints, `composer.json`, `package.json`, `drizzle.config.ts`, and app-local `.env*`
|
||||||
- Tests: `./vendor/bin/sail artisan test`
|
- Local dev (Sail-first, canonical workflow):
|
||||||
- Policy sync: `./vendor/bin/sail artisan intune:sync-policies`
|
- Install: `cd apps/platform && composer install`
|
||||||
|
- Env bootstrap: `cd apps/platform && cp .env.example .env`
|
||||||
|
- Start stack: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Generate app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||||
|
- Init DB: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||||
|
- Tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
- Policy sync: `cd apps/platform && ./vendor/bin/sail artisan intune:sync-policies`
|
||||||
|
- Compatibility helper for tooling that cannot set a nested working directory: `./scripts/platform-sail ...`
|
||||||
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
||||||
- Microsoft Graph (Intune) env vars:
|
- Microsoft Graph (Intune) env vars:
|
||||||
- `GRAPH_TENANT_ID`
|
- `GRAPH_TENANT_ID`
|
||||||
@ -25,10 +32,17 @@ ## TenantPilot setup
|
|||||||
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||||
- Deployment (Dokploy, staging → production):
|
- Deployment (Dokploy, staging → production):
|
||||||
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
||||||
|
- Run application commands from `apps/platform`, including `php artisan filament:assets`.
|
||||||
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
||||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||||
- Keep secrets/env in Dokploy, never in code.
|
- Keep secrets/env in Dokploy, never in code.
|
||||||
|
|
||||||
|
## Platform relocation rollout notes
|
||||||
|
|
||||||
|
- Open branches that still touch legacy root app paths should merge `dev` first, then remap file moves from `app/`, `bootstrap/`, `config/`, `database/`, `lang/`, `public/`, `resources/`, `routes/`, `storage/`, and `tests/` into `apps/platform/...`.
|
||||||
|
- Keep using merge-based catch-up on shared feature branches; do not rebase long-lived shared branches just to absorb the relocation.
|
||||||
|
- VS Code tasks and MCP launchers now delegate through `./scripts/platform-sail` from the repo root. Human-facing docs remain `apps/platform`-first.
|
||||||
|
|
||||||
## Bulk operations (Feature 005)
|
## Bulk operations (Feature 005)
|
||||||
|
|
||||||
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||||
@ -39,8 +53,23 @@ ### Troubleshooting
|
|||||||
|
|
||||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
- Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
|
||||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||||
|
- **Moved app but old commands still fail** usually means the command is still being run from repo root. Switch to `cd apps/platform && ...` or use `./scripts/platform-sail ...` only for tooling that cannot set `cwd`.
|
||||||
|
|
||||||
|
## Rollback checklist
|
||||||
|
|
||||||
|
1. Revert the relocation commit or merge on your feature branch instead of hard-resetting shared history.
|
||||||
|
2. Preserve any local app env overrides before switching commits: `cp apps/platform/.env /tmp/tenantatlas.platform.env.backup` if needed.
|
||||||
|
3. Stop local containers and clean generated artifacts: `cd apps/platform && ./vendor/bin/sail down -v`, then remove `apps/platform/vendor`, `apps/platform/node_modules`, `apps/platform/public/build`, and `apps/platform/public/hot` if they need a clean rebuild.
|
||||||
|
4. After rollback, restore the matching env file for the restored topology and rerun the documented setup flow for that commit.
|
||||||
|
5. Notify owners of open feature branches that the topology changed so they can remap outstanding work before the next merge from `dev`.
|
||||||
|
|
||||||
|
## Deployment unknowns
|
||||||
|
|
||||||
|
- Dokploy build context for a repo-root compose file plus an app-root Laravel runtime still needs staging confirmation.
|
||||||
|
- Production web, queue, and scheduler working directories must be verified explicitly after the move; do not assume repo root and app root behave interchangeably.
|
||||||
|
- Any Dokploy volume mounts or storage persistence paths that previously targeted repo-root `storage/` must be reviewed against `apps/platform/storage/`.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@ -64,7 +93,7 @@ ## Graph Contract Registry & Drift Guard
|
|||||||
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
||||||
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
- Derived @odata.type values within the family are accepted for preview/restore routing.
|
||||||
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
|
||||||
- Drift check: `php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
- Drift check: `cd apps/platform && php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
||||||
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
||||||
|
|
||||||
## Policy Settings Display
|
## Policy Settings Display
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
|
||||||
{--tenant=* : Limit to tenant_id/external_id}
|
|
||||||
{--older-than=5 : Only reconcile runs older than N minutes}
|
|
||||||
{--dry-run : Do not write changes}';
|
|
||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
$query = OperationRun::query()
|
|
||||||
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
|
||||||
->whereIn('status', ['queued', 'running']);
|
|
||||||
|
|
||||||
if ($olderThanMinutes > 0) {
|
|
||||||
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenantIdentifiers !== []) {
|
|
||||||
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
$this->info('No tenants matched the provided identifiers.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->whereIn('tenant_id', $tenantIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
foreach ($query->cursor() as $operationRun) {
|
|
||||||
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
|
|
||||||
|
|
||||||
if (! is_numeric($backupScheduleRunId)) {
|
|
||||||
$skipped++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduleRun = BackupScheduleRun::query()
|
|
||||||
->whereKey((int) $backupScheduleRunId)
|
|
||||||
->where('tenant_id', $operationRun->tenant_id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $scheduleRun) {
|
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'backup_schedule_run.not_found',
|
|
||||||
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failed++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
|
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->updateRun($operationRun, 'running', 'pending');
|
|
||||||
|
|
||||||
if ($scheduleRun->started_at) {
|
|
||||||
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$outcome = match ($scheduleRun->status) {
|
|
||||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
|
||||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
|
||||||
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
|
|
||||||
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
|
||||||
default => 'failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
|
||||||
$syncFailures = $summary['sync_failures'] ?? [];
|
|
||||||
|
|
||||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
|
||||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
|
||||||
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
|
||||||
|
|
||||||
$processed = $policiesBackedUp + $syncFailuresCount;
|
|
||||||
if ($policiesTotal > 0) {
|
|
||||||
$processed = min($policiesTotal, $processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
$summaryCounts = array_filter([
|
|
||||||
'total' => $policiesTotal,
|
|
||||||
'processed' => $processed,
|
|
||||||
'succeeded' => $policiesBackedUp,
|
|
||||||
'failed' => $syncFailuresCount,
|
|
||||||
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
|
|
||||||
'items' => $policiesTotal,
|
|
||||||
], fn (mixed $value): bool => is_int($value) && $value !== 0);
|
|
||||||
|
|
||||||
$failures = [];
|
|
||||||
|
|
||||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
|
|
||||||
$failures[] = [
|
|
||||||
'code' => 'backup_schedule_run.cancelled',
|
|
||||||
'message' => 'Backup schedule run was cancelled.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
|
||||||
$failures[] = [
|
|
||||||
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
|
|
||||||
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($syncFailures)) {
|
|
||||||
foreach ($syncFailures as $failure) {
|
|
||||||
if (! is_array($failure)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
|
||||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
|
||||||
$errors = $failure['errors'] ?? null;
|
|
||||||
|
|
||||||
$firstErrorMessage = null;
|
|
||||||
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
|
||||||
$firstErrorMessage = $errors[0]['message'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = $status !== null
|
|
||||||
? "{$policyType}: Graph returned {$status}"
|
|
||||||
: "{$policyType}: Graph request failed";
|
|
||||||
|
|
||||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
|
||||||
$message .= ' - '.trim($firstErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failures[] = [
|
|
||||||
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
|
|
||||||
'message' => RunFailureSanitizer::sanitizeMessage($message),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRun->update([
|
|
||||||
'context' => array_merge($operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
|
||||||
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: $outcome,
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
failures: $failures,
|
|
||||||
);
|
|
||||||
|
|
||||||
$operationRun->forceFill([
|
|
||||||
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
|
|
||||||
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Reconciled %d run(s), skipped %d, failed %d.',
|
|
||||||
$reconciled,
|
|
||||||
$skipped,
|
|
||||||
$failed,
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->comment('Dry-run: no changes written.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $tenantIdentifiers
|
|
||||||
* @return array<int>
|
|
||||||
*/
|
|
||||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
|
||||||
{
|
|
||||||
$tenantIds = [];
|
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
|
||||||
$tenant = Tenant::query()
|
|
||||||
->forTenant($identifier)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
$tenantIds[] = (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($tenantIds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserTenantPreference;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
class ChooseTenant extends Page
|
|
||||||
{
|
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'choose-tenant';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Choose tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.choose-tenant';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function getTenants(): Collection
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
|
||||||
|
|
||||||
if ($tenants instanceof Collection) {
|
|
||||||
return $tenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($tenants);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectTenant(int $tenantId): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->whereKey($tenantId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
|
||||||
{
|
|
||||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
|
||||||
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Schema::hasTable('user_tenant_preferences')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserTenantPreference::query()->updateOrCreate(
|
|
||||||
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
|
||||||
['last_used_at' => now()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
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\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
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 = 'Drift';
|
|
||||||
|
|
||||||
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 = InventorySyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
|
||||||
->whereNotNull('finished_at')
|
|
||||||
->orderByDesc('finished_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
|
||||||
$this->state = 'blocked';
|
|
||||||
$this->message = 'No successful inventory runs found yet.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
|
||||||
$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->finished_at?->toDateTimeString();
|
|
||||||
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$existingOperationRun = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'drift.generate')
|
|
||||||
->where('context->scope_key', $scopeKey)
|
|
||||||
->where('context->baseline_run_id', (int) $baseline->getKey())
|
|
||||||
->where('context->current_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_run_id', $baseline->getKey())
|
|
||||||
->where('current_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_run_id', $baseline->getKey())
|
|
||||||
->where('current_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_run_id' => (int) $baseline->getKey(),
|
|
||||||
'current_run_id' => (int) $current->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $opService->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'drift.generate',
|
|
||||||
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_run_id' => (int) $baseline->getKey(),
|
|
||||||
'current_run_id' => (int) $current->getKey(),
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->operationRunId = (int) $opRun->getKey();
|
|
||||||
$this->state = 'generating';
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Drift generation already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->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 InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCurrentRunUrl(): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($this->currentRunId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOperationRunUrl(): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($this->operationRunId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
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';
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class InventoryLanding extends Page
|
|
||||||
{
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Overview';
|
|
||||||
|
|
||||||
protected static ?string $cluster = InventoryCluster::class;
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-landing';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
InventoryKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\ManagedTenants\ManagedTenantContext;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
|
|
||||||
class ArchivedStatus extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants/archived';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Archived managed tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.archived-status';
|
|
||||||
|
|
||||||
public ?Tenant $tenant = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->tenantMemberships()->exists()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tenant = ManagedTenantContext::archivedTenant();
|
|
||||||
|
|
||||||
if (! $this->tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $this->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('back_to_managed_tenants')
|
|
||||||
->label('Back to managed tenants')
|
|
||||||
->url(Index::getUrl()),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('restore')
|
|
||||||
->label('Restore')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->action(function (): void {
|
|
||||||
$tenant = $this->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->restore();
|
|
||||||
$tenant->refresh();
|
|
||||||
|
|
||||||
ManagedTenantContext::setCurrentTenant($tenant);
|
|
||||||
ManagedTenantContext::clearArchivedTenant();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Managed tenant restored')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->redirect(Current::getUrl());
|
|
||||||
}),
|
|
||||||
fn () => $this->tenant,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('force_delete')
|
|
||||||
->label('Force delete')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->action(function (): void {
|
|
||||||
$tenant = $this->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->forceDelete();
|
|
||||||
|
|
||||||
ManagedTenantContext::clear();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Managed tenant deleted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->redirect(Index::getUrl());
|
|
||||||
}),
|
|
||||||
fn () => $this->tenant,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\ManagedTenants\ManagedTenantContext;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
|
|
||||||
class Current extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants/current';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Current managed tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.current';
|
|
||||||
|
|
||||||
public ?Tenant $tenant = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->tenantMemberships()->exists()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentTenantId = ManagedTenantContext::currentTenantId();
|
|
||||||
|
|
||||||
if (is_int($currentTenantId)) {
|
|
||||||
$selectedTenant = Tenant::withTrashed()->find($currentTenantId);
|
|
||||||
|
|
||||||
if (! $selectedTenant instanceof Tenant) {
|
|
||||||
ManagedTenantContext::clearCurrentTenant();
|
|
||||||
} elseif (! $selectedTenant->isActive()) {
|
|
||||||
ManagedTenantContext::clearCurrentTenant();
|
|
||||||
ManagedTenantContext::setArchivedTenant($selectedTenant);
|
|
||||||
|
|
||||||
$this->redirect(ArchivedStatus::getUrl());
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
$this->tenant = $selectedTenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
$canViewAny = Tenant::query()
|
|
||||||
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
|
|
||||||
->cursor()
|
|
||||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
|
|
||||||
|
|
||||||
if (! $canViewAny) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $this->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The active status is already verified above.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Actions\Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('back_to_managed_tenants')
|
|
||||||
->label('Back to managed tenants')
|
|
||||||
->url(Index::getUrl()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class EditManagedTenant extends Page implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants/{managedTenant}/edit';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Edit managed tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.edit';
|
|
||||||
|
|
||||||
public Tenant $tenant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
public array $data = [];
|
|
||||||
|
|
||||||
public function mount(string $managedTenant): void
|
|
||||||
{
|
|
||||||
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $this->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenant->isActive()) {
|
|
||||||
\App\Support\ManagedTenants\ManagedTenantContext::setArchivedTenant($this->tenant);
|
|
||||||
|
|
||||||
$this->redirect(ArchivedStatus::getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->form->fill([
|
|
||||||
'name' => $this->tenant->name,
|
|
||||||
'environment' => $this->tenant->environment,
|
|
||||||
'tenant_id' => $this->tenant->tenant_id,
|
|
||||||
'domain' => $this->tenant->domain,
|
|
||||||
'app_client_id' => $this->tenant->app_client_id,
|
|
||||||
'app_client_secret' => null,
|
|
||||||
'app_certificate_thumbprint' => $this->tenant->app_certificate_thumbprint,
|
|
||||||
'app_notes' => $this->tenant->app_notes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Forms\Components\TextInput::make('name')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\Select::make('environment')
|
|
||||||
->options([
|
|
||||||
'prod' => 'PROD',
|
|
||||||
'dev' => 'DEV',
|
|
||||||
'staging' => 'STAGING',
|
|
||||||
'other' => 'Other',
|
|
||||||
])
|
|
||||||
->default('other')
|
|
||||||
->required(),
|
|
||||||
Forms\Components\TextInput::make('tenant_id')
|
|
||||||
->label('Tenant ID (GUID)')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('domain')
|
|
||||||
->label('Primary domain')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('app_client_id')
|
|
||||||
->label('App Client ID')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('app_client_secret')
|
|
||||||
->label('App Client Secret')
|
|
||||||
->password()
|
|
||||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
|
||||||
->dehydrated(fn ($state) => filled($state)),
|
|
||||||
Forms\Components\TextInput::make('app_certificate_thumbprint')
|
|
||||||
->label('Certificate thumbprint')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\Textarea::make('app_notes')
|
|
||||||
->label('Notes')
|
|
||||||
->rows(3),
|
|
||||||
])
|
|
||||||
->statePath('data');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $this->form->getState();
|
|
||||||
|
|
||||||
$this->tenant->fill($data);
|
|
||||||
$this->tenant->save();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Managed tenant updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->redirect(ViewManagedTenant::getUrl(['tenant' => $this->tenant]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class Index extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = true;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Managed tenants';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Managed tenants';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.index';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->tenantMemberships()->exists()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
$canViewAny = Tenant::query()
|
|
||||||
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
|
|
||||||
->cursor()
|
|
||||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
|
|
||||||
|
|
||||||
if (! $canViewAny) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('add_managed_tenant')
|
|
||||||
->label('Add managed tenant')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->url('/admin/managed-tenants/onboarding'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query($this->managedTenantsQuery())
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('name')->searchable(),
|
|
||||||
TextColumn::make('tenant_id')->label('Tenant ID')->copyable()->searchable(),
|
|
||||||
TextColumn::make('environment')->badge()->sortable(),
|
|
||||||
TextColumn::make('status')->badge()->sortable(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('open')
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open"),
|
|
||||||
fn () => Filament::getTenant(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->url(fn (Tenant $record): string => ViewManagedTenant::getUrl(['managedTenant' => $record])),
|
|
||||||
fn () => Filament::getTenant(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->url(fn (Tenant $record): string => EditManagedTenant::getUrl(['managedTenant' => $record])),
|
|
||||||
fn () => Filament::getTenant(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No managed tenants')
|
|
||||||
->emptyStateDescription('Add your first managed tenant to begin onboarding.')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('empty_add_managed_tenant')
|
|
||||||
->label('Add managed tenant')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->url('/admin/managed-tenants/onboarding'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function managedTenantsQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return Tenant::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = $user->tenants()
|
|
||||||
->withTrashed()
|
|
||||||
->pluck('tenants.id');
|
|
||||||
|
|
||||||
return Tenant::query()
|
|
||||||
->withTrashed()
|
|
||||||
->whereIn('id', $tenantIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class Onboarding extends Page implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants/onboarding';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Add managed tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.onboarding';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
public array $data = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
static::abortIfNonMember();
|
|
||||||
|
|
||||||
if (! static::canView()) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->form->fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
|
||||||
|
|
||||||
if ($tenantIds->isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
|
||||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_CREATE)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Forms\Components\TextInput::make('name')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\Select::make('environment')
|
|
||||||
->options([
|
|
||||||
'prod' => 'PROD',
|
|
||||||
'dev' => 'DEV',
|
|
||||||
'staging' => 'STAGING',
|
|
||||||
'other' => 'Other',
|
|
||||||
])
|
|
||||||
->default('other')
|
|
||||||
->required(),
|
|
||||||
Forms\Components\TextInput::make('tenant_id')
|
|
||||||
->label('Tenant ID (GUID)')
|
|
||||||
->required()
|
|
||||||
->maxLength(255)
|
|
||||||
->unique(ignoreRecord: true),
|
|
||||||
Forms\Components\TextInput::make('domain')
|
|
||||||
->label('Primary domain')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('app_client_id')
|
|
||||||
->label('App Client ID')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('app_client_secret')
|
|
||||||
->label('App Client Secret')
|
|
||||||
->password()
|
|
||||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
|
||||||
->dehydrated(fn ($state) => filled($state)),
|
|
||||||
Forms\Components\TextInput::make('app_certificate_thumbprint')
|
|
||||||
->label('Certificate thumbprint')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\Textarea::make('app_notes')
|
|
||||||
->label('Notes')
|
|
||||||
->rows(3),
|
|
||||||
])
|
|
||||||
->statePath('data');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(AuditLogger $auditLogger): void
|
|
||||||
{
|
|
||||||
static::abortIfNonMember();
|
|
||||||
|
|
||||||
if (! static::canView()) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $this->form->getState();
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->create($data);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if ($user instanceof User) {
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenant->getKey() => [
|
|
||||||
'role' => 'owner',
|
|
||||||
'source' => 'manual',
|
|
||||||
'created_by_user_id' => $user->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'managed_tenant.onboarding.created',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'tenant_guid' => (string) $tenant->tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: (int) $user->getKey(),
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
status: 'success',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->getKey(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Managed tenant added')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function abortIfNonMember(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->tenantMemberships()->exists()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\ManagedTenants;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\ManagedTenants\ManagedTenantContext;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class ViewManagedTenant extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'managed-tenants/{managedTenant}';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Managed tenant';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.managed-tenants.view';
|
|
||||||
|
|
||||||
public Tenant $tenant;
|
|
||||||
|
|
||||||
public function mount(string $managedTenant): void
|
|
||||||
{
|
|
||||||
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $this->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenant->isActive()) {
|
|
||||||
ManagedTenantContext::setArchivedTenant($this->tenant);
|
|
||||||
|
|
||||||
$this->redirect(ArchivedStatus::getUrl());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('open')
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (): string => "/admin/managed-tenants/{$this->tenant->getKey()}/open"),
|
|
||||||
fn () => $this->tenant,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->url(fn (): string => EditManagedTenant::getUrl(['managedTenant' => $this->tenant])),
|
|
||||||
fn () => $this->tenant,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class Operations extends Page implements HasForms, HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
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 table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(
|
|
||||||
OperationRun::query()
|
|
||||||
->where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->latest('created_at')
|
|
||||||
)
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('type')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
|
|
||||||
TextColumn::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->searchable(),
|
|
||||||
|
|
||||||
TextColumn::make('created_at')
|
|
||||||
->dateTime()
|
|
||||||
->sortable()
|
|
||||||
->label('Started'),
|
|
||||||
|
|
||||||
TextColumn::make('duration')
|
|
||||||
->getStateUsing(function (OperationRun $record) {
|
|
||||||
if ($record->started_at && $record->completed_at) {
|
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '-';
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('outcome')
|
|
||||||
->options([
|
|
||||||
'succeeded' => 'Succeeded',
|
|
||||||
'partially_succeeded' => 'Partially Succeeded',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'cancelled' => 'Cancelled',
|
|
||||||
'pending' => 'Pending',
|
|
||||||
]),
|
|
||||||
|
|
||||||
SelectFilter::make('type')
|
|
||||||
->options(
|
|
||||||
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->distinct()
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->toArray()
|
|
||||||
),
|
|
||||||
|
|
||||||
Filter::make('created_at')
|
|
||||||
->form([
|
|
||||||
DatePicker::make('created_from'),
|
|
||||||
DatePicker::make('created_until'),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
return $query
|
|
||||||
->when(
|
|
||||||
$data['created_from'],
|
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$data['created_until'],
|
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
// View action handled by opening a modal or side-peek
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
|
||||||
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
|
|
||||||
class NoAccess extends Page
|
|
||||||
{
|
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'no-access';
|
|
||||||
|
|
||||||
protected static ?string $title = 'No access';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.no-access';
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?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 [
|
|
||||||
Actions\CreateAction::make(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class BackupScheduleRunsRelationManager extends RelationManager
|
|
||||||
{
|
|
||||||
protected static string $relationship = 'runs';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
|
||||||
->defaultSort('scheduled_for', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('scheduled_for')
|
|
||||||
->label('Scheduled for')
|
|
||||||
->dateTime(),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('duration')
|
|
||||||
->label('Duration')
|
|
||||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
|
||||||
if (! $record->started_at || ! $record->finished_at) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
|
|
||||||
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return $seconds.'s';
|
|
||||||
}
|
|
||||||
|
|
||||||
$minutes = intdiv($seconds, 60);
|
|
||||||
$rem = $seconds % 60;
|
|
||||||
|
|
||||||
return sprintf('%dm %ds', $minutes, $rem);
|
|
||||||
}),
|
|
||||||
Tables\Columns\TextColumn::make('counts')
|
|
||||||
->label('Counts')
|
|
||||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
|
||||||
|
|
||||||
$total = (int) ($summary['policies_total'] ?? 0);
|
|
||||||
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
|
||||||
$errors = (int) ($summary['errors_count'] ?? 0);
|
|
||||||
|
|
||||||
if ($total === 0 && $backedUp === 0 && $errors === 0) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
|
|
||||||
}),
|
|
||||||
Tables\Columns\TextColumn::make('error_code')
|
|
||||||
->label('Error')
|
|
||||||
->badge()
|
|
||||||
->default('—'),
|
|
||||||
Tables\Columns\TextColumn::make('error_message')
|
|
||||||
->label('Message')
|
|
||||||
->default('—')
|
|
||||||
->limit(80)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('backup_set_id')
|
|
||||||
->label('Backup set')
|
|
||||||
->default('—')
|
|
||||||
->url(function (BackupScheduleRun $record): ?string {
|
|
||||||
if (! $record->backup_set_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
|
|
||||||
})
|
|
||||||
->openUrlInNewTab(true),
|
|
||||||
])
|
|
||||||
->filters([])
|
|
||||||
->headerActions([])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->modalHeading('View backup schedule run')
|
|
||||||
->modalSubmitAction(false)
|
|
||||||
->modalCancelActionLabel('Close')
|
|
||||||
->modalContent(function (BackupScheduleRun $record): View {
|
|
||||||
return view('filament.modals.backup-schedule-run-view', [
|
|
||||||
'run' => $record,
|
|
||||||
]);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListBackupSets extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewBackupSet extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
|
||||||
}
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
<?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 BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
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 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));
|
|
||||||
})
|
|
||||||
->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')->since()->label('Last seen'),
|
|
||||||
])
|
|
||||||
->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([
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
||||||
use App\Jobs\EntraGroupSyncJob;
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('view_group_sync_runs')
|
|
||||||
->label('Group Sync Runs')
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
|
||||||
->visible(fn (): bool => (bool) Tenant::current()),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('sync_groups')
|
|
||||||
->label('Sync Groups')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
|
||||||
|
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'directory_groups.sync',
|
|
||||||
inputs: ['selection_key' => $selectionKey],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Group sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ----------------------------------------------
|
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_key', $selectionKey)
|
|
||||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing instanceof EntraGroupSyncRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Group sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = EntraGroupSyncRun::query()->create([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'selection_key' => $selectionKey,
|
|
||||||
'slot_key' => null,
|
|
||||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
|
||||||
'initiator_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
selectionKey: $selectionKey,
|
|
||||||
slotKey: null,
|
|
||||||
runId: (int) $run->getKey(),
|
|
||||||
operationRun: $opRun
|
|
||||||
));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Group sync started')
|
|
||||||
->body('Sync dispatched.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->tooltip('You do not have permission to sync groups.')
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewEntraGroup extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupResource::class;
|
|
||||||
}
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EntraGroupSyncRunResource extends Resource
|
|
||||||
{
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = EntraGroupSyncRun::class;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Group Sync Runs';
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Legacy run view')
|
|
||||||
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('canonical_view')
|
|
||||||
->label('Canonical view')
|
|
||||||
->state('View in Operations')
|
|
||||||
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
|
||||||
->badge()
|
|
||||||
->color('primary'),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Sync Run')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('initiator.name')
|
|
||||||
->label('Initiator')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
|
||||||
TextEntry::make('selection_key')->label('Selection'),
|
|
||||||
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
|
|
||||||
TextEntry::make('started_at')->dateTime(),
|
|
||||||
TextEntry::make('finished_at')->dateTime(),
|
|
||||||
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
|
|
||||||
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
|
||||||
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
|
||||||
TextEntry::make('error_count')->label('Errors')->numeric(),
|
|
||||||
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
|
|
||||||
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Error Summary')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('error_code')->placeholder('—'),
|
|
||||||
TextEntry::make('error_category')->placeholder('—'),
|
|
||||||
ViewEntry::make('error_summary')
|
|
||||||
->label('Safe error summary')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('id', 'desc')
|
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
|
||||||
})
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('initiator.name')
|
|
||||||
->label('Initiator')
|
|
||||||
->placeholder('—')
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('selection_key')
|
|
||||||
->label('Selection')
|
|
||||||
->limit(24)
|
|
||||||
->copyable(),
|
|
||||||
Tables\Columns\TextColumn::make('slot_key')
|
|
||||||
->label('Slot')
|
|
||||||
->placeholder('—')
|
|
||||||
->limit(16)
|
|
||||||
->copyable(),
|
|
||||||
Tables\Columns\TextColumn::make('started_at')->since(),
|
|
||||||
Tables\Columns\TextColumn::make('finished_at')->since(),
|
|
||||||
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
|
|
||||||
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
|
|
||||||
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
|
||||||
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('initiator')
|
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
|
|
||||||
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
||||||
use App\Jobs\EntraGroupSyncJob;
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListEntraGroupSyncRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('sync_groups')
|
|
||||||
->label('Sync Groups')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_key', $selectionKey)
|
|
||||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing instanceof EntraGroupSyncRun) {
|
|
||||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'run_type' => 'directory_groups',
|
|
||||||
'run_id' => (int) $existing->getKey(),
|
|
||||||
'status' => $normalizedStatus,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = EntraGroupSyncRun::query()->create([
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'selection_key' => $selectionKey,
|
|
||||||
'slot_key' => null,
|
|
||||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
|
||||||
'initiator_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
selectionKey: $selectionKey,
|
|
||||||
slotKey: null,
|
|
||||||
runId: (int) $run->getKey(),
|
|
||||||
));
|
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'run_type' => 'directory_groups',
|
|
||||||
'run_id' => (int) $run->getKey(),
|
|
||||||
'status' => 'queued',
|
|
||||||
]));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewEntraGroupSyncRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
|
||||||
}
|
|
||||||
@ -1,445 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\PolicyVersion;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\BulkAction;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Finding::class;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Findings';
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record instanceof Finding) {
|
|
||||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Finding')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
|
||||||
TextEntry::make('severity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
||||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
|
||||||
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
|
||||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
||||||
TextEntry::make('subject_type')->label('Subject type'),
|
|
||||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
|
||||||
TextEntry::make('baseline_run_id')
|
|
||||||
->label('Baseline run')
|
|
||||||
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
|
||||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
|
||||||
: null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('current_run_id')
|
|
||||||
->label('Current run')
|
|
||||||
->url(fn (Finding $record): ?string => $record->current_run_id
|
|
||||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
|
||||||
: null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
|
||||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Diff')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('settings_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.normalized-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
|
||||||
|
|
||||||
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
|
||||||
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
|
||||||
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
|
||||||
|
|
||||||
if (($addedCount + $removedCount + $changedCount) === 0) {
|
|
||||||
Arr::set(
|
|
||||||
$diff,
|
|
||||||
'summary.message',
|
|
||||||
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $diff;
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
ViewEntry::make('scope_tags_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.scope-tags-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
ViewEntry::make('assignments_diff')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.assignments-diff')
|
|
||||||
->state(function (Finding $record): array {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant) {
|
|
||||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
|
||||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
|
||||||
|
|
||||||
$baselineVersion = is_numeric($baselineId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$currentVersion = is_numeric($currentId)
|
|
||||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
|
||||||
})
|
|
||||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->collapsed()
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Evidence (Sanitized)')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('evidence_jsonb')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('severity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
||||||
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
Finding::STATUS_NEW => 'New',
|
|
||||||
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
|
||||||
])
|
|
||||||
->default(Finding::STATUS_NEW),
|
|
||||||
Tables\Filters\SelectFilter::make('finding_type')
|
|
||||||
->options([
|
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
|
||||||
])
|
|
||||||
->default(Finding::FINDING_TYPE_DRIFT),
|
|
||||||
Tables\Filters\Filter::make('scope_key')
|
|
||||||
->form([
|
|
||||||
TextInput::make('scope_key')
|
|
||||||
->label('Scope key')
|
|
||||||
->placeholder('Inventory selection hash')
|
|
||||||
->maxLength(255),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$scopeKey = $data['scope_key'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($scopeKey) || $scopeKey === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('scope_key', $scopeKey);
|
|
||||||
}),
|
|
||||||
Tables\Filters\Filter::make('run_ids')
|
|
||||||
->label('Run IDs')
|
|
||||||
->form([
|
|
||||||
TextInput::make('baseline_run_id')
|
|
||||||
->label('Baseline run id')
|
|
||||||
->numeric(),
|
|
||||||
TextInput::make('current_run_id')
|
|
||||||
->label('Current run id')
|
|
||||||
->numeric(),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$baselineRunId = $data['baseline_run_id'] ?? null;
|
|
||||||
if (is_numeric($baselineRunId)) {
|
|
||||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRunId = $data['current_run_id'] ?? null;
|
|
||||||
if (is_numeric($currentRunId)) {
|
|
||||||
$query->where('current_run_id', (int) $currentRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('acknowledge')
|
|
||||||
->label('Acknowledge')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
|
||||||
->authorize(function (Finding $record): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can('update', $record);
|
|
||||||
})
|
|
||||||
->action(function (Finding $record): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Finding belongs to a different tenant')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->acknowledge($user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Finding acknowledged')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([
|
|
||||||
UiEnforcement::forBulkAction(
|
|
||||||
BulkAction::make('acknowledge_selected')
|
|
||||||
->label('Acknowledge selected')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Collection $records): void {
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgedCount = 0;
|
|
||||||
$skippedCount = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
if (! $record instanceof Finding) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->status !== Finding::STATUS_NEW) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->acknowledge($user);
|
|
||||||
$acknowledgedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
|
||||||
if ($skippedCount > 0) {
|
|
||||||
$body .= " Skipped {$skippedCount}.";
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk acknowledge completed')
|
|
||||||
->body($body)
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->addSelect([
|
|
||||||
'subject_display_name' => InventoryItem::query()
|
|
||||||
->select('display_name')
|
|
||||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
|
||||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
|
||||||
->limit(1),
|
|
||||||
])
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListFindings::route('/'),
|
|
||||||
'view' => Pages\ViewFinding::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('acknowledge_all_matching')
|
|
||||||
->label('Acknowledge all matching')
|
|
||||||
->icon('heroicon-o-check')
|
|
||||||
->color('gray')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
|
||||||
->modalDescription(function (): string {
|
|
||||||
$count = $this->getAllMatchingCount();
|
|
||||||
|
|
||||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
|
||||||
})
|
|
||||||
->form(function (): array {
|
|
||||||
$count = $this->getAllMatchingCount();
|
|
||||||
|
|
||||||
if ($count <= 100) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
TextInput::make('confirmation')
|
|
||||||
->label('Type ACKNOWLEDGE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['ACKNOWLEDGE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->action(function (array $data): void {
|
|
||||||
$query = $this->buildAllMatchingQuery();
|
|
||||||
$count = (clone $query)->count();
|
|
||||||
|
|
||||||
if ($count === 0) {
|
|
||||||
Notification::make()
|
|
||||||
->title('No matching findings')
|
|
||||||
->body('There are no new findings matching the current filters.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $query->update([
|
|
||||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->deselectAllTableRecords();
|
|
||||||
$this->resetPage();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk acknowledge completed')
|
|
||||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
|
||||||
{
|
|
||||||
$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);
|
|
||||||
|
|
||||||
$findingType = $this->getFindingTypeFilterValue();
|
|
||||||
if (is_string($findingType) && $findingType !== '') {
|
|
||||||
$query->where('finding_type', $findingType);
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
|
||||||
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
|
||||||
if (is_string($scopeKey) && $scopeKey !== '') {
|
|
||||||
$query->where('scope_key', $scopeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
|
||||||
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
|
||||||
if (is_numeric($baselineRunId)) {
|
|
||||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
|
||||||
if (is_numeric($currentRunId)) {
|
|
||||||
$query->where('current_run_id', (int) $currentRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getAllMatchingCount(): int
|
|
||||||
{
|
|
||||||
return (int) $this->buildAllMatchingQuery()->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStatusFilterValue(): string
|
|
||||||
{
|
|
||||||
$state = $this->getTableFilterState('status') ?? [];
|
|
||||||
$value = Arr::get($state, 'value');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== ''
|
|
||||||
? $value
|
|
||||||
: Finding::STATUS_NEW;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getFindingTypeFilterValue(): string
|
|
||||||
{
|
|
||||||
$state = $this->getTableFilterState('finding_type') ?? [];
|
|
||||||
$value = Arr::get($state, 'value');
|
|
||||||
|
|
||||||
return is_string($value) && $value !== ''
|
|
||||||
? $value
|
|
||||||
: Finding::FINDING_TYPE_DRIFT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingResource::class;
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class InventorySyncRunResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = InventorySyncRun::class;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = true;
|
|
||||||
|
|
||||||
protected static ?string $cluster = InventoryCluster::class;
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 2;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
|
||||||
|
|
||||||
public static function canViewAny(): 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->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record instanceof InventorySyncRun) {
|
|
||||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return 'Sync History';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Legacy run view')
|
|
||||||
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('canonical_view')
|
|
||||||
->label('Canonical view')
|
|
||||||
->state('View in Operations')
|
|
||||||
->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
|
||||||
->badge()
|
|
||||||
->color('primary'),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Sync Run')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('user.name')
|
|
||||||
->label('Initiator')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
|
||||||
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
|
|
||||||
TextEntry::make('started_at')->dateTime(),
|
|
||||||
TextEntry::make('finished_at')->dateTime(),
|
|
||||||
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
|
||||||
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
|
||||||
TextEntry::make('errors_count')->label('Errors')->numeric(),
|
|
||||||
TextEntry::make('had_errors')
|
|
||||||
->label('Had errors')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Selection Payload')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('selection_payload')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Error Summary')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('error_codes')
|
|
||||||
->label('Error codes')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
ViewEntry::make('error_context')
|
|
||||||
->label('Safe error context')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('id', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
|
||||||
->label('Initiator')
|
|
||||||
->placeholder('—')
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('selection_hash')
|
|
||||||
->label('Selection')
|
|
||||||
->copyable()
|
|
||||||
->limit(12),
|
|
||||||
Tables\Columns\TextColumn::make('started_at')->since(),
|
|
||||||
Tables\Columns\TextColumn::make('finished_at')->since(),
|
|
||||||
Tables\Columns\TextColumn::make('items_observed_count')
|
|
||||||
->label('Observed')
|
|
||||||
->numeric(),
|
|
||||||
Tables\Columns\TextColumn::make('items_upserted_count')
|
|
||||||
->label('Upserted')
|
|
||||||
->numeric(),
|
|
||||||
Tables\Columns\TextColumn::make('errors_count')
|
|
||||||
->label('Errors')
|
|
||||||
->numeric(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('user')
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListInventorySyncRuns::route('/'),
|
|
||||||
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListInventorySyncRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = InventorySyncRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
InventoryKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewInventorySyncRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = InventorySyncRunResource::class;
|
|
||||||
}
|
|
||||||
@ -1,310 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class OperationRunResource extends Resource
|
|
||||||
{
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = OperationRun::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'operations';
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Operations';
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('user')
|
|
||||||
->latest('id')
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
Section::make('Run')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('type')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
TextEntry::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextEntry::make('initiator_name')->label('Initiator'),
|
|
||||||
TextEntry::make('target_scope_display')
|
|
||||||
->label('Target')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
|
||||||
->columnSpanFull(),
|
|
||||||
TextEntry::make('elapsed')
|
|
||||||
->label('Elapsed')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
|
||||||
TextEntry::make('expected_duration')
|
|
||||||
->label('Expected')
|
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
|
|
||||||
TextEntry::make('stuck_guidance')
|
|
||||||
->label('')
|
|
||||||
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
|
|
||||||
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
|
|
||||||
TextEntry::make('created_at')->dateTime(),
|
|
||||||
TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
|
|
||||||
])
|
|
||||||
->extraAttributes([
|
|
||||||
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
|
||||||
])
|
|
||||||
->poll(function (OperationRun $record, $livewire): ?string {
|
|
||||||
if (($livewire->opsUxIsTabHidden ?? false) === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($livewire->mountedActions ?? null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RunDetailPolling::interval($record);
|
|
||||||
})
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Counts')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('summary_counts')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Failures')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('failure_summary')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Context')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('context')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->since()
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('duration')
|
|
||||||
->getStateUsing(function (OperationRun $record): string {
|
|
||||||
if ($record->started_at && $record->completed_at) {
|
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '—';
|
|
||||||
}),
|
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('type')
|
|
||||||
->options(function (): array {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
if (! $tenantId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->select('type')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('type')
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->all();
|
|
||||||
}),
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
|
||||||
OperationRunStatus::Running->value => 'Running',
|
|
||||||
OperationRunStatus::Completed->value => 'Completed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('outcome')
|
|
||||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
|
||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->options(function (): array {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
if (! $tenantId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->whereNotNull('initiator_name')
|
|
||||||
->select('initiator_name')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('initiator_name')
|
|
||||||
->pluck('initiator_name', 'initiator_name')
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
Tables\Filters\Filter::make('created_at')
|
|
||||||
->label('Created')
|
|
||||||
->form([
|
|
||||||
DatePicker::make('created_from')
|
|
||||||
->label('From'),
|
|
||||||
DatePicker::make('created_until')
|
|
||||||
->label('Until'),
|
|
||||||
])
|
|
||||||
->default(fn (): array => [
|
|
||||||
'created_from' => now()->subDays(30),
|
|
||||||
'created_until' => now(),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$from = $data['created_from'] ?? null;
|
|
||||||
if ($from) {
|
|
||||||
$query->whereDate('created_at', '>=', $from);
|
|
||||||
}
|
|
||||||
|
|
||||||
$until = $data['created_until'] ?? null;
|
|
||||||
if ($until) {
|
|
||||||
$query->whereDate('created_at', '<=', $until);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListOperationRuns::route('/'),
|
|
||||||
'view' => Pages\ViewOperationRun::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
|
|
||||||
$targetScope = $context['target_scope'] ?? null;
|
|
||||||
if (! is_array($targetScope)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
|
||||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
|
||||||
|
|
||||||
$entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null;
|
|
||||||
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null;
|
|
||||||
|
|
||||||
$directoryContextId = match (true) {
|
|
||||||
is_string($directoryContextId) => trim($directoryContextId),
|
|
||||||
is_int($directoryContextId) => (string) $directoryContextId,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
$entra = null;
|
|
||||||
|
|
||||||
if ($entraTenantName !== null && $entraTenantName !== '') {
|
|
||||||
$entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName;
|
|
||||||
} elseif ($entraTenantId !== null && $entraTenantId !== '') {
|
|
||||||
$entra = $entraTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = array_values(array_filter([
|
|
||||||
$entra,
|
|
||||||
$directoryContextId ? "directory_context_id: {$directoryContextId}" : null,
|
|
||||||
], fn (?string $value): bool => $value !== null && $value !== ''));
|
|
||||||
|
|
||||||
return $parts !== [] ? implode(' · ', $parts) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ListOperationRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Tab>
|
|
||||||
*/
|
|
||||||
public function getTabs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'all' => Tab::make(),
|
|
||||||
'active' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])),
|
|
||||||
'succeeded' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
|
||||||
'partial' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
|
||||||
'failed' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTablePollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ViewOperationRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRun $run */
|
|
||||||
$run = $this->getRecord();
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($run, $tenant);
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
|
||||||
->label($label)
|
|
||||||
->url($url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($actions)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make($actions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
|
||||||
use App\Jobs\SyncPoliciesJob;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = PolicyResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('sync')
|
|
||||||
->label('Sync from Intune')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->action(function (self $livewire): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestedTypes = array_map(
|
|
||||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
|
||||||
config('tenantpilot.supported_policy_types', [])
|
|
||||||
);
|
|
||||||
|
|
||||||
sort($requestedTypes);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'all',
|
|
||||||
'types' => $requestedTypes,
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
|
|
||||||
});
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
|
||||||
->tooltip('You do not have permission to sync policies.')
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Filament\Support\Enums\Width;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
|
|
||||||
class ViewPolicyVersion extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = PolicyVersionResource::class;
|
|
||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
|
||||||
|
|
||||||
public function getFooter(): ?View
|
|
||||||
{
|
|
||||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
|
||||||
'record' => $this->getRecord(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,658 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Providers\CredentialManager;
|
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
|
||||||
{
|
|
||||||
use ScopesGlobalSearchToTenant;
|
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
|
||||||
|
|
||||||
protected static ?string $model = ProviderConnection::class;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Connections';
|
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'display_name';
|
|
||||||
|
|
||||||
protected static function hasTenantCapability(string $capability): 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, $capability);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
TextInput::make('display_name')
|
|
||||||
->label('Display name')
|
|
||||||
->required()
|
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('entra_tenant_id')
|
|
||||||
->label('Entra tenant ID')
|
|
||||||
->required()
|
|
||||||
->maxLength(255)
|
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
||||||
->rules(['uuid']),
|
|
||||||
Toggle::make('is_default')
|
|
||||||
->label('Default connection')
|
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
||||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
|
||||||
TextInput::make('status')
|
|
||||||
->label('Status')
|
|
||||||
->disabled()
|
|
||||||
->dehydrated(false),
|
|
||||||
TextInput::make('health_status')
|
|
||||||
->label('Health')
|
|
||||||
->disabled()
|
|
||||||
->dehydrated(false),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
|
||||||
})
|
|
||||||
->defaultSort('display_name')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
|
||||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->label('Status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
|
||||||
Tables\Columns\TextColumn::make('health_status')
|
|
||||||
->label('Health')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
|
||||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->label('Status')
|
|
||||||
->options([
|
|
||||||
'connected' => 'Connected',
|
|
||||||
'needs_consent' => 'Needs consent',
|
|
||||||
'error' => 'Error',
|
|
||||||
'disabled' => 'Disabled',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$value = $data['value'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('status', $value);
|
|
||||||
}),
|
|
||||||
SelectFilter::make('health_status')
|
|
||||||
->label('Health')
|
|
||||||
->options([
|
|
||||||
'ok' => 'OK',
|
|
||||||
'degraded' => 'Degraded',
|
|
||||||
'down' => 'Down',
|
|
||||||
'unknown' => 'Unknown',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$value = $data['value'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('health_status', $value);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\EditAction::make()
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('check_connection')
|
|
||||||
->label('Check connection')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'provider.connection.check',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('A connection check is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection check queued')
|
|
||||||
->body('Health check was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('inventory_sync')
|
|
||||||
->label('Inventory sync')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('info')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'inventory.sync',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderInventorySyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('An inventory sync is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync queued')
|
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('compliance_snapshot')
|
|
||||||
->label('Compliance snapshot')
|
|
||||||
->icon('heroicon-o-shield-check')
|
|
||||||
->color('info')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'compliance.snapshot',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderComplianceSnapshotJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('A compliance snapshot is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Compliance snapshot queued')
|
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('set_default')
|
|
||||||
->label('Set as default')
|
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->color('primary')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->makeDefault();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.default_set',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Default connection updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('update_credentials')
|
|
||||||
->label('Update credentials')
|
|
||||||
->icon('heroicon-o-key')
|
|
||||||
->color('primary')
|
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
|
||||||
->form([
|
|
||||||
TextInput::make('client_id')
|
|
||||||
->label('Client ID')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('client_secret')
|
|
||||||
->label('Client secret')
|
|
||||||
->password()
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
])
|
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
|
||||||
connection: $record,
|
|
||||||
clientId: (string) $data['client_id'],
|
|
||||||
clientSecret: (string) $data['client_secret'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.credentials_updated',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Credentials updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('enable_connection')
|
|
||||||
->label('Enable connection')
|
|
||||||
->icon('heroicon-o-play')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => $status,
|
|
||||||
'health_status' => 'unknown',
|
|
||||||
'last_health_check_at' => null,
|
|
||||||
'last_error_reason_code' => null,
|
|
||||||
'last_error_message' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.enabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
'to_status' => $status,
|
|
||||||
'credentials_present' => $hadCredentials,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $hadCredentials) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection enabled (credentials missing)')
|
|
||||||
->body('Add credentials before running checks or operations.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Provider connection enabled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('disable_connection')
|
|
||||||
->label('Disable connection')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => 'disabled',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.disabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Provider connection disabled')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('Actions')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListProviderConnections::route('/'),
|
|
||||||
'create' => Pages\CreateProviderConnection::route('/create'),
|
|
||||||
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListProviderConnections extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\CreateAction::make()
|
|
||||||
->authorize(fn (): bool => true)
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
||||||
->tooltip('You do not have permission to create provider connections.')
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListRestoreRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->redirect('/admin/managed-tenants/onboarding');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterCreate(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$this->record->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ViewAction::make(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record): void {
|
|
||||||
$record->delete();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
|
||||||
->tooltip('You do not have permission to archive managed tenants.')
|
|
||||||
->preserveVisibility()
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenants extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
parent::mount();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if ($user instanceof User && ! $user->tenantMemberships()->exists()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('add_managed_tenant')
|
|
||||||
->label('Add managed tenant')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->url('/admin/managed-tenants/onboarding')
|
|
||||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Intune\RbacHealthService;
|
|
||||||
use App\Services\Intune\TenantConfigService;
|
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewTenant extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TenantArchivedBanner::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('open_managed_tenant')
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
Actions\Action::make('admin_consent')
|
|
||||||
->label('Admin consent')
|
|
||||||
->icon('heroicon-o-clipboard-document')
|
|
||||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
Actions\Action::make('open_in_entra')
|
|
||||||
->label('Open in Entra')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
Actions\Action::make('verify')
|
|
||||||
->label('Verify configuration')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (
|
|
||||||
Tenant $record,
|
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
RbacHealthService $rbacHealthService,
|
|
||||||
AuditLogger $auditLogger
|
|
||||||
) {
|
|
||||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
|
||||||
}),
|
|
||||||
TenantResource::rbacAction(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Deactivate')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record,
|
|
||||||
action: 'tenant.archived',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant deactivated')
|
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('Actions')
|
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
|
|
||||||
class DashboardKpis extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected function getPollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', 0),
|
|
||||||
Stat::make('High severity drift', 0),
|
|
||||||
Stat::make('Active operations', 0),
|
|
||||||
Stat::make('Inventory active', 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$openDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$highSeverityDriftFindings = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryActiveRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory.sync')
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', $openDriftFindings)
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('High severity drift', $highSeverityDriftFindings)
|
|
||||||
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('Active operations', $activeRuns)
|
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
|
||||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Filament\Pages\DriftLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class NeedsAttention extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'pollingInterval' => null,
|
|
||||||
'items' => [],
|
|
||||||
'healthyChecks' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
$highSeverityCount = (int) Finding::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'High severity drift findings',
|
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestDriftSuccess = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'drift.generate')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestDriftSuccess) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'No drift scan yet',
|
|
||||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Drift stale',
|
|
||||||
'body' => 'Last drift scan is older than 7 days.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestDriftFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'drift.generate')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestDriftFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Drift generation failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Operations in progress',
|
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = array_slice($items, 0, 5);
|
|
||||||
|
|
||||||
$healthyChecks = [];
|
|
||||||
|
|
||||||
if ($items === []) {
|
|
||||||
$healthyChecks = [
|
|
||||||
[
|
|
||||||
'title' => 'Drift findings look healthy',
|
|
||||||
'body' => 'No high severity drift findings are open.',
|
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Drift scans are up to date',
|
|
||||||
'body' => $latestDriftSuccess?->completed_at
|
|
||||||
? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
|
||||||
: 'Drift scan history is available in Drift.',
|
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
|
||||||
'linkLabel' => 'Open Drift',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'No active operations',
|
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
|
||||||
'items' => $items,
|
|
||||||
'healthyChecks' => $healthyChecks,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Widgets\TableWidget;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class RecentOperations extends TableWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
return $table
|
|
||||||
->heading('Recent Operations')
|
|
||||||
->query($this->getQuery())
|
|
||||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
|
||||||
->paginated([10])
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('short_id')
|
|
||||||
->label('Run')
|
|
||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey())
|
|
||||||
->copyable()
|
|
||||||
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
|
|
||||||
TextColumn::make('type')
|
|
||||||
->label('Operation')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->limit(40)
|
|
||||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
TextColumn::make('created_at')
|
|
||||||
->label('Started')
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
|
|
||||||
? OperationRunLinks::view($record, $tenant)
|
|
||||||
: null)
|
|
||||||
->emptyStateHeading('No operations yet')
|
|
||||||
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<OperationRun>
|
|
||||||
*/
|
|
||||||
private function getQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->latest('created_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Widgets\Inventory;
|
|
||||||
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
use App\Support\Inventory\InventoryKpiBadges;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
||||||
use App\Support\Inventory\InventorySyncStatusBadge;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\HtmlString;
|
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inventory KPI aggregation source-of-truth:
|
|
||||||
* - `inventory_items.policy_type`
|
|
||||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
|
||||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
|
||||||
*
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
Stat::make('Total items', 0),
|
|
||||||
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
|
||||||
Stat::make('Last inventory sync', '—'),
|
|
||||||
Stat::make('Active ops', 0),
|
|
||||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
/** @var array<string, int> $countsByPolicyType */
|
|
||||||
$countsByPolicyType = InventoryItem::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
|
||||||
->groupBy('policy_type')
|
|
||||||
->pluck('aggregate', 'policy_type')
|
|
||||||
->map(fn ($value): int => (int) $value)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$totalItems = array_sum($countsByPolicyType);
|
|
||||||
|
|
||||||
$restorableItems = 0;
|
|
||||||
$partialItems = 0;
|
|
||||||
$riskItems = 0;
|
|
||||||
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
|
||||||
$restorableItems += $count;
|
|
||||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
|
||||||
$partialItems += $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
|
||||||
$riskItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$coveragePercent = $totalItems > 0
|
|
||||||
? (int) round(($restorableItems / $totalItems) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
$lastRun = InventorySyncRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$lastInventorySyncTimeLabel = '—';
|
|
||||||
$lastInventorySyncStatusLabel = '—';
|
|
||||||
$lastInventorySyncStatusColor = 'gray';
|
|
||||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
|
||||||
$lastInventorySyncViewUrl = null;
|
|
||||||
|
|
||||||
if ($lastRun instanceof InventorySyncRun) {
|
|
||||||
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
|
|
||||||
|
|
||||||
if ($timestamp) {
|
|
||||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (string) ($lastRun->status ?? '');
|
|
||||||
|
|
||||||
$badge = InventorySyncStatusBadge::for($status);
|
|
||||||
$lastInventorySyncStatusLabel = $badge['label'];
|
|
||||||
$lastInventorySyncStatusColor = $badge['color'];
|
|
||||||
$lastInventorySyncStatusIcon = $badge['icon'];
|
|
||||||
|
|
||||||
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
$badgeColor = $lastInventorySyncStatusColor;
|
|
||||||
|
|
||||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<x-filament::badge :color="$badgeColor" size="sm">
|
|
||||||
{{ $statusLabel }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if ($viewUrl)
|
|
||||||
<x-filament::link :href="$viewUrl" size="sm">
|
|
||||||
View run
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'badgeColor' => $badgeColor,
|
|
||||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
|
||||||
'viewUrl' => $lastInventorySyncViewUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$activeOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory.sync')
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
|
||||||
|
|
||||||
$dependenciesItems = 0;
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
|
||||||
$dependenciesItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Total items', $totalItems),
|
|
||||||
Stat::make('Coverage', $coveragePercent.'%')
|
|
||||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
|
||||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
|
||||||
->description(new HtmlString($lastInventorySyncDescription)),
|
|
||||||
Stat::make('Active ops', $activeOps),
|
|
||||||
Stat::make('Inventory ops', $inventoryOps)
|
|
||||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Intune\TenantConfigService;
|
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
|
||||||
|
|
||||||
class AdminConsentCallbackController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the incoming request.
|
|
||||||
*/
|
|
||||||
public function __invoke(
|
|
||||||
Request $request,
|
|
||||||
AuditLogger $auditLogger,
|
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
GraphClientInterface $graphClient
|
|
||||||
): View {
|
|
||||||
$expectedState = $request->session()->pull('tenant_onboard_state');
|
|
||||||
$tenantKey = $request->string('tenant')->toString();
|
|
||||||
$state = $request->string('state')->toString();
|
|
||||||
$tenantIdentifier = $tenantKey ?: $this->parseState($state);
|
|
||||||
|
|
||||||
if ($expectedState && $expectedState !== $state) {
|
|
||||||
abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state');
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_if(empty($tenantIdentifier), 404);
|
|
||||||
|
|
||||||
$tenant = Tenant::withTrashed()
|
|
||||||
->forTenant($tenantIdentifier)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($tenant?->trashed()) {
|
|
||||||
$tenant->restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'tenant_id' => $tenantIdentifier,
|
|
||||||
'name' => 'New Tenant',
|
|
||||||
'app_client_id' => config('graph.client_id'),
|
|
||||||
'app_client_secret' => config('graph.client_secret'),
|
|
||||||
'app_status' => 'pending',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$error = $request->string('error')->toString() ?: null;
|
|
||||||
$consentGranted = $request->has('admin_consent')
|
|
||||||
? filter_var($request->input('admin_consent'), FILTER_VALIDATE_BOOLEAN)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$status = match (true) {
|
|
||||||
$error !== null => 'error',
|
|
||||||
$consentGranted === false => 'consent_denied',
|
|
||||||
$consentGranted === true => 'ok',
|
|
||||||
default => 'pending',
|
|
||||||
};
|
|
||||||
|
|
||||||
$tenant->update([
|
|
||||||
'app_status' => $status,
|
|
||||||
'app_notes' => $error,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.consent.callback',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'status' => $status,
|
|
||||||
'state' => $state,
|
|
||||||
'error' => $error,
|
|
||||||
'consent' => $consentGranted,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
status: $status === 'ok' ? 'success' : 'error',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return view('admin-consent-callback', [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'status' => $status,
|
|
||||||
'error' => $error,
|
|
||||||
'consentGranted' => $consentGranted,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleAuthorizationCodeFlow(
|
|
||||||
Request $request,
|
|
||||||
AuditLogger $auditLogger,
|
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
GraphClientInterface $graphClient
|
|
||||||
): View {
|
|
||||||
$expectedState = $request->session()->pull('tenant_onboard_state');
|
|
||||||
if ($expectedState && $expectedState !== $request->string('state')->toString()) {
|
|
||||||
abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state');
|
|
||||||
}
|
|
||||||
|
|
||||||
$redirectUri = route('admin.consent.callback');
|
|
||||||
|
|
||||||
$token = $this->exchangeAuthorizationCode(
|
|
||||||
code: $request->string('code')->toString(),
|
|
||||||
redirectUri: $redirectUri
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenantId = $token['tenant_id'] ?? null;
|
|
||||||
abort_if(empty($tenantId), 500, 'Tenant ID missing from token');
|
|
||||||
|
|
||||||
/** @var Tenant|null $tenant */
|
|
||||||
$tenant = Tenant::withTrashed()
|
|
||||||
->forTenant($tenantId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($tenant?->trashed()) {
|
|
||||||
$tenant->restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'tenant_id' => $tenantId,
|
|
||||||
'name' => 'New Tenant',
|
|
||||||
'app_client_id' => config('graph.client_id'),
|
|
||||||
'app_client_secret' => config('graph.client_secret'),
|
|
||||||
'app_status' => 'pending',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$orgResponse = $graphClient->getOrganization([
|
|
||||||
'tenant' => $tenant->graphTenantId(),
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($orgResponse->successful()) {
|
|
||||||
$org = $orgResponse->data ?? [];
|
|
||||||
$tenant->update([
|
|
||||||
'name' => $org['displayName'] ?? $tenant->name,
|
|
||||||
'domain' => $org['verifiedDomains'][0]['name'] ?? $tenant->domain,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$configResult = $configService->testConnectivity($tenant);
|
|
||||||
$permissionService->compare($tenant);
|
|
||||||
|
|
||||||
$status = $configResult['success'] ? 'ok' : 'error';
|
|
||||||
|
|
||||||
$tenant->update([
|
|
||||||
'app_status' => $status,
|
|
||||||
'app_notes' => $configResult['error_message'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.consent.callback',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'status' => $status,
|
|
||||||
'error' => $configResult['error_message'],
|
|
||||||
'from' => 'authorization_code',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
status: $status === 'ok' ? 'success' : 'error',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return view('admin-consent-callback', [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'status' => $status,
|
|
||||||
'error' => $configResult['error_message'],
|
|
||||||
'consentGranted' => $status === 'ok',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{access_token:string,id_token:string,tenant_id:?string}
|
|
||||||
*/
|
|
||||||
private function exchangeAuthorizationCode(string $code, string $redirectUri): array
|
|
||||||
{
|
|
||||||
$response = Http::asForm()->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
|
|
||||||
'client_id' => config('graph.client_id'),
|
|
||||||
'client_secret' => config('graph.client_secret'),
|
|
||||||
'code' => $code,
|
|
||||||
'grant_type' => 'authorization_code',
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'scope' => 'https://graph.microsoft.com/.default offline_access openid profile',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
abort(ResponseAlias::HTTP_BAD_GATEWAY, 'Failed to exchange code for token');
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = $response->json();
|
|
||||||
$idToken = $body['id_token'] ?? null;
|
|
||||||
$tenantId = $this->parseTenantIdFromToken($idToken);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'access_token' => $body['access_token'] ?? '',
|
|
||||||
'id_token' => $idToken ?? '',
|
|
||||||
'tenant_id' => $tenantId,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseTenantIdFromToken(?string $token): ?string
|
|
||||||
{
|
|
||||||
if (! $token || ! str_contains($token, '.')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode('.', $token);
|
|
||||||
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')) ?: '[]', true);
|
|
||||||
|
|
||||||
return $payload['tid'] ?? $payload['tenant'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseState(?string $state): ?string
|
|
||||||
{
|
|
||||||
if (empty($state)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($state, '|')) {
|
|
||||||
[, $value] = explode('|', $state, 2);
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantOnboardingController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$clientId = config('graph.client_id');
|
|
||||||
$redirectUri = route('admin.consent.callback');
|
|
||||||
$targetTenant = $request->string('tenant')->toString() ?: config('graph.tenant_id', 'organizations');
|
|
||||||
$tenantSegment = $targetTenant ?: 'organizations';
|
|
||||||
|
|
||||||
abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured');
|
|
||||||
|
|
||||||
$state = Str::uuid()->toString();
|
|
||||||
$request->session()->put('tenant_onboard_state', $state);
|
|
||||||
|
|
||||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'scope' => 'https://graph.microsoft.com/.default',
|
|
||||||
'state' => $state,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->away($url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\BackupSet;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class ApplyBackupScheduleRetentionJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(public int $backupScheduleId) {}
|
|
||||||
|
|
||||||
public function handle(AuditLogger $auditLogger): void
|
|
||||||
{
|
|
||||||
$schedule = BackupSchedule::query()
|
|
||||||
->with('tenant')
|
|
||||||
->find($this->backupScheduleId);
|
|
||||||
|
|
||||||
if (! $schedule || ! $schedule->tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$keepLast = (int) ($schedule->retention_keep_last ?? 30);
|
|
||||||
|
|
||||||
if ($keepLast < 1) {
|
|
||||||
$keepLast = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var Collection<int, int> $keepBackupSetIds */
|
|
||||||
$keepBackupSetIds = BackupScheduleRun::query()
|
|
||||||
->where('backup_schedule_id', $schedule->id)
|
|
||||||
->whereNotNull('backup_set_id')
|
|
||||||
->orderByDesc('scheduled_for')
|
|
||||||
->limit($keepLast)
|
|
||||||
->pluck('backup_set_id')
|
|
||||||
->filter()
|
|
||||||
->values();
|
|
||||||
|
|
||||||
/** @var Collection<int, int> $deleteBackupSetIds */
|
|
||||||
$deleteBackupSetIds = BackupScheduleRun::query()
|
|
||||||
->where('backup_schedule_id', $schedule->id)
|
|
||||||
->whereNotNull('backup_set_id')
|
|
||||||
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
|
|
||||||
->pluck('backup_set_id')
|
|
||||||
->filter()
|
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if ($deleteBackupSetIds->isEmpty()) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $schedule->tenant,
|
|
||||||
action: 'backup_schedule.retention_applied',
|
|
||||||
resourceType: 'backup_schedule',
|
|
||||||
resourceId: (string) $schedule->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'keep_last' => $keepLast,
|
|
||||||
'deleted_backup_sets' => 0,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletedCount = 0;
|
|
||||||
|
|
||||||
BackupSet::query()
|
|
||||||
->where('tenant_id', $schedule->tenant_id)
|
|
||||||
->whereIn('id', $deleteBackupSetIds->all())
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
|
|
||||||
foreach ($sets as $set) {
|
|
||||||
$set->delete();
|
|
||||||
$deletedCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $schedule->tenant,
|
|
||||||
action: 'backup_schedule.retention_applied',
|
|
||||||
resourceType: 'backup_schedule',
|
|
||||||
resourceId: (string) $schedule->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'keep_last' => $keepLast,
|
|
||||||
'deleted_backup_sets' => $deletedCount,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Directory\EntraGroupSyncService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class EntraGroupSyncJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantId,
|
|
||||||
public string $selectionKey,
|
|
||||||
public ?string $slotKey = null,
|
|
||||||
public ?int $runId = null,
|
|
||||||
?OperationRun $operationRun = null
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
|
|
||||||
{
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $this->resolveRun($tenant);
|
|
||||||
|
|
||||||
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
|
|
||||||
// Already ran?
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run->update([
|
|
||||||
'status' => EntraGroupSyncRun::STATUS_RUNNING,
|
|
||||||
'started_at' => CarbonImmutable::now('UTC'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'directory_groups.sync.started',
|
|
||||||
context: [
|
|
||||||
'selection_key' => $run->selection_key,
|
|
||||||
'run_id' => $run->getKey(),
|
|
||||||
'slot_key' => $run->slot_key,
|
|
||||||
],
|
|
||||||
actorId: $run->initiator_user_id,
|
|
||||||
status: 'success',
|
|
||||||
resourceType: 'entra_group_sync_run',
|
|
||||||
resourceId: (string) $run->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $syncService->sync($tenant, $run);
|
|
||||||
|
|
||||||
$terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED;
|
|
||||||
|
|
||||||
if ($result['error_code'] !== null) {
|
|
||||||
$terminalStatus = EntraGroupSyncRun::STATUS_FAILED;
|
|
||||||
} elseif ($result['safety_stop_triggered'] === true) {
|
|
||||||
$terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run->update([
|
|
||||||
'status' => $terminalStatus,
|
|
||||||
'pages_fetched' => $result['pages_fetched'],
|
|
||||||
'items_observed_count' => $result['items_observed_count'],
|
|
||||||
'items_upserted_count' => $result['items_upserted_count'],
|
|
||||||
'error_count' => $result['error_count'],
|
|
||||||
'safety_stop_triggered' => $result['safety_stop_triggered'],
|
|
||||||
'safety_stop_reason' => $result['safety_stop_reason'],
|
|
||||||
'error_code' => $result['error_code'],
|
|
||||||
'error_category' => $result['error_category'],
|
|
||||||
'error_summary' => $result['error_summary'],
|
|
||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update OperationRun with stats
|
|
||||||
if ($this->operationRun) {
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opOutcome = match ($terminalStatus) {
|
|
||||||
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
|
|
||||||
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
|
|
||||||
EntraGroupSyncRun::STATUS_FAILED => 'failed',
|
|
||||||
default => 'failed'
|
|
||||||
};
|
|
||||||
|
|
||||||
$opService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
'completed',
|
|
||||||
$opOutcome,
|
|
||||||
[
|
|
||||||
'fetched' => $result['items_observed_count'],
|
|
||||||
'upserted' => $result['items_upserted_count'],
|
|
||||||
'errors' => $result['error_count'],
|
|
||||||
],
|
|
||||||
$result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
|
||||||
? 'directory_groups.sync.succeeded'
|
|
||||||
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
|
|
||||||
? 'directory_groups.sync.partial'
|
|
||||||
: 'directory_groups.sync.failed'),
|
|
||||||
context: [
|
|
||||||
'selection_key' => $run->selection_key,
|
|
||||||
'run_id' => $run->getKey(),
|
|
||||||
'slot_key' => $run->slot_key,
|
|
||||||
'pages_fetched' => $run->pages_fetched,
|
|
||||||
'items_observed_count' => $run->items_observed_count,
|
|
||||||
'items_upserted_count' => $run->items_upserted_count,
|
|
||||||
'error_code' => $run->error_code,
|
|
||||||
'error_category' => $run->error_category,
|
|
||||||
],
|
|
||||||
actorId: $run->initiator_user_id,
|
|
||||||
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
|
|
||||||
resourceType: 'entra_group_sync_run',
|
|
||||||
resourceId: (string) $run->getKey(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRun(Tenant $tenant): EntraGroupSyncRun
|
|
||||||
{
|
|
||||||
if ($this->runId !== null) {
|
|
||||||
$run = EntraGroupSyncRun::query()
|
|
||||||
->whereKey($this->runId)
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($run instanceof EntraGroupSyncRun) {
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException('EntraGroupSyncRun not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->slotKey !== null) {
|
|
||||||
$run = EntraGroupSyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_key', $this->selectionKey)
|
|
||||||
->where('slot_key', $this->slotKey)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($run instanceof EntraGroupSyncRun) {
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException('EntraGroupSyncRun not found for slot.');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException('Job missing runId/slotKey.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\BackupItem;
|
|
||||||
use App\Services\AssignmentBackupService;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class FetchAssignmentsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of times the job may be attempted.
|
|
||||||
*/
|
|
||||||
public int $tries = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of seconds to wait before retrying the job.
|
|
||||||
*/
|
|
||||||
public int $backoff = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $backupItemId,
|
|
||||||
public string $tenantExternalId,
|
|
||||||
public string $policyExternalId,
|
|
||||||
public array $policyPayload
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(AssignmentBackupService $assignmentBackupService): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$backupItem = BackupItem::find($this->backupItemId);
|
|
||||||
|
|
||||||
if ($backupItem === null) {
|
|
||||||
Log::warning('FetchAssignmentsJob: BackupItem not found', [
|
|
||||||
'backup_item_id' => $this->backupItemId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $backupItem->tenant;
|
|
||||||
|
|
||||||
if ($tenant === null) {
|
|
||||||
Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [
|
|
||||||
'backup_item_id' => $this->backupItemId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process Settings Catalog policies
|
|
||||||
if ($backupItem->policy_type !== 'settingsCatalogPolicy') {
|
|
||||||
Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [
|
|
||||||
'backup_item_id' => $this->backupItemId,
|
|
||||||
'policy_type' => $backupItem->policy_type,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$assignmentBackupService->enrichWithAssignments(
|
|
||||||
backupItem: $backupItem,
|
|
||||||
tenant: $tenant,
|
|
||||||
policyType: $backupItem->policy_type,
|
|
||||||
policyId: $backupItem->policy_identifier,
|
|
||||||
policyPayload: $this->policyPayload,
|
|
||||||
includeAssignments: true
|
|
||||||
);
|
|
||||||
|
|
||||||
Log::info('FetchAssignmentsJob: Successfully enriched BackupItem', [
|
|
||||||
'backup_item_id' => $this->backupItemId,
|
|
||||||
'assignment_count' => $backupItem->getAssignmentCount(),
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('FetchAssignmentsJob: Failed to enrich BackupItem', [
|
|
||||||
'backup_item_id' => $this->backupItemId,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Don't retry - fail soft
|
|
||||||
$this->fail($e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GenerateDriftFindingsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantId,
|
|
||||||
public int $userId,
|
|
||||||
public int $baselineRunId,
|
|
||||||
public int $currentRunId,
|
|
||||||
public string $scopeKey,
|
|
||||||
?OperationRun $operationRun = null,
|
|
||||||
public array $context = [],
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
DriftFindingGenerator $generator,
|
|
||||||
OperationRunService $runs,
|
|
||||||
TargetScopeConcurrencyLimiter $limiter,
|
|
||||||
): void {
|
|
||||||
Log::info('GenerateDriftFindingsJob: started', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_run_id' => $this->baselineRunId,
|
|
||||||
'current_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
|
||||||
throw new RuntimeException('OperationRun is required for drift generation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
if ($this->operationRun->status === 'completed') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
|
|
||||||
|
|
||||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
|
||||||
|
|
||||||
if (! $lock) {
|
|
||||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
|
||||||
$this->release(max(1, $delay));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
|
||||||
if (! $baseline instanceof InventorySyncRun) {
|
|
||||||
throw new RuntimeException('Baseline run not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
|
||||||
if (! $current instanceof InventorySyncRun) {
|
|
||||||
throw new RuntimeException('Current run not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$runs->updateRun($this->operationRun, 'running');
|
|
||||||
|
|
||||||
$counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : [];
|
|
||||||
if ((int) ($counts['total'] ?? 0) === 0) {
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = $generator->generate(
|
|
||||||
tenant: $tenant,
|
|
||||||
baseline: $baseline,
|
|
||||||
current: $current,
|
|
||||||
scopeKey: $this->scopeKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
Log::info('GenerateDriftFindingsJob: completed', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_run_id' => $this->baselineRunId,
|
|
||||||
'current_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
'created_findings_count' => $created,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, [
|
|
||||||
'processed' => 1,
|
|
||||||
'succeeded' => 1,
|
|
||||||
'created' => $created,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('GenerateDriftFindingsJob: failed', [
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
'baseline_run_id' => $this->baselineRunId,
|
|
||||||
'current_run_id' => $this->currentRunId,
|
|
||||||
'scope_key' => $this->scopeKey,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, [
|
|
||||||
'processed' => 1,
|
|
||||||
'failed' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runs->appendFailures($this->operationRun, [[
|
|
||||||
'code' => 'drift.generate.failed',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]]);
|
|
||||||
|
|
||||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
} finally {
|
|
||||||
$lock->release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Providers\Contracts\HealthResult;
|
|
||||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class ProviderConnectionHealthCheckJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantId,
|
|
||||||
public int $userId,
|
|
||||||
public int $providerConnectionId,
|
|
||||||
?OperationRun $operationRun = null,
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, object>
|
|
||||||
*/
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
MicrosoftProviderHealthCheck $healthCheck,
|
|
||||||
OperationRunService $runs,
|
|
||||||
): void {
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::query()->find($this->userId);
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
throw new RuntimeException('User not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->find($this->providerConnectionId);
|
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
|
||||||
throw new RuntimeException('ProviderConnection not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $healthCheck->check($connection);
|
|
||||||
|
|
||||||
$this->applyHealthResult($connection, $result);
|
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entraTenantName = $this->resolveEntraTenantName($connection, $result);
|
|
||||||
|
|
||||||
if ($entraTenantName !== null) {
|
|
||||||
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
|
|
||||||
$metadata['entra_tenant_name'] = $entraTenantName;
|
|
||||||
$connection->update(['metadata' => $metadata]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
|
||||||
|
|
||||||
if ($result->healthy) {
|
|
||||||
$runs->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runs->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [[
|
|
||||||
'code' => 'provider.connection.check.failed',
|
|
||||||
'reason_code' => $result->reasonCode ?? 'unknown_error',
|
|
||||||
'message' => $result->message ?? 'Health check failed.',
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
|
||||||
{
|
|
||||||
$existing = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name');
|
|
||||||
|
|
||||||
if (is_string($existing) && trim($existing) !== '') {
|
|
||||||
return trim($existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidate = $result->meta['organization_display_name'] ?? null;
|
|
||||||
|
|
||||||
return is_string($candidate) && trim($candidate) !== '' ? trim($candidate) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
|
||||||
|
|
||||||
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
|
|
||||||
|
|
||||||
if (is_string($entraTenantName) && $entraTenantName !== '') {
|
|
||||||
$targetScope['entra_tenant_name'] = $entraTenantName;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context['target_scope'] = $targetScope;
|
|
||||||
|
|
||||||
$run->update(['context' => $context]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyHealthResult(ProviderConnection $connection, HealthResult $result): void
|
|
||||||
{
|
|
||||||
$connection->update([
|
|
||||||
'status' => $result->status,
|
|
||||||
'health_status' => $result->healthStatus,
|
|
||||||
'last_health_check_at' => now(),
|
|
||||||
'last_error_reason_code' => $result->healthy ? null : $result->reasonCode,
|
|
||||||
'last_error_message' => $result->healthy ? null : $result->message,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class PruneOldOperationRunsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $retentionDays = 90
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
OperationRun::where('created_at', '<', now()->subDays($this->retentionDays))
|
|
||||||
->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Services\AdapterRunReconciler;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ReconcileAdapterRunsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
/** @var AdapterRunReconciler $reconciler */
|
|
||||||
$reconciler = app(AdapterRunReconciler::class);
|
|
||||||
|
|
||||||
$result = $reconciler->reconcile([
|
|
||||||
'older_than_minutes' => 60,
|
|
||||||
'limit' => 50,
|
|
||||||
'dry_run' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Log::info('ReconcileAdapterRunsJob completed', [
|
|
||||||
'candidates' => (int) ($result['candidates'] ?? 0),
|
|
||||||
'reconciled' => (int) ($result['reconciled'] ?? 0),
|
|
||||||
'skipped' => (int) ($result['skipped'] ?? 0),
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::warning('ReconcileAdapterRunsJob failed', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\RestoreRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\AssignmentRestoreService;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class RestoreAssignmentsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public int $tries = 1;
|
|
||||||
|
|
||||||
public int $backoff = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $restoreRunId,
|
|
||||||
public int $tenantId,
|
|
||||||
public string $policyType,
|
|
||||||
public string $policyId,
|
|
||||||
public array $assignments,
|
|
||||||
public array $groupMapping,
|
|
||||||
public array $foundationMapping = [],
|
|
||||||
public ?string $actorEmail = null,
|
|
||||||
public ?string $actorName = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(AssignmentRestoreService $assignmentRestoreService): array
|
|
||||||
{
|
|
||||||
$restoreRun = RestoreRun::find($this->restoreRunId);
|
|
||||||
$tenant = Tenant::find($this->tenantId);
|
|
||||||
|
|
||||||
if (! $restoreRun || ! $tenant) {
|
|
||||||
Log::warning('RestoreAssignmentsJob missing context', [
|
|
||||||
'restore_run_id' => $this->restoreRunId,
|
|
||||||
'tenant_id' => $this->tenantId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'outcomes' => [],
|
|
||||||
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $assignmentRestoreService->restore(
|
|
||||||
tenant: $tenant,
|
|
||||||
policyType: $this->policyType,
|
|
||||||
policyId: $this->policyId,
|
|
||||||
assignments: $this->assignments,
|
|
||||||
groupMapping: $this->groupMapping,
|
|
||||||
foundationMapping: $this->foundationMapping,
|
|
||||||
restoreRun: $restoreRun,
|
|
||||||
actorEmail: $this->actorEmail,
|
|
||||||
actorName: $this->actorName,
|
|
||||||
);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('RestoreAssignmentsJob failed', [
|
|
||||||
'restore_run_id' => $this->restoreRunId,
|
|
||||||
'policy_id' => $this->policyId,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'outcomes' => [[
|
|
||||||
'status' => 'failed',
|
|
||||||
'reason' => $e->getMessage(),
|
|
||||||
]],
|
|
||||||
'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,582 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
|
||||||
use App\Services\BackupScheduling\RunErrorMapper;
|
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Intune\BackupService;
|
|
||||||
use App\Services\Intune\PolicySyncService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Bus;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class RunBackupScheduleJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public int $tries = 3;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $backupScheduleRunId,
|
|
||||||
?OperationRun $operationRun = null,
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
PolicySyncService $policySyncService,
|
|
||||||
BackupService $backupService,
|
|
||||||
PolicyTypeResolver $policyTypeResolver,
|
|
||||||
ScheduleTimeService $scheduleTimeService,
|
|
||||||
AuditLogger $auditLogger,
|
|
||||||
RunErrorMapper $errorMapper,
|
|
||||||
): void {
|
|
||||||
$run = BackupScheduleRun::query()
|
|
||||||
->with(['schedule', 'tenant', 'user'])
|
|
||||||
->find($this->backupScheduleRunId);
|
|
||||||
|
|
||||||
if (! $run) {
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$this->markOperationRunFailed(
|
|
||||||
run: $this->operationRun,
|
|
||||||
summaryCounts: [],
|
|
||||||
reasonCode: 'run_not_found',
|
|
||||||
reason: 'Backup schedule run not found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $run->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$this->resolveOperationRunFromContext($tenant, $run);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$this->operationRun->update([
|
|
||||||
'context' => array_merge($this->operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var OperationRunService $operationRunService */
|
|
||||||
$operationRunService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
if ($this->operationRun->status === 'queued') {
|
|
||||||
$operationRunService->updateRun($this->operationRun, 'running');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule = $run->schedule;
|
|
||||||
|
|
||||||
if (! $schedule instanceof BackupSchedule) {
|
|
||||||
$run->update([
|
|
||||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
|
||||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
|
||||||
'error_message' => 'Schedule not found.',
|
|
||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$this->markOperationRunFailed(
|
|
||||||
run: $this->operationRun,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
'failed' => 1,
|
|
||||||
],
|
|
||||||
reasonCode: 'schedule_not_found',
|
|
||||||
reason: 'Schedule not found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$run->update([
|
|
||||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
|
||||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
|
||||||
'error_message' => 'Tenant not found.',
|
|
||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$this->markOperationRunFailed(
|
|
||||||
run: $this->operationRun,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
'failed' => 1,
|
|
||||||
],
|
|
||||||
reasonCode: 'tenant_not_found',
|
|
||||||
reason: 'Tenant not found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
|
|
||||||
|
|
||||||
if (! $lock->get()) {
|
|
||||||
$this->finishRun(
|
|
||||||
run: $run,
|
|
||||||
schedule: $schedule,
|
|
||||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
|
||||||
errorCode: 'CONCURRENT_RUN',
|
|
||||||
errorMessage: 'Another run is already in progress for this schedule.',
|
|
||||||
summary: ['reason' => 'concurrent_run'],
|
|
||||||
scheduleTimeService: $scheduleTimeService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
schedule: $schedule,
|
|
||||||
run: $run->refresh(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
$run->forceFill([
|
|
||||||
'started_at' => $run->started_at ?? $nowUtc,
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->notifyRunStarted($run, $schedule);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'backup_schedule.run_started',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'backup_schedule_id' => $schedule->id,
|
|
||||||
'backup_schedule_run_id' => $run->id,
|
|
||||||
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
resourceType: 'backup_schedule_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
status: 'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
|
|
||||||
$validTypes = $runtime['valid'];
|
|
||||||
$unknownTypes = $runtime['unknown'];
|
|
||||||
|
|
||||||
if (empty($validTypes)) {
|
|
||||||
$this->finishRun(
|
|
||||||
run: $run,
|
|
||||||
schedule: $schedule,
|
|
||||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
|
||||||
errorCode: 'UNKNOWN_POLICY_TYPE',
|
|
||||||
errorMessage: 'All configured policy types are unknown.',
|
|
||||||
summary: [
|
|
||||||
'unknown_policy_types' => $unknownTypes,
|
|
||||||
],
|
|
||||||
scheduleTimeService: $scheduleTimeService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
schedule: $schedule,
|
|
||||||
run: $run->refresh(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$supported = array_values(array_filter(
|
|
||||||
config('tenantpilot.supported_policy_types', []),
|
|
||||||
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
|
|
||||||
));
|
|
||||||
|
|
||||||
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
|
|
||||||
|
|
||||||
$policyIds = $syncReport['synced'] ?? [];
|
|
||||||
$syncFailures = $syncReport['failures'] ?? [];
|
|
||||||
|
|
||||||
$backupSet = $backupService->createBackupSet(
|
|
||||||
tenant: $tenant,
|
|
||||||
policyIds: $policyIds,
|
|
||||||
actorEmail: null,
|
|
||||||
actorName: null,
|
|
||||||
name: 'Scheduled backup: '.$schedule->name,
|
|
||||||
includeAssignments: false,
|
|
||||||
includeScopeTags: false,
|
|
||||||
includeFoundations: (bool) ($schedule->include_foundations ?? false),
|
|
||||||
);
|
|
||||||
|
|
||||||
$status = match ($backupSet->status) {
|
|
||||||
'completed' => BackupScheduleRun::STATUS_SUCCESS,
|
|
||||||
'partial' => BackupScheduleRun::STATUS_PARTIAL,
|
|
||||||
'failed' => BackupScheduleRun::STATUS_FAILED,
|
|
||||||
default => BackupScheduleRun::STATUS_SUCCESS,
|
|
||||||
};
|
|
||||||
|
|
||||||
$errorCode = null;
|
|
||||||
$errorMessage = null;
|
|
||||||
|
|
||||||
$summary = [
|
|
||||||
'policies_total' => count($policyIds),
|
|
||||||
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
|
|
||||||
'sync_failures' => $syncFailures,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! empty($unknownTypes)) {
|
|
||||||
$status = BackupScheduleRun::STATUS_PARTIAL;
|
|
||||||
$errorCode = 'UNKNOWN_POLICY_TYPE';
|
|
||||||
$errorMessage = 'Some configured policy types are unknown and were skipped.';
|
|
||||||
$summary['unknown_policy_types'] = $unknownTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->finishRun(
|
|
||||||
run: $run,
|
|
||||||
schedule: $schedule,
|
|
||||||
status: $status,
|
|
||||||
errorCode: $errorCode,
|
|
||||||
errorMessage: $errorMessage,
|
|
||||||
summary: $summary,
|
|
||||||
scheduleTimeService: $scheduleTimeService,
|
|
||||||
backupSetId: (string) $backupSet->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
schedule: $schedule,
|
|
||||||
run: $run->refresh(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'backup_schedule.run_finished',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'backup_schedule_id' => $schedule->id,
|
|
||||||
'backup_schedule_run_id' => $run->id,
|
|
||||||
'status' => $status,
|
|
||||||
'error_code' => $errorCode,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
resourceType: 'backup_schedule_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
|
|
||||||
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
|
||||||
|
|
||||||
if ($mapped['shouldRetry']) {
|
|
||||||
if ($this->operationRun) {
|
|
||||||
/** @var OperationRunService $operationRunService */
|
|
||||||
$operationRunService = app(OperationRunService::class);
|
|
||||||
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->release($mapped['delay']);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->finishRun(
|
|
||||||
run: $run,
|
|
||||||
schedule: $schedule,
|
|
||||||
status: BackupScheduleRun::STATUS_FAILED,
|
|
||||||
errorCode: $mapped['error_code'],
|
|
||||||
errorMessage: $mapped['error_message'],
|
|
||||||
summary: [
|
|
||||||
'exception' => get_class($throwable),
|
|
||||||
'attempt' => $attempt,
|
|
||||||
],
|
|
||||||
scheduleTimeService: $scheduleTimeService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
schedule: $schedule,
|
|
||||||
run: $run->refresh(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'backup_schedule.run_failed',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'backup_schedule_id' => $schedule->id,
|
|
||||||
'backup_schedule_run_id' => $run->id,
|
|
||||||
'error_code' => $mapped['error_code'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
resourceType: 'backup_schedule_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
status: 'failed'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
optional($lock)->release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
|
|
||||||
{
|
|
||||||
$user = $run->user;
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$notification = Notification::make()
|
|
||||||
->title('Backup started')
|
|
||||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$notification->sendToDatabase($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
|
|
||||||
{
|
|
||||||
$user = $run->user;
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = match ($run->status) {
|
|
||||||
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
|
|
||||||
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
|
|
||||||
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
|
|
||||||
default => 'Backup failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
$notification = Notification::make()
|
|
||||||
->title($title)
|
|
||||||
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
|
|
||||||
|
|
||||||
if (filled($run->error_message)) {
|
|
||||||
$notification->body($notification->getBody()."\n".$run->error_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
match ($run->status) {
|
|
||||||
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
|
|
||||||
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
|
|
||||||
default => $notification->danger(),
|
|
||||||
};
|
|
||||||
|
|
||||||
$notification
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
|
||||||
])
|
|
||||||
->sendToDatabase($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncOperationRunFromRun(
|
|
||||||
Tenant $tenant,
|
|
||||||
BackupSchedule $schedule,
|
|
||||||
BackupScheduleRun $run,
|
|
||||||
): void {
|
|
||||||
if (! $this->operationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$outcome = match ($run->status) {
|
|
||||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
|
||||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
|
||||||
// Note: 'cancelled' is a reserved OperationRun outcome token.
|
|
||||||
// We treat schedule SKIPPED/CANCELED as terminal failures with a failure entry.
|
|
||||||
BackupScheduleRun::STATUS_SKIPPED,
|
|
||||||
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
|
||||||
default => 'failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
$summary = is_array($run->summary) ? $run->summary : [];
|
|
||||||
$syncFailures = $summary['sync_failures'] ?? [];
|
|
||||||
|
|
||||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
|
||||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
|
||||||
$syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
|
||||||
|
|
||||||
$failedCount = max(0, $policiesTotal - $policiesBackedUp);
|
|
||||||
|
|
||||||
$summaryCounts = [
|
|
||||||
'total' => $policiesTotal,
|
|
||||||
'processed' => $policiesTotal,
|
|
||||||
'succeeded' => $policiesBackedUp,
|
|
||||||
'failed' => $failedCount,
|
|
||||||
'skipped' => 0,
|
|
||||||
'created' => filled($run->backup_set_id) ? 1 : 0,
|
|
||||||
'updated' => $policiesBackedUp,
|
|
||||||
'items' => $policiesTotal,
|
|
||||||
];
|
|
||||||
|
|
||||||
$failures = [];
|
|
||||||
|
|
||||||
if (filled($run->error_message) || filled($run->error_code)) {
|
|
||||||
$failures[] = [
|
|
||||||
'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
|
|
||||||
'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($syncFailures)) {
|
|
||||||
foreach ($syncFailures as $failure) {
|
|
||||||
if (! is_array($failure)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
|
||||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
|
||||||
$errors = $failure['errors'] ?? null;
|
|
||||||
|
|
||||||
$firstErrorMessage = null;
|
|
||||||
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
|
||||||
$firstErrorMessage = $errors[0]['message'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = $status !== null
|
|
||||||
? "{$policyType}: Graph returned {$status}"
|
|
||||||
: "{$policyType}: Graph request failed";
|
|
||||||
|
|
||||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
|
||||||
$message .= ' - '.trim($firstErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failures[] = [
|
|
||||||
'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error',
|
|
||||||
'message' => $message,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $operationRunService */
|
|
||||||
$operationRunService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$this->operationRun->update([
|
|
||||||
'context' => array_merge($this->operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: $outcome,
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
failures: $failures,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markOperationRunFailed(
|
|
||||||
OperationRun $run,
|
|
||||||
array $summaryCounts,
|
|
||||||
string $reasonCode,
|
|
||||||
string $reason,
|
|
||||||
): void {
|
|
||||||
/** @var OperationRunService $operationRunService */
|
|
||||||
$operationRunService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$run,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => $reasonCode,
|
|
||||||
'message' => $reason,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void
|
|
||||||
{
|
|
||||||
if ($this->operationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
|
||||||
->whereIn('status', ['queued', 'running'])
|
|
||||||
->where('context->backup_schedule_run_id', (int) $run->getKey())
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($operationRun instanceof OperationRun) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function finishRun(
|
|
||||||
BackupScheduleRun $run,
|
|
||||||
BackupSchedule $schedule,
|
|
||||||
string $status,
|
|
||||||
?string $errorCode,
|
|
||||||
?string $errorMessage,
|
|
||||||
array $summary,
|
|
||||||
ScheduleTimeService $scheduleTimeService,
|
|
||||||
?string $backupSetId = null,
|
|
||||||
): void {
|
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
$run->forceFill([
|
|
||||||
'status' => $status,
|
|
||||||
'error_code' => $errorCode,
|
|
||||||
'error_message' => $errorMessage,
|
|
||||||
'summary' => Arr::wrap($summary),
|
|
||||||
'finished_at' => $nowUtc,
|
|
||||||
'backup_set_id' => $backupSetId,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$schedule->forceFill([
|
|
||||||
'last_run_at' => $nowUtc,
|
|
||||||
'last_run_status' => $status,
|
|
||||||
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
|
||||||
])->saveQuietly();
|
|
||||||
|
|
||||||
$this->notifyRunFinished($run, $schedule);
|
|
||||||
|
|
||||||
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
|
||||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Inventory\InventorySyncService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantId,
|
|
||||||
public int $userId,
|
|
||||||
public int $inventorySyncRunId,
|
|
||||||
?OperationRun $operationRun = null
|
|
||||||
) {
|
|
||||||
$this->operationRun = $operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the middleware the job should pass through.
|
|
||||||
*
|
|
||||||
* @return array<int, object>
|
|
||||||
*/
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void
|
|
||||||
{
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::query()->find($this->userId);
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
throw new RuntimeException('User not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = InventorySyncRun::query()->find($this->inventorySyncRunId);
|
|
||||||
if (! $run instanceof InventorySyncRun) {
|
|
||||||
throw new RuntimeException('InventorySyncRun not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
|
|
||||||
if (! is_array($policyTypes)) {
|
|
||||||
$policyTypes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$processedPolicyTypes = [];
|
|
||||||
$successCount = 0;
|
|
||||||
$failedCount = 0;
|
|
||||||
|
|
||||||
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
|
||||||
// It will also handle success completion if no exceptions thrown.
|
|
||||||
// However, InventorySyncService execution logic might be complex with partial failures.
|
|
||||||
// We might want to explicitly update the OperationRun if partial failures occur.
|
|
||||||
|
|
||||||
$run = $inventorySyncService->executePendingRun(
|
|
||||||
$run,
|
|
||||||
$tenant,
|
|
||||||
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void {
|
|
||||||
$processedPolicyTypes[] = $policyType;
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$successCount++;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failedCount++;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => count($policyTypes),
|
|
||||||
'processed' => count($policyTypes),
|
|
||||||
'succeeded' => count($policyTypes),
|
|
||||||
'failed' => 0,
|
|
||||||
// Reuse allowed keys for inventory item stats.
|
|
||||||
'items' => (int) $run->items_observed_count,
|
|
||||||
'updated' => (int) $run->items_upserted_count,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.completed',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
'observed' => $run->items_observed_count,
|
|
||||||
'upserted' => $run->items_upserted_count,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => count($policyTypes),
|
|
||||||
'processed' => count($policyTypes),
|
|
||||||
'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count),
|
|
||||||
'failed' => (int) $run->errors_count,
|
|
||||||
'items' => (int) $run->items_observed_count,
|
|
||||||
'updated' => (int) $run->items_upserted_count,
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.partial',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
'observed' => $run->items_observed_count,
|
|
||||||
'upserted' => $run->items_upserted_count,
|
|
||||||
'errors' => $run->errors_count,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
status: 'failure',
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
|
|
||||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
|
|
||||||
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => count($policyTypes),
|
|
||||||
'processed' => count($policyTypes),
|
|
||||||
'succeeded' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'skipped' => count($policyTypes),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
['code' => 'inventory.skipped', 'message' => $reason],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.skipped',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
'reason' => $reason,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
|
|
||||||
|
|
||||||
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
|
|
||||||
|
|
||||||
if ($this->operationRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'total' => count($policyTypes),
|
|
||||||
'processed' => count($policyTypes),
|
|
||||||
'succeeded' => $successCount,
|
|
||||||
'failed' => max($failedCount, count($missingPolicyTypes)),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
['code' => 'inventory.failed', 'message' => $reason],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.failed',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
'reason' => $reason,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
status: 'failure',
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Monitoring;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class OperationsDetail extends Component implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
// Ensure tenant scope
|
|
||||||
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
|
|
||||||
$this->run = $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.monitoring.operations-detail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class BackupScheduleRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const STATUS_RUNNING = 'running';
|
|
||||||
|
|
||||||
public const STATUS_SUCCESS = 'success';
|
|
||||||
|
|
||||||
public const STATUS_PARTIAL = 'partial';
|
|
||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
|
||||||
|
|
||||||
public const STATUS_CANCELED = 'canceled';
|
|
||||||
|
|
||||||
public const STATUS_SKIPPED = 'skipped';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'scheduled_for' => 'datetime',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'finished_at' => 'datetime',
|
|
||||||
'summary' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function schedule(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function backupSet(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(BackupSet::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class EntraGroupSyncRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
|
||||||
|
|
||||||
public const STATUS_RUNNING = 'running';
|
|
||||||
|
|
||||||
public const STATUS_SUCCEEDED = 'succeeded';
|
|
||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
|
||||||
|
|
||||||
public const STATUS_PARTIAL = 'partial';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'safety_stop_triggered' => 'boolean',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'finished_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function initiator(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'initiator_user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class Finding extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
|
||||||
|
|
||||||
public const string SEVERITY_LOW = 'low';
|
|
||||||
|
|
||||||
public const string SEVERITY_MEDIUM = 'medium';
|
|
||||||
|
|
||||||
public const string SEVERITY_HIGH = 'high';
|
|
||||||
|
|
||||||
public const string STATUS_NEW = 'new';
|
|
||||||
|
|
||||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'acknowledged_at' => 'datetime',
|
|
||||||
'evidence_jsonb' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function baselineRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(InventorySyncRun::class, 'current_run_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledgedByUser(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledge(User $user): void
|
|
||||||
{
|
|
||||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class InventorySyncRun extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\InventorySyncRunFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const STATUS_RUNNING = 'running';
|
|
||||||
|
|
||||||
public const STATUS_SUCCESS = 'success';
|
|
||||||
|
|
||||||
public const STATUS_PARTIAL = 'partial';
|
|
||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
|
||||||
|
|
||||||
public const STATUS_SKIPPED = 'skipped';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'selection_payload' => 'array',
|
|
||||||
'had_errors' => 'boolean',
|
|
||||||
'error_codes' => 'array',
|
|
||||||
'error_context' => 'array',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'finished_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeCompleted(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->whereIn('status', [
|
|
||||||
self::STATUS_SUCCESS,
|
|
||||||
self::STATUS_PARTIAL,
|
|
||||||
self::STATUS_FAILED,
|
|
||||||
self::STATUS_SKIPPED,
|
|
||||||
])
|
|
||||||
->whereNotNull('finished_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class OperationRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'summary_counts' => 'array',
|
|
||||||
'failure_summary' => 'array',
|
|
||||||
'context' => 'array',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', ['queued', 'running']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
class PolicyVersion extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'snapshot' => 'array',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'assignments' => 'array',
|
|
||||||
'scope_tags' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function previous(): ?self
|
|
||||||
{
|
|
||||||
return $this->policy
|
|
||||||
? $this->policy
|
|
||||||
->versions()
|
|
||||||
->where('version_number', '<', $this->version_number)
|
|
||||||
->orderByDesc('version_number')
|
|
||||||
->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function policy(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Policy::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopePruneEligible($query, int $days = 90)
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('captured_at', '<', now()->subDays($days))
|
|
||||||
->whereRaw(
|
|
||||||
'policy_versions.version_number < (select max(pv2.version_number) from policy_versions pv2 where pv2.policy_id = policy_versions.policy_id and pv2.deleted_at is null)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ProviderConnection extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_default' => 'boolean',
|
|
||||||
'scopes_granted' => 'array',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'last_health_check_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function credential(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function makeDefault(): void
|
|
||||||
{
|
|
||||||
DB::transaction(function (): void {
|
|
||||||
static::query()
|
|
||||||
->where('tenant_id', $this->tenant_id)
|
|
||||||
->where('provider', $this->provider)
|
|
||||||
->where('is_default', true)
|
|
||||||
->whereKeyNot($this->getKey())
|
|
||||||
->update(['is_default' => false]);
|
|
||||||
|
|
||||||
static::query()
|
|
||||||
->whereKey($this->getKey())
|
|
||||||
->update(['is_default' => true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class BackupScheduleRunDispatchedNotification extends Notification
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* tenant_id:int,
|
|
||||||
* trigger:string,
|
|
||||||
* scheduled_for:string,
|
|
||||||
* backup_schedule_id?:int,
|
|
||||||
* backup_schedule_run_id?:int,
|
|
||||||
* schedule_ids?:array<int, int>,
|
|
||||||
* backup_schedule_run_ids?:array<int, int>
|
|
||||||
* } $metadata
|
|
||||||
*/
|
|
||||||
public function __construct(public array $metadata) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$trigger = (string) ($this->metadata['trigger'] ?? 'run_now');
|
|
||||||
|
|
||||||
$title = match ($trigger) {
|
|
||||||
'retry' => 'Retry dispatched',
|
|
||||||
'bulk_retry' => 'Retries dispatched',
|
|
||||||
'bulk_run_now' => 'Runs dispatched',
|
|
||||||
default => 'Run dispatched',
|
|
||||||
};
|
|
||||||
|
|
||||||
$body = match ($trigger) {
|
|
||||||
'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.',
|
|
||||||
default => 'A backup run has been queued.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class OperationRunCompleted extends Notification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public OperationRun $run
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
return OperationUxPresenter::terminalDatabaseNotification(
|
|
||||||
run: $this->run,
|
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
|
||||||
)->getDatabaseMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Notifications;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
class RunStatusChangedNotification extends Notification
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* tenant_id:int,
|
|
||||||
* run_type:string,
|
|
||||||
* run_id:int,
|
|
||||||
* status:string,
|
|
||||||
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
|
|
||||||
* } $metadata
|
|
||||||
*/
|
|
||||||
public function __construct(public array $metadata) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$status = (string) ($this->metadata['status'] ?? 'queued');
|
|
||||||
$runType = (string) ($this->metadata['run_type'] ?? 'run');
|
|
||||||
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
|
|
||||||
$runId = (int) ($this->metadata['run_id'] ?? 0);
|
|
||||||
|
|
||||||
$title = match ($status) {
|
|
||||||
'queued' => 'Run queued',
|
|
||||||
'running' => 'Run started',
|
|
||||||
'completed', 'succeeded' => 'Run completed',
|
|
||||||
'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)',
|
|
||||||
'failed' => 'Run failed',
|
|
||||||
default => 'Run updated',
|
|
||||||
};
|
|
||||||
|
|
||||||
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
|
|
||||||
|
|
||||||
$color = match ($status) {
|
|
||||||
'queued', 'running' => 'gray',
|
|
||||||
'completed', 'succeeded' => 'success',
|
|
||||||
'partial', 'partially succeeded', 'completed_with_errors' => 'warning',
|
|
||||||
'failed' => 'danger',
|
|
||||||
default => 'gray',
|
|
||||||
};
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) {
|
|
||||||
$tenant = Tenant::query()->find($tenantId);
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
$url = match ($runType) {
|
|
||||||
'bulk_operation' => OperationRunLinks::view($runId, $tenant),
|
|
||||||
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
|
||||||
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (! $url) {
|
|
||||||
return [
|
|
||||||
'format' => 'filament',
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
'color' => $color,
|
|
||||||
'duration' => 'persistent',
|
|
||||||
'actions' => [],
|
|
||||||
'icon' => null,
|
|
||||||
'iconColor' => null,
|
|
||||||
'status' => null,
|
|
||||||
'view' => null,
|
|
||||||
'viewData' => [
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($url)
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'format' => 'filament',
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
'color' => $color,
|
|
||||||
'duration' => 'persistent',
|
|
||||||
'actions' => $actions,
|
|
||||||
'icon' => null,
|
|
||||||
'iconColor' => null,
|
|
||||||
'status' => null,
|
|
||||||
'view' => null,
|
|
||||||
'viewData' => [
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class BackupSchedulePolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
|
||||||
{
|
|
||||||
$tenant ??= Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return $this->isTenantMember($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $this->isTenantMember($user, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $schedule->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, BackupSchedule $schedule): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EntraGroupPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EntraGroup $group): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $group->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EntraGroupSyncRunPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EntraGroupSyncRun $run): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $run->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class FindingPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, Finding $finding): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $finding->tenant_id === (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, Finding $finding): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class OperationRunPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, OperationRun $run): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ProviderConnectionPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows('provider.view', $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, ProviderConnection $connection): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows('provider.manage', $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, ProviderConnection $connection): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, ProviderConnection $connection): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\RestoreRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserTenantPreference;
|
|
||||||
use App\Observers\RestoreRunObserver;
|
|
||||||
use App\Policies\BackupSchedulePolicy;
|
|
||||||
use App\Policies\EntraGroupPolicy;
|
|
||||||
use App\Policies\EntraGroupSyncRunPolicy;
|
|
||||||
use App\Policies\FindingPolicy;
|
|
||||||
use App\Policies\OperationRunPolicy;
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
|
||||||
use App\Services\Graph\MicrosoftGraphClient;
|
|
||||||
use App\Services\Graph\NullGraphClient;
|
|
||||||
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
|
||||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
|
||||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
|
||||||
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
|
|
||||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
|
||||||
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
|
||||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
|
||||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
|
||||||
use App\Services\Intune\TermsAndConditionsNormalizer;
|
|
||||||
use App\Services\Intune\WindowsDriverUpdateProfileNormalizer;
|
|
||||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
|
||||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
|
||||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions\Action as FilamentAction;
|
|
||||||
use Filament\Actions\BulkAction as FilamentBulkAction;
|
|
||||||
use Filament\Events\TenantSet;
|
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->app->singleton(GraphClientInterface::class, function ($app) {
|
|
||||||
$config = $app['config']->get('graph');
|
|
||||||
|
|
||||||
if (! empty($config['enabled'])) {
|
|
||||||
return $app->make(MicrosoftGraphClient::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $app->make(NullGraphClient::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->app->tag(
|
|
||||||
[
|
|
||||||
AppProtectionPolicyNormalizer::class,
|
|
||||||
CompliancePolicyNormalizer::class,
|
|
||||||
DeviceConfigurationPolicyNormalizer::class,
|
|
||||||
EnrollmentAutopilotPolicyNormalizer::class,
|
|
||||||
GroupPolicyConfigurationNormalizer::class,
|
|
||||||
ManagedDeviceAppConfigurationNormalizer::class,
|
|
||||||
ScriptsPolicyNormalizer::class,
|
|
||||||
SettingsCatalogPolicyNormalizer::class,
|
|
||||||
TermsAndConditionsNormalizer::class,
|
|
||||||
WindowsDriverUpdateProfileNormalizer::class,
|
|
||||||
WindowsFeatureUpdateProfileNormalizer::class,
|
|
||||||
WindowsQualityUpdateProfileNormalizer::class,
|
|
||||||
WindowsUpdateRingNormalizer::class,
|
|
||||||
],
|
|
||||||
'policy-type-normalizers'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
if (! FilamentAction::hasMacro('requireCapability')) {
|
|
||||||
FilamentAction::macro('requireCapability', function (string $capability): FilamentAction {
|
|
||||||
UiEnforcement::forAction($this)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability($capability)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! FilamentBulkAction::hasMacro('requireCapability')) {
|
|
||||||
FilamentBulkAction::macro('requireCapability', function (string $capability): FilamentBulkAction {
|
|
||||||
UiEnforcement::forBulkAction($this)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability($capability)
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::for('entra-callback', function (Request $request) {
|
|
||||||
return Limit::perMinute(20)->by((string) $request->ip());
|
|
||||||
});
|
|
||||||
|
|
||||||
RestoreRun::observe(RestoreRunObserver::class);
|
|
||||||
|
|
||||||
Event::listen(TenantSet::class, function (TenantSet $event): void {
|
|
||||||
static $hasPreferencesTable;
|
|
||||||
|
|
||||||
$hasPreferencesTable ??= Schema::hasTable('user_tenant_preferences');
|
|
||||||
|
|
||||||
if (! $hasPreferencesTable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $event->getTenant();
|
|
||||||
$user = $event->getUser();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserTenantPreference::query()->updateOrCreate(
|
|
||||||
[
|
|
||||||
'user_id' => $user->getKey(),
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'last_used_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
|
||||||
Gate::policy(Finding::class, FindingPolicy::class);
|
|
||||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
|
||||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
|
||||||
Gate::policy(OperationRun::class, OperationRunPolicy::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
protected $policies = [
|
|
||||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
$this->registerPolicies();
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
|
||||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
|
||||||
return $resolver->can($user, $tenant, $capability);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (Capabilities::all() as $capability) {
|
|
||||||
$defineTenantCapability($capability);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PlatformCapabilities::all() as $capability) {
|
|
||||||
Gate::define($capability, function (PlatformUser $user) use ($capability): bool {
|
|
||||||
return $user->hasCapability($capability);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Auth;
|
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class PostLoginRedirectResolver
|
|
||||||
{
|
|
||||||
public function resolve(User $user): string
|
|
||||||
{
|
|
||||||
$tenants = $this->getActiveTenants($user);
|
|
||||||
|
|
||||||
if ($tenants->isEmpty()) {
|
|
||||||
return '/admin/no-access';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenants->count() === 1) {
|
|
||||||
/** @var Tenant $tenant */
|
|
||||||
$tenant = $tenants->first();
|
|
||||||
|
|
||||||
return TenantDashboard::getUrl(tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/admin/choose-tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function getActiveTenants(User $user): Collection
|
|
||||||
{
|
|
||||||
return $user->tenants()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user