Compare commits
37 Commits
083-requir
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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"
|
||||||
25
.github/agents/copilot-instructions.md
vendored
25
.github/agents/copilot-instructions.md
vendored
@ -22,6 +22,23 @@ ## Active Technologies
|
|||||||
- PostgreSQL (via Sail) (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.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.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.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -41,10 +58,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||||
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
|
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
|
||||||
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
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
|
||||||
@ -1,17 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.7.0 → 1.8.0
|
- Version change: 1.9.0 → 1.10.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
|
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
|
||||||
- Added sections: None
|
- Added sections:
|
||||||
|
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||||
|
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
||||||
|
- Summary counts contract (OPS-UX-SUM-001)
|
||||||
|
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||||
|
- Scheduled/system runs (OPS-UX-SYS-001)
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs:
|
||||||
|
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
@ -38,18 +44,42 @@ ### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### 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:
|
||||||
@ -67,15 +97,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
|
||||||
@ -97,9 +127,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:
|
||||||
@ -129,6 +162,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.
|
||||||
@ -147,11 +246,13 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
||||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
||||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
||||||
|
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
||||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
- Create/Edit MUST provide consistent Save/Cancel UX.
|
||||||
|
|
||||||
Grouping & safety
|
Grouping & safety
|
||||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
||||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
- Bulk actions MUST be grouped via BulkActionGroup.
|
||||||
|
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
||||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
||||||
- Relevant mutations MUST write an audit log entry.
|
- Relevant mutations MUST write an audit log entry.
|
||||||
|
|
||||||
@ -163,7 +264,50 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
Spec / DoD gates
|
Spec / DoD gates
|
||||||
- Every spec MUST include a “UI Action Matrix”.
|
- Every spec MUST include a “UI Action Matrix”.
|
||||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
||||||
- CI MUST enforce the contract (test/command) and block merges on violations.
|
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
||||||
|
|
||||||
|
### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||||
|
|
||||||
|
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
|
||||||
|
|
||||||
|
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 be inside Sections/Cards. No “naked” inputs at the root schema level.
|
||||||
|
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
|
||||||
|
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
|
||||||
|
|
||||||
|
View pages
|
||||||
|
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
|
||||||
|
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
|
||||||
|
- Long text MUST render as readable prose (not textarea styling).
|
||||||
|
|
||||||
|
Empty states
|
||||||
|
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
|
||||||
|
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
|
||||||
|
|
||||||
|
Actions & flows
|
||||||
|
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
|
||||||
|
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
|
||||||
|
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
|
||||||
|
|
||||||
|
Table work-surface defaults
|
||||||
|
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
|
||||||
|
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
|
||||||
|
|
||||||
|
Enforcement
|
||||||
|
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
||||||
|
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
||||||
|
|
||||||
|
Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
|
- Every feature spec MUST declare:
|
||||||
|
- Scope: workspace | tenant | canonical-view
|
||||||
|
- Primary Routes
|
||||||
|
- 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 that prevent cross-tenant leakage
|
||||||
|
|
||||||
### Data Minimization & Safe Logging
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
@ -200,4 +344,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.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
|
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23
|
||||||
|
|||||||
@ -35,16 +35,21 @@ ## 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
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
|
|||||||
@ -5,6 +5,18 @@ # 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]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -82,11 +94,18 @@ ## 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 (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),
|
||||||
@ -103,7 +122,11 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
**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.
|
||||||
|
|||||||
@ -14,12 +14,20 @@ # 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,
|
||||||
@ -33,6 +41,14 @@ # Tasks: [FEATURE NAME]
|
|||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
|
- 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),
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
|||||||
357
Agents.md
357
Agents.md
@ -389,6 +389,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 +399,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 +415,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,6 +424,7 @@ ## 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.
|
||||||
@ -434,6 +438,7 @@ ## 3) Panel setup defaults
|
|||||||
- 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 +452,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 +465,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 +474,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 +485,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 +495,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 +508,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 +518,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 +527,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 +537,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 +547,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 +562,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 +583,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 +591,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 +606,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 +615,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 +638,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,24 +646,28 @@ ## 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.
|
- [ ] `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”
|
||||||
|
|
||||||
@ -648,12 +675,13 @@ ## Deployment / Ops
|
|||||||
|
|
||||||
# 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.1
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -666,56 +694,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.
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Replies
|
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
|
||||||
|
|
||||||
## 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,38 +770,44 @@ ### 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 `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
@ -770,20 +821,21 @@ ## Laravel Sail
|
|||||||
|
|
||||||
=== 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 `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 `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 `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 +843,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 `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 `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 `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== 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 +897,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 `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 `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/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: `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: `vendor/bin/sail artisan test --compact` or filter: `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
|
||||||
|
|||||||
357
GEMINI.md
357
GEMINI.md
@ -229,6 +229,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 +239,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 +255,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,6 +264,7 @@ ## 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.
|
||||||
@ -274,6 +278,7 @@ ## 3) Panel setup defaults
|
|||||||
- 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 +292,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 +305,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 +314,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 +325,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 +335,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 +348,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 +358,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 +367,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 +377,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 +387,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 +402,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 +423,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 +431,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 +446,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 +455,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 +478,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,24 +486,28 @@ ## 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.
|
- [ ] `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”
|
||||||
|
|
||||||
@ -488,12 +515,13 @@ ## Deployment / Ops
|
|||||||
|
|
||||||
# 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.1
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -506,56 +534,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.
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||||
|
|
||||||
## Replies
|
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
|
||||||
|
|
||||||
## 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,38 +610,44 @@ ### 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 `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||||
@ -610,20 +661,21 @@ ## Laravel Sail
|
|||||||
|
|
||||||
=== 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 `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 `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 `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 +683,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 `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 `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 `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== 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 +737,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 `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 `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/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: `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: `vendor/bin/sail artisan test --compact` or filter: `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
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class GraphContractCheck extends Command
|
class GraphContractCheck extends Command
|
||||||
@ -11,7 +12,7 @@ class GraphContractCheck extends Command
|
|||||||
|
|
||||||
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
|
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
|
||||||
|
|
||||||
public function handle(GraphClientInterface $graph): int
|
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int
|
||||||
{
|
{
|
||||||
$contracts = config('graph_contracts.types', []);
|
$contracts = config('graph_contracts.types', []);
|
||||||
|
|
||||||
@ -36,11 +37,13 @@ public function handle(GraphClientInterface $graph): int
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = array_filter([
|
$queryInput = array_filter([
|
||||||
'$top' => 1,
|
'$top' => 1,
|
||||||
'$select' => $select,
|
'$select' => $select,
|
||||||
'$expand' => $expand,
|
'$expand' => $expand,
|
||||||
]);
|
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []);
|
||||||
|
|
||||||
|
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
|
||||||
|
|
||||||
$response = $graph->request('GET', $resource, [
|
$response = $graph->request('GET', $resource, [
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
|
|||||||
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PruneReviewPacksCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$expired = $this->expireReadyPacks();
|
||||||
|
$hardDeleted = 0;
|
||||||
|
|
||||||
|
if ($this->option('hard-delete')) {
|
||||||
|
$hardDeleted = $this->hardDeleteExpiredPacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition ready packs past retention to expired and delete their files.
|
||||||
|
*/
|
||||||
|
private function expireReadyPacks(): int
|
||||||
|
{
|
||||||
|
$packs = ReviewPack::query()
|
||||||
|
->ready()
|
||||||
|
->pastRetention()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$disk = Storage::disk('exports');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($packs as $pack) {
|
||||||
|
/** @var ReviewPack $pack */
|
||||||
|
if ($pack->file_path && $disk->exists($pack->file_path)) {
|
||||||
|
$disk->delete($pack->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-delete expired packs that are past the grace period.
|
||||||
|
*/
|
||||||
|
private function hardDeleteExpiredPacks(): int
|
||||||
|
{
|
||||||
|
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||||
|
|
||||||
|
$cutoff = now()->subDays($graceDays);
|
||||||
|
|
||||||
|
return ReviewPack::query()
|
||||||
|
->expired()
|
||||||
|
->where('updated_at', '<', $cutoff)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\StoredReport;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PruneStoredReportsCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'stored-reports:prune {--days= : Number of days to retain reports}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete stored reports older than the retention period';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) ($this->option('days') ?: config('tenantpilot.stored_reports.retention_days', 90));
|
||||||
|
|
||||||
|
if ($days < 1) {
|
||||||
|
$this->error('Retention days must be at least 1.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
$deleted = StoredReport::query()
|
||||||
|
->where('created_at', '<', $cutoff)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Deleted {$deleted} stored report(s) older than {$days} days.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
120
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class TenantpilotBackfillFindingLifecycle extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||||
|
{--tenant=* : Limit to tenant_id/external_id}';
|
||||||
|
|
||||||
|
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||||
|
|
||||||
|
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||||
|
{
|
||||||
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
|
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||||
|
|
||||||
|
if ($tenants->isEmpty()) {
|
||||||
|
$this->info('No tenants matched the provided identifiers.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queued = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$nothingToDo = 0;
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $runbookService->start(
|
||||||
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
|
initiator: null,
|
||||||
|
reason: null,
|
||||||
|
source: 'cli',
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
$errors = $e->errors();
|
||||||
|
|
||||||
|
if (isset($errors['preflight.affected_count'])) {
|
||||||
|
$nothingToDo++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error(sprintf(
|
||||||
|
'Backfill blocked for tenant %d: %s',
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$e->getMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
|
||||||
|
$queued,
|
||||||
|
$skipped,
|
||||||
|
$nothingToDo,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||||
|
*/
|
||||||
|
private function resolveTenants(array $tenantIdentifiers)
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant($identifier)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = array_values(array_unique($tenantIds));
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal file
343
app/Console/Commands/TenantpilotBackfillWorkspaceIds.php
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\BackfillWorkspaceIdsJob;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\WorkspaceIsolation\TenantOwnedTables;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TenantpilotBackfillWorkspaceIds extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:backfill-workspace-ids
|
||||||
|
{--dry-run : Print per-table counts only}
|
||||||
|
{--table= : Restrict to a single tenant-owned table}
|
||||||
|
{--batch-size=5000 : Rows per queued chunk}
|
||||||
|
{--resume-from=0 : Resume from id cursor}
|
||||||
|
{--max-rows= : Maximum rows to process per table job}';
|
||||||
|
|
||||||
|
protected $description = 'Backfill missing workspace_id across tenant-owned tables.';
|
||||||
|
|
||||||
|
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): int
|
||||||
|
{
|
||||||
|
$tables = $this->resolveTables();
|
||||||
|
|
||||||
|
if ($tables === []) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchSize = max(1, (int) $this->option('batch-size'));
|
||||||
|
$resumeFrom = max(0, (int) $this->option('resume-from'));
|
||||||
|
$maxRows = $this->normalizeMaxRows();
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$lock = Cache::lock('tenantpilot:backfill-workspace-ids', 900);
|
||||||
|
|
||||||
|
if (! $lock->get()) {
|
||||||
|
$this->error('Another workspace backfill is already running.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tableStats = $this->collectTableStats($tables);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Table', 'Missing workspace_id', 'Unresolvable tenant mapping', 'Sample row ids'],
|
||||||
|
array_map(static function (array $stats): array {
|
||||||
|
return [
|
||||||
|
$stats['table'],
|
||||||
|
$stats['missing'],
|
||||||
|
$stats['unresolvable'],
|
||||||
|
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
|
||||||
|
];
|
||||||
|
}, $tableStats),
|
||||||
|
);
|
||||||
|
|
||||||
|
$unresolvable = array_values(array_filter($tableStats, static fn (array $stats): bool => $stats['unresolvable'] > 0));
|
||||||
|
|
||||||
|
if ($unresolvable !== []) {
|
||||||
|
foreach ($unresolvable as $stats) {
|
||||||
|
$this->error(sprintf(
|
||||||
|
'Unresolvable tenant->workspace mapping in %s (%d rows). Sample ids: %s',
|
||||||
|
$stats['table'],
|
||||||
|
$stats['unresolvable'],
|
||||||
|
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('Dry-run complete. No changes written.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceWorkloads = $this->collectWorkspaceWorkloads($tables, $maxRows);
|
||||||
|
|
||||||
|
if ($workspaceWorkloads === []) {
|
||||||
|
$this->info('No rows require workspace_id backfill.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatchedJobs = 0;
|
||||||
|
|
||||||
|
foreach ($workspaceWorkloads as $workspaceId => $workload) {
|
||||||
|
$workspace = Workspace::query()->find($workspaceId);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $operationRunService->ensureWorkspaceRunWithIdentity(
|
||||||
|
workspace: $workspace,
|
||||||
|
type: 'workspace_isolation_backfill_workspace_ids',
|
||||||
|
identityInputs: [
|
||||||
|
'tables' => array_keys($workload['tables']),
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'source' => 'tenantpilot:backfill-workspace-ids',
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'batch_size' => $batchSize,
|
||||||
|
'max_rows' => $maxRows,
|
||||||
|
'resume_from' => $resumeFrom,
|
||||||
|
'tables' => array_keys($workload['tables']),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Workspace %d already has an active backfill run (#%d).',
|
||||||
|
(int) $workspace->getKey(),
|
||||||
|
(int) $run->getKey(),
|
||||||
|
));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tableProgress = [];
|
||||||
|
foreach ($workload['tables'] as $table => $count) {
|
||||||
|
$tableProgress[$table] = [
|
||||||
|
'target_rows' => (int) $count,
|
||||||
|
'processed' => 0,
|
||||||
|
'last_processed_id' => $resumeFrom,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$context['table_progress'] = $tableProgress;
|
||||||
|
|
||||||
|
$run->update([
|
||||||
|
'context' => $context,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => (int) $workload['total'],
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->updateRun($run, status: 'running');
|
||||||
|
|
||||||
|
$workspaceAuditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: 'workspace_isolation.backfill_workspace_ids.started',
|
||||||
|
context: [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'tables' => array_keys($workload['tables']),
|
||||||
|
'planned_rows' => (int) $workload['total'],
|
||||||
|
'batch_size' => $batchSize,
|
||||||
|
],
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $run->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspaceJobs = 0;
|
||||||
|
|
||||||
|
foreach ($workload['tables'] as $table => $tableRows) {
|
||||||
|
if ($tableRows <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackfillWorkspaceIdsJob::dispatch(
|
||||||
|
operationRunId: (int) $run->getKey(),
|
||||||
|
workspaceId: (int) $workspace->getKey(),
|
||||||
|
table: $table,
|
||||||
|
batchSize: $batchSize,
|
||||||
|
maxRows: $maxRows,
|
||||||
|
resumeFrom: $resumeFrom,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspaceJobs++;
|
||||||
|
$dispatchedJobs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceAuditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: 'workspace_isolation.backfill_workspace_ids.dispatched',
|
||||||
|
context: [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'jobs_dispatched' => $workspaceJobs,
|
||||||
|
'tables' => array_keys($workload['tables']),
|
||||||
|
],
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $run->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Workspace %d run #%d queued (%d job(s)).',
|
||||||
|
(int) $workspace->getKey(),
|
||||||
|
(int) $run->getKey(),
|
||||||
|
$workspaceJobs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Backfill jobs dispatched: %d', $dispatchedJobs));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function resolveTables(): array
|
||||||
|
{
|
||||||
|
$selectedTable = $this->option('table');
|
||||||
|
|
||||||
|
if (! is_string($selectedTable) || trim($selectedTable) === '') {
|
||||||
|
return TenantOwnedTables::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedTable = trim($selectedTable);
|
||||||
|
|
||||||
|
if (! TenantOwnedTables::contains($selectedTable)) {
|
||||||
|
$this->error(sprintf('Unknown tenant-owned table: %s', $selectedTable));
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$selectedTable];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeMaxRows(): ?int
|
||||||
|
{
|
||||||
|
$maxRows = $this->option('max-rows');
|
||||||
|
|
||||||
|
if (! is_numeric($maxRows)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxRows = (int) $maxRows;
|
||||||
|
|
||||||
|
return $maxRows > 0 ? $maxRows : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tables
|
||||||
|
* @return array<int, array{table: string, missing: int, unresolvable: int, sample_ids: array<int, int>}>
|
||||||
|
*/
|
||||||
|
private function collectTableStats(array $tables): array
|
||||||
|
{
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
|
||||||
|
|
||||||
|
$unresolvableQuery = DB::table($table)
|
||||||
|
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||||
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query->whereNull('tenants.id')
|
||||||
|
->orWhereNull('tenants.workspace_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
$unresolvable = (int) $unresolvableQuery->count();
|
||||||
|
|
||||||
|
$sampleIds = DB::table($table)
|
||||||
|
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||||
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query->whereNull('tenants.id')
|
||||||
|
->orWhereNull('tenants.workspace_id');
|
||||||
|
})
|
||||||
|
->orderBy(sprintf('%s.id', $table))
|
||||||
|
->limit(5)
|
||||||
|
->pluck(sprintf('%s.id', $table))
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$stats[] = [
|
||||||
|
'table' => $table,
|
||||||
|
'missing' => $missing,
|
||||||
|
'unresolvable' => $unresolvable,
|
||||||
|
'sample_ids' => $sampleIds,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tables
|
||||||
|
* @return array<int, array{total: int, tables: array<string, int>}>
|
||||||
|
*/
|
||||||
|
private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
|
||||||
|
{
|
||||||
|
$workloads = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$rows = DB::table($table)
|
||||||
|
->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
|
||||||
|
->whereNull(sprintf('%s.workspace_id', $table))
|
||||||
|
->whereNotNull('tenants.workspace_id')
|
||||||
|
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
|
||||||
|
->groupBy('tenants.workspace_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$workspaceId = (int) $row->workspace_id;
|
||||||
|
|
||||||
|
if ($workspaceId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowCount = (int) $row->row_count;
|
||||||
|
|
||||||
|
if ($maxRows !== null) {
|
||||||
|
$rowCount = min($rowCount, $maxRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rowCount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($workloads[$workspaceId])) {
|
||||||
|
$workloads[$workspaceId] = [
|
||||||
|
'total' => 0,
|
||||||
|
'tables' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$workloads[$workspaceId]['tables'][$table] = $rowCount;
|
||||||
|
$workloads[$workspaceId]['total'] += $rowCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $workloads;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||||
|
use App\Jobs\Alerts\EvaluateAlertsJob;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class TenantpilotDispatchAlerts extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
|
||||||
|
|
||||||
|
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
|
||||||
|
|
||||||
|
public function handle(OperationRunService $operationRuns): int
|
||||||
|
{
|
||||||
|
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceFilter = array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $value): int => (int) $value,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
)));
|
||||||
|
|
||||||
|
$workspaces = $this->resolveWorkspaces($workspaceFilter);
|
||||||
|
|
||||||
|
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||||
|
|
||||||
|
$queuedEvaluate = 0;
|
||||||
|
$queuedDeliver = 0;
|
||||||
|
$skippedEvaluate = 0;
|
||||||
|
$skippedDeliver = 0;
|
||||||
|
|
||||||
|
foreach ($workspaces as $workspace) {
|
||||||
|
$evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||||
|
workspace: $workspace,
|
||||||
|
type: 'alerts.evaluate',
|
||||||
|
identityInputs: ['slot_key' => $slotKey],
|
||||||
|
context: [
|
||||||
|
'trigger' => 'scheduled_dispatch',
|
||||||
|
'slot_key' => $slotKey,
|
||||||
|
],
|
||||||
|
initiator: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($evaluateRun->wasRecentlyCreated) {
|
||||||
|
EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey());
|
||||||
|
$queuedEvaluate++;
|
||||||
|
} else {
|
||||||
|
$skippedEvaluate++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||||
|
workspace: $workspace,
|
||||||
|
type: 'alerts.deliver',
|
||||||
|
identityInputs: ['slot_key' => $slotKey],
|
||||||
|
context: [
|
||||||
|
'trigger' => 'scheduled_dispatch',
|
||||||
|
'slot_key' => $slotKey,
|
||||||
|
],
|
||||||
|
initiator: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($deliverRun->wasRecentlyCreated) {
|
||||||
|
DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey());
|
||||||
|
$queuedDeliver++;
|
||||||
|
} else {
|
||||||
|
$skippedDeliver++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.',
|
||||||
|
$workspaces->count(),
|
||||||
|
$queuedEvaluate,
|
||||||
|
$skippedEvaluate,
|
||||||
|
$queuedDeliver,
|
||||||
|
$skippedDeliver,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $workspaceIds
|
||||||
|
* @return Collection<int, Workspace>
|
||||||
|
*/
|
||||||
|
private function resolveWorkspaces(array $workspaceIds): Collection
|
||||||
|
{
|
||||||
|
return Workspace::query()
|
||||||
|
->when(
|
||||||
|
$workspaceIds !== [],
|
||||||
|
fn ($query) => $query->whereIn('id', $workspaceIds),
|
||||||
|
fn ($query) => $query->whereHas('tenants'),
|
||||||
|
)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,9 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
||||||
{
|
{
|
||||||
@ -46,27 +46,38 @@ public function handle(): int
|
|||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
|
/** @var OperationRunService $opService */
|
||||||
'tenant_id' => $tenant->getKey(),
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'entra_group_sync',
|
||||||
|
identityInputs: [
|
||||||
'selection_key' => $selectionKey,
|
'selection_key' => $selectionKey,
|
||||||
'slot_key' => $slotKey,
|
'slot_key' => $slotKey,
|
||||||
'status' => 'pending',
|
],
|
||||||
'initiator_user_id' => null,
|
context: [
|
||||||
'created_at' => $now,
|
'selection_key' => $selectionKey,
|
||||||
'updated_at' => $now,
|
'slot_key' => $slotKey,
|
||||||
]);
|
'trigger' => 'scheduled',
|
||||||
|
],
|
||||||
|
initiator: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($inserted === 1) {
|
|
||||||
$created++;
|
$created++;
|
||||||
|
|
||||||
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
||||||
tenantId: $tenant->getKey(),
|
tenantId: $tenant->getKey(),
|
||||||
selectionKey: $selectionKey,
|
selectionKey: $selectionKey,
|
||||||
slotKey: $slotKey,
|
slotKey: $slotKey,
|
||||||
|
runId: null,
|
||||||
|
operationRun: $opRun,
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
$skipped++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info(sprintf(
|
$this->info(sprintf(
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
@ -14,6 +13,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class TenantpilotPurgeNonPersistentData extends Command
|
class TenantpilotPurgeNonPersistentData extends Command
|
||||||
@ -80,10 +80,6 @@ public function handle(): int
|
|||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($tenant): void {
|
DB::transaction(function () use ($tenant): void {
|
||||||
BackupScheduleRun::query()
|
|
||||||
->where('tenant_id', $tenant->id)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
BackupSchedule::query()
|
BackupSchedule::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
@ -117,6 +113,8 @@ public function handle(): int
|
|||||||
->delete();
|
->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->recordPurgeOperationRun($tenant, $counts);
|
||||||
|
|
||||||
$this->info('Purged.');
|
$this->info('Purged.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +148,6 @@ private function resolveTenants()
|
|||||||
private function countsForTenant(Tenant $tenant): array
|
private function countsForTenant(Tenant $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
|
||||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
@ -161,4 +158,39 @@ private function countsForTenant(Tenant $tenant): array
|
|||||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $counts
|
||||||
|
*/
|
||||||
|
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||||
|
{
|
||||||
|
OperationRun::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->id,
|
||||||
|
'user_id' => null,
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'type' => 'backup_schedule_purge',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'run_identity_hash' => hash('sha256', implode(':', [
|
||||||
|
(string) $tenant->id,
|
||||||
|
'backup_schedule_purge',
|
||||||
|
now()->toISOString(),
|
||||||
|
Str::uuid()->toString(),
|
||||||
|
])),
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => array_sum($counts),
|
||||||
|
'processed' => array_sum($counts),
|
||||||
|
'succeeded' => array_sum($counts),
|
||||||
|
'failed' => 0,
|
||||||
|
],
|
||||||
|
'failure_summary' => [],
|
||||||
|
'context' => [
|
||||||
|
'source' => 'tenantpilot:purge-nonpersistent',
|
||||||
|
'deleted_rows' => $counts,
|
||||||
|
],
|
||||||
|
'started_at' => now(),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
{--older-than=5 : Only reconcile runs older than N minutes}
|
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||||
{--dry-run : Do not write changes}';
|
{--dry-run : Do not write changes}';
|
||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): int
|
public function handle(OperationRunService $operationRunService): int
|
||||||
{
|
{
|
||||||
@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
->where('type', 'backup_schedule_run')
|
||||||
->whereIn('status', ['queued', 'running']);
|
->whereIn('status', ['queued', 'running']);
|
||||||
|
|
||||||
if ($olderThanMinutes > 0) {
|
if ($olderThanMinutes > 0) {
|
||||||
@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
foreach ($query->cursor() as $operationRun) {
|
foreach ($query->cursor() as $operationRun) {
|
||||||
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
|
$backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
|
||||||
|
|
||||||
if (! is_numeric($backupScheduleRunId)) {
|
if (! is_numeric($backupScheduleId)) {
|
||||||
$skipped++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduleRun = BackupScheduleRun::query()
|
|
||||||
->whereKey((int) $backupScheduleRunId)
|
|
||||||
->where('tenant_id', $operationRun->tenant_id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $scheduleRun) {
|
|
||||||
if (! $dryRun) {
|
if (! $dryRun) {
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
outcome: 'failed',
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
failures: [
|
failures: [
|
||||||
[
|
[
|
||||||
'code' => 'backup_schedule_run.not_found',
|
'code' => 'backup_schedule.missing_context',
|
||||||
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
|
'message' => 'Backup schedule context is missing from this operation run.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -82,118 +71,62 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
|
$schedule = BackupSchedule::query()
|
||||||
|
->whereKey((int) $backupScheduleId)
|
||||||
|
->where('tenant_id', (int) $operationRun->tenant_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $schedule instanceof BackupSchedule) {
|
||||||
if (! $dryRun) {
|
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(
|
$operationRunService->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
outcome: $outcome,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
summaryCounts: $summaryCounts,
|
failures: [
|
||||||
failures: $failures,
|
[
|
||||||
|
'code' => 'backup_schedule.not_found',
|
||||||
|
'message' => 'Backup schedule not found for this operation run.',
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$operationRun->forceFill([
|
$failed++;
|
||||||
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
|
|
||||||
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
|
continue;
|
||||||
])->save();
|
}
|
||||||
|
|
||||||
|
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operationRun->status === 'running') {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'backup_schedule.stalled',
|
||||||
|
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$skipped++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info(sprintf(
|
$this->info(sprintf(
|
||||||
|
|||||||
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Services\Runbooks\RunbookReason;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class TenantpilotRunDeployRunbooks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||||
|
|
||||||
|
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||||
|
|
||||||
|
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$runbookService->start(
|
||||||
|
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||||
|
initiator: null,
|
||||||
|
reason: new RunbookReason(
|
||||||
|
reasonCode: RunbookReason::CODE_DATA_REPAIR,
|
||||||
|
reasonText: 'Deploy hook automated runbooks',
|
||||||
|
),
|
||||||
|
source: 'deploy_hook',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info('Deploy runbooks started (if needed).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
$errors = $e->errors();
|
||||||
|
|
||||||
|
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
|
||||||
|
|
||||||
|
if ($skippable) {
|
||||||
|
$this->info('Deploy runbooks skipped (nothing to do or already running).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Deploy runbooks blocked by validation errors.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Contracts/Hardening/WriteGateInterface.php
Normal file
23
app/Contracts/Hardening/WriteGateInterface.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Contracts\Hardening;
|
||||||
|
|
||||||
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
interface WriteGateInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluate whether a write operation is allowed for the given tenant.
|
||||||
|
*
|
||||||
|
* @throws ProviderAccessHardeningRequired when the operation is blocked
|
||||||
|
*/
|
||||||
|
public function evaluate(Tenant $tenant, string $operationType): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the gate would block a write operation for the given tenant.
|
||||||
|
*
|
||||||
|
* Non-throwing variant for UI disabled-state checks.
|
||||||
|
*/
|
||||||
|
public function wouldBlock(Tenant $tenant): bool;
|
||||||
|
}
|
||||||
17
app/Exceptions/Hardening/ProviderAccessHardeningRequired.php
Normal file
17
app/Exceptions/Hardening/ProviderAccessHardeningRequired.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\Hardening;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ProviderAccessHardeningRequired extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $tenantId,
|
||||||
|
public readonly string $operationType,
|
||||||
|
public readonly string $reasonCode,
|
||||||
|
public readonly string $reasonMessage,
|
||||||
|
) {
|
||||||
|
parent::__construct($reasonMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,10 +7,15 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Clusters\Cluster;
|
use Filament\Clusters\Cluster;
|
||||||
use Filament\Pages\Enums\SubNavigationPosition;
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryCluster extends Cluster
|
class InventoryCluster extends Cluster
|
||||||
{
|
{
|
||||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Items';
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\Monitoring;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Clusters\Cluster;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AlertsCluster extends Cluster
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
return Filament::getCurrentPanel()?->getId() === 'admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
304
app/Filament/Pages/BaselineCompareLanding.php
Normal file
304
app/Filament/Pages/BaselineCompareLanding.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BaselineCompareLanding extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Baseline Compare';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Baseline Compare';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.baseline-compare-landing';
|
||||||
|
|
||||||
|
public ?string $state = null;
|
||||||
|
|
||||||
|
public ?string $message = null;
|
||||||
|
|
||||||
|
public ?string $profileName = null;
|
||||||
|
|
||||||
|
public ?int $profileId = null;
|
||||||
|
|
||||||
|
public ?int $snapshotId = null;
|
||||||
|
|
||||||
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
|
public ?int $findingsCount = null;
|
||||||
|
|
||||||
|
/** @var array<string, int>|null */
|
||||||
|
public ?array $severityCounts = null;
|
||||||
|
|
||||||
|
public ?string $lastComparedAt = null;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->state = 'no_tenant';
|
||||||
|
$this->message = 'No tenant selected.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
|
$this->state = 'no_assignment';
|
||||||
|
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $assignment->baselineProfile;
|
||||||
|
|
||||||
|
if ($profile === null) {
|
||||||
|
$this->state = 'no_assignment';
|
||||||
|
$this->message = 'The assigned baseline profile no longer exists.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->profileName = (string) $profile->name;
|
||||||
|
$this->profileId = (int) $profile->getKey();
|
||||||
|
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||||
|
|
||||||
|
if ($this->snapshotId === null) {
|
||||||
|
$this->state = 'no_snapshot';
|
||||||
|
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'baseline_compare')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||||
|
$this->state = 'comparing';
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
$this->message = 'A baseline comparison is currently in progress.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
||||||
|
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
|
$findingsQuery = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey);
|
||||||
|
|
||||||
|
$totalFindings = (int) (clone $findingsQuery)->count();
|
||||||
|
|
||||||
|
if ($totalFindings > 0) {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->findingsCount = $totalFindings;
|
||||||
|
$this->severityCounts = [
|
||||||
|
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
|
||||||
|
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
||||||
|
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun) {
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->findingsCount = 0;
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = 'idle';
|
||||||
|
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->compareNowAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareNowAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('compareNow')
|
||||||
|
->label('Compare Now')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Start baseline comparison')
|
||||||
|
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
||||||
|
->visible(fn (): bool => $this->canCompare())
|
||||||
|
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
Notification::make()->title('Not authenticated')->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
Notification::make()->title('No tenant context')->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
if (! ($result['ok'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot start comparison')
|
||||||
|
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $result['run'] ?? null;
|
||||||
|
|
||||||
|
if ($run instanceof OperationRun) {
|
||||||
|
$this->operationRunId = (int) $run->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = 'comparing';
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||||
|
->actions($run instanceof OperationRun ? [
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($run, $tenant)),
|
||||||
|
] : [])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canCompare(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFindingsUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if ($this->operationRunId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,17 @@ class ChooseTenant extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.choose-tenant';
|
protected string $view = 'filament.pages.choose-tenant';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the simple-layout topbar to prevent lazy-loaded
|
||||||
|
* DatabaseNotifications from triggering Livewire update 404s.
|
||||||
|
*/
|
||||||
|
protected function getLayoutData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hasTopbar' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, Tenant>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,10 +7,11 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Filament\Actions\Action;
|
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@ -30,33 +31,18 @@ class ChooseWorkspace extends Page
|
|||||||
protected string $view = 'filament.pages.choose-workspace';
|
protected string $view = 'filament.pages.choose-workspace';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* Workspace roles keyed by workspace_id.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public array $workspaceRoles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<\Filament\Actions\Action>
|
||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
Action::make('createWorkspace')
|
|
||||||
->label('Create workspace')
|
|
||||||
->modalHeading('Create workspace')
|
|
||||||
->visible(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User
|
|
||||||
&& $user->can('create', Workspace::class);
|
|
||||||
})
|
|
||||||
->form([
|
|
||||||
TextInput::make('name')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('slug')
|
|
||||||
->helperText('Optional. Used in URLs if set.')
|
|
||||||
->maxLength(255)
|
|
||||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
|
||||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
|
||||||
->dehydrated(fn ($state) => filled($state)),
|
|
||||||
])
|
|
||||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,15 +56,28 @@ public function getWorkspaces(): Collection
|
|||||||
return Workspace::query()->whereRaw('1 = 0')->get();
|
return Workspace::query()->whereRaw('1 = 0')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Workspace::query()
|
$workspaces = Workspace::query()
|
||||||
->whereIn('id', function ($query) use ($user): void {
|
->whereIn('id', function ($query) use ($user): void {
|
||||||
$query->from('workspace_memberships')
|
$query->from('workspace_memberships')
|
||||||
->select('workspace_id')
|
->select('workspace_id')
|
||||||
->where('user_id', $user->getKey());
|
->where('user_id', $user->getKey());
|
||||||
})
|
})
|
||||||
->whereNull('archived_at')
|
->whereNull('archived_at')
|
||||||
|
->withCount(['tenants' => function ($query): void {
|
||||||
|
$query->where('status', 'active');
|
||||||
|
}])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
// Build roles map from memberships.
|
||||||
|
$memberships = WorkspaceMembership::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('workspace_id', $workspaces->pluck('id'))
|
||||||
|
->pluck('role', 'workspace_id');
|
||||||
|
|
||||||
|
$this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all();
|
||||||
|
|
||||||
|
return $workspaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectWorkspace(int $workspaceId): void
|
public function selectWorkspace(int $workspaceId): void
|
||||||
@ -105,11 +104,35 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$prevWorkspaceId = $context->currentWorkspaceId(request());
|
||||||
|
|
||||||
$context->setCurrentWorkspace($workspace, $user, request());
|
$context->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
|
// Audit: manual workspace selection.
|
||||||
|
/** @var WorkspaceAuditLogger $logger */
|
||||||
|
$logger = app(WorkspaceAuditLogger::class);
|
||||||
|
|
||||||
|
$logger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceSelected->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'method' => 'manual',
|
||||||
|
'reason' => 'chooser',
|
||||||
|
'prev_workspace_id' => $prevWorkspaceId,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
/** @var WorkspaceRedirectResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceRedirectResolver::class);
|
||||||
|
|
||||||
|
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -147,41 +170,9 @@ public function createWorkspace(array $data): void
|
|||||||
|
|
||||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
/** @var WorkspaceRedirectResolver $resolver */
|
||||||
}
|
$resolver = app(WorkspaceRedirectResolver::class);
|
||||||
|
|
||||||
private function redirectAfterWorkspaceSelected(User $user): string
|
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
return self::getUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return self::getUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantsQuery = $user->tenants()
|
|
||||||
->where('workspace_id', $workspace->getKey())
|
|
||||||
->where('status', 'active');
|
|
||||||
|
|
||||||
$tenantCount = (int) $tenantsQuery->count();
|
|
||||||
|
|
||||||
if ($tenantCount === 0) {
|
|
||||||
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenantCount === 1) {
|
|
||||||
$tenant = $tenantsQuery->first();
|
|
||||||
|
|
||||||
if ($tenant !== null) {
|
|
||||||
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChooseTenant::getUrl();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,8 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -16,11 +14,12 @@
|
|||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -28,7 +27,7 @@ class DriftLanding extends Page
|
|||||||
{
|
{
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Drift';
|
protected static ?string $navigationLabel = 'Drift';
|
||||||
|
|
||||||
@ -67,21 +66,35 @@ public function mount(): void
|
|||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestSuccessful = InventorySyncRun::query()
|
$latestSuccessful = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
->where('type', 'inventory_sync')
|
||||||
->whereNotNull('finished_at')
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->orderByDesc('finished_at')
|
->whereIn('outcome', [
|
||||||
|
OperationRunOutcome::Succeeded->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
])
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->orderByDesc('completed_at')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
if (! $latestSuccessful instanceof OperationRun) {
|
||||||
$this->state = 'blocked';
|
$this->state = 'blocked';
|
||||||
$this->message = 'No successful inventory runs found yet.';
|
$this->message = 'No successful inventory runs found yet.';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
$latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
|
||||||
|
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
|
||||||
|
|
||||||
|
if ($scopeKey === '') {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->scopeKey = $scopeKey;
|
$this->scopeKey = $scopeKey;
|
||||||
|
|
||||||
$selector = app(DriftRunSelector::class);
|
$selector = app(DriftRunSelector::class);
|
||||||
@ -100,15 +113,15 @@ public function mount(): void
|
|||||||
$this->baselineRunId = (int) $baseline->getKey();
|
$this->baselineRunId = (int) $baseline->getKey();
|
||||||
$this->currentRunId = (int) $current->getKey();
|
$this->currentRunId = (int) $current->getKey();
|
||||||
|
|
||||||
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
$this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
|
||||||
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
$this->currentFinishedAt = $current->completed_at?->toDateTimeString();
|
||||||
|
|
||||||
$existingOperationRun = OperationRun::query()
|
$existingOperationRun = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'drift.generate')
|
->where('type', 'drift_generate_findings')
|
||||||
->where('context->scope_key', $scopeKey)
|
->where('context->scope_key', $scopeKey)
|
||||||
->where('context->baseline_run_id', (int) $baseline->getKey())
|
->where('context->baseline_operation_run_id', (int) $baseline->getKey())
|
||||||
->where('context->current_run_id', (int) $current->getKey())
|
->where('context->current_operation_run_id', (int) $current->getKey())
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -120,8 +133,8 @@ public function mount(): void
|
|||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
->where('scope_key', $scopeKey)
|
->where('scope_key', $scopeKey)
|
||||||
->where('baseline_run_id', $baseline->getKey())
|
->where('baseline_operation_run_id', $baseline->getKey())
|
||||||
->where('current_run_id', $current->getKey())
|
->where('current_operation_run_id', $current->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
@ -130,8 +143,8 @@ public function mount(): void
|
|||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
->where('scope_key', $scopeKey)
|
->where('scope_key', $scopeKey)
|
||||||
->where('baseline_run_id', $baseline->getKey())
|
->where('baseline_operation_run_id', $baseline->getKey())
|
||||||
->where('current_run_id', $current->getKey())
|
->where('current_operation_run_id', $current->getKey())
|
||||||
->where('status', Finding::STATUS_NEW)
|
->where('status', Finding::STATUS_NEW)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
@ -189,8 +202,8 @@ public function mount(): void
|
|||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
$selectionIdentity = $selection->fromQuery([
|
$selectionIdentity = $selection->fromQuery([
|
||||||
'scope_key' => $scopeKey,
|
'scope_key' => $scopeKey,
|
||||||
'baseline_run_id' => (int) $baseline->getKey(),
|
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||||
'current_run_id' => (int) $current->getKey(),
|
'current_operation_run_id' => (int) $current->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
@ -198,7 +211,7 @@ public function mount(): void
|
|||||||
|
|
||||||
$opRun = $opService->enqueueBulkOperation(
|
$opRun = $opService->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'drift.generate',
|
type: 'drift_generate_findings',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
@ -216,8 +229,8 @@ public function mount(): void
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'scope_key' => $scopeKey,
|
'scope_key' => $scopeKey,
|
||||||
'baseline_run_id' => (int) $baseline->getKey(),
|
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||||
'current_run_id' => (int) $current->getKey(),
|
'current_operation_run_id' => (int) $current->getKey(),
|
||||||
],
|
],
|
||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
@ -226,10 +239,8 @@ public function mount(): void
|
|||||||
$this->state = 'generating';
|
$this->state = 'generating';
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated) {
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Drift generation already active')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -261,7 +272,7 @@ public function getBaselineRunUrl(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
return route('admin.operations.view', ['run' => $this->baselineRunId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCurrentRunUrl(): ?string
|
public function getCurrentRunUrl(): ?string
|
||||||
@ -270,7 +281,7 @@ public function getCurrentRunUrl(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
return route('admin.operations.view', ['run' => $this->currentRunId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOperationRunUrl(): ?string
|
public function getOperationRunUrl(): ?string
|
||||||
|
|||||||
@ -4,7 +4,11 @@
|
|||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -24,6 +28,22 @@ class InventoryCoverage extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
protected string $view = 'filament.pages.inventory-coverage';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,25 +4,84 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||||
|
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class Alerts extends Page
|
class Alerts extends Page
|
||||||
{
|
{
|
||||||
protected static bool $isDiscovered = false;
|
protected static ?string $cluster = AlertsCluster::class;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Alerts';
|
protected static ?string $navigationLabel = 'Overview';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||||
|
|
||||||
protected static ?string $slug = 'alerts';
|
protected static ?string $slug = 'overview';
|
||||||
|
|
||||||
protected static ?string $title = 'Alerts';
|
protected static ?string $title = 'Alerts';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.alerts';
|
protected string $view = 'filament.pages.monitoring.alerts';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AlertsKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return app(OperateHubShell::class)->headerActions(
|
||||||
|
scopeActionName: 'operate_hub_scope_alerts',
|
||||||
|
returnActionName: 'operate_hub_return_alerts',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -25,4 +27,15 @@ class AuditLog extends Page
|
|||||||
protected static ?string $title = 'Audit Log';
|
protected static ?string $title = 'Audit Log';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return app(OperateHubShell::class)->headerActions(
|
||||||
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,14 @@
|
|||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Action::make('operate_hub_scope_operations')
|
||||||
|
->label($operateHubShell->scopeLabel(request()))
|
||||||
|
->color('gray')
|
||||||
|
->disabled(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||||
|
->label('Back to '.$activeTenant->name)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||||
|
|
||||||
|
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||||
|
->label('Show all tenants')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||||
|
|
||||||
|
$this->removeTableFilter('tenant_id');
|
||||||
|
|
||||||
|
$this->redirect('/admin/operations');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
public function updatedActiveTab(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
@ -61,6 +105,8 @@ public function table(Table $table): Table
|
|||||||
->query(function (): Builder {
|
->query(function (): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->with('user')
|
->with('user')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
@ -71,6 +117,10 @@ public function table(Table $table): Table
|
|||||||
->when(
|
->when(
|
||||||
! $workspaceId,
|
! $workspaceId,
|
||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$activeTenant instanceof Tenant,
|
||||||
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->applyActiveTab($query);
|
return $this->applyActiveTab($query);
|
||||||
|
|||||||
@ -8,18 +8,21 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
class TenantlessOperationRunViewer extends Page
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
@ -37,15 +40,41 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
Action::make('refresh')
|
Action::make('operate_hub_scope_run_detail')
|
||||||
|
->label($operateHubShell->scopeLabel(request()))
|
||||||
|
->color('gray')
|
||||||
|
->disabled(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||||
|
->label('← Back to '.$activeTenant->name)
|
||||||
|
->color('gray')
|
||||||
|
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||||
|
|
||||||
|
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||||
|
->label('Show all operations')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
|
} else {
|
||||||
|
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||||
|
->label('Back to Operations')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('refresh')
|
||||||
->label('Refresh')
|
->label('Refresh')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => isset($this->run)
|
->url(fn (): string => isset($this->run)
|
||||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||||
: route('admin.operations.index')),
|
: route('admin.operations.index'));
|
||||||
];
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
@ -87,20 +116,7 @@ public function mount(OperationRun $run): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
$this->authorize('view', $run);
|
||||||
|
|
||||||
if ($workspaceId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isMember = WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $isMember) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
}
|
}
|
||||||
|
|||||||
978
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
978
app/Filament/Pages/Settings/WorkspaceSettings.php
Normal file
@ -0,0 +1,978 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Settings\SettingDefinition;
|
||||||
|
use App\Support\Settings\SettingsRegistry;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class WorkspaceSettings extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'settings/workspace';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Workspace settings';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
|
||||||
|
*/
|
||||||
|
private const SETTING_FIELDS = [
|
||||||
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||||
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||||
|
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||||
|
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||||
|
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const KEYVALUE_FIELDS = [
|
||||||
|
'drift_severity_mapping',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings SLA days are decomposed into individual form fields per severity.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const SLA_SUB_FIELDS = [
|
||||||
|
'findings_sla_critical' => 'critical',
|
||||||
|
'findings_sla_high' => 'high',
|
||||||
|
'findings_sla_medium' => 'medium',
|
||||||
|
'findings_sla_low' => 'low',
|
||||||
|
];
|
||||||
|
|
||||||
|
public Workspace $workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $workspaceOverrides = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
|
||||||
|
*/
|
||||||
|
public array $resolvedSettings = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||||
|
*
|
||||||
|
* @var array<string, array{user_name: string, updated_at: Carbon}>
|
||||||
|
*/
|
||||||
|
public array $domainLastModified = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('save')
|
||||||
|
->label('Save')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->save();
|
||||||
|
})
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage workspace settings.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
$this->redirect('/admin/choose-workspace');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->workspace = $workspace;
|
||||||
|
|
||||||
|
$this->authorizeWorkspaceView($user);
|
||||||
|
|
||||||
|
$this->loadFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->statePath('data')
|
||||||
|
->schema([
|
||||||
|
Section::make('Backup settings')
|
||||||
|
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('backup_retention_keep_last_default')
|
||||||
|
->label('Default retention keep-last')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('versions')
|
||||||
|
->hint('1 – 365')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(365)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default'))
|
||||||
|
->hintAction($this->makeResetAction('backup_retention_keep_last_default')),
|
||||||
|
TextInput::make('backup_retention_min_floor')
|
||||||
|
->label('Minimum retention floor')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('versions')
|
||||||
|
->hint('1 – 365')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(365)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor'))
|
||||||
|
->hintAction($this->makeResetAction('backup_retention_min_floor')),
|
||||||
|
]),
|
||||||
|
Section::make('Drift settings')
|
||||||
|
->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.'))
|
||||||
|
->schema([
|
||||||
|
KeyValue::make('drift_severity_mapping')
|
||||||
|
->label('Severity mapping')
|
||||||
|
->keyLabel('Finding type')
|
||||||
|
->valueLabel('Severity')
|
||||||
|
->keyPlaceholder('e.g. drift')
|
||||||
|
->valuePlaceholder('critical, high, medium, or low')
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
||||||
|
->hintAction($this->makeResetAction('drift_severity_mapping')),
|
||||||
|
]),
|
||||||
|
Section::make('Findings settings')
|
||||||
|
->key('findings_section')
|
||||||
|
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
|
||||||
|
->columns(2)
|
||||||
|
->afterHeader([
|
||||||
|
$this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'),
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
TextInput::make('findings_sla_critical')
|
||||||
|
->label('Critical severity')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('days')
|
||||||
|
->hint('1 – 3,650')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(3650)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->slaFieldHelperText('critical')),
|
||||||
|
TextInput::make('findings_sla_high')
|
||||||
|
->label('High severity')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('days')
|
||||||
|
->hint('1 – 3,650')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(3650)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->slaFieldHelperText('high')),
|
||||||
|
TextInput::make('findings_sla_medium')
|
||||||
|
->label('Medium severity')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('days')
|
||||||
|
->hint('1 – 3,650')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(3650)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->slaFieldHelperText('medium')),
|
||||||
|
TextInput::make('findings_sla_low')
|
||||||
|
->label('Low severity')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('days')
|
||||||
|
->hint('1 – 3,650')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(3650)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->slaFieldHelperText('low')),
|
||||||
|
]),
|
||||||
|
Section::make('Operations settings')
|
||||||
|
->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.'))
|
||||||
|
->schema([
|
||||||
|
TextInput::make('operations_operation_run_retention_days')
|
||||||
|
->label('Operation run retention')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('days')
|
||||||
|
->hint('7 – 3,650')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(7)
|
||||||
|
->maxValue(3650)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
|
||||||
|
->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
|
||||||
|
TextInput::make('operations_stuck_run_threshold_minutes')
|
||||||
|
->label('Stuck run threshold')
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->suffix('minutes')
|
||||||
|
->hint('0 – 10,080')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(10080)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes'))
|
||||||
|
->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeWorkspaceManage($user);
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
|
||||||
|
$this->composeSlaSubFieldsIntoData();
|
||||||
|
|
||||||
|
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
||||||
|
|
||||||
|
if ($validationErrors !== []) {
|
||||||
|
throw ValidationException::withMessages($validationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
$changedSettingsCount = 0;
|
||||||
|
|
||||||
|
foreach (self::SETTING_FIELDS as $field => $setting) {
|
||||||
|
$incomingValue = $normalizedValues[$field] ?? null;
|
||||||
|
$currentOverride = $this->workspaceOverrideForField($field);
|
||||||
|
|
||||||
|
if ($incomingValue === null) {
|
||||||
|
if ($currentOverride === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $setting['domain'],
|
||||||
|
key: $setting['key'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$changedSettingsCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->valuesEqual($incomingValue, $currentOverride)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $setting['domain'],
|
||||||
|
key: $setting['key'],
|
||||||
|
value: $incomingValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
$changedSettingsCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetSetting(string $field): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeWorkspaceManage($user);
|
||||||
|
|
||||||
|
$setting = $this->settingForField($field);
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($field) === null) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Setting already uses default')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(SettingsWriter::class)->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $setting['domain'],
|
||||||
|
key: $setting['key'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace setting reset to default')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadFormState(): void
|
||||||
|
{
|
||||||
|
$resolver = app(SettingsResolver::class);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$workspaceOverrides = [];
|
||||||
|
$resolvedSettings = [];
|
||||||
|
|
||||||
|
foreach (self::SETTING_FIELDS as $field => $setting) {
|
||||||
|
$resolved = $resolver->resolveDetailed(
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $setting['domain'],
|
||||||
|
key: $setting['key'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspaceValue = $resolved['workspace_value'];
|
||||||
|
|
||||||
|
$workspaceOverrides[$field] = $workspaceValue;
|
||||||
|
$resolvedSettings[$field] = [
|
||||||
|
'source' => $resolved['source'],
|
||||||
|
'value' => $resolved['value'],
|
||||||
|
'system_default' => $resolved['system_default'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$data[$field] = $workspaceValue === null
|
||||||
|
? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null)
|
||||||
|
: $this->formatValueForInput($field, $workspaceValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
|
||||||
|
|
||||||
|
$this->data = $data;
|
||||||
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
|
|
||||||
|
$this->loadDomainLastModified();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load per-domain "last modified" metadata from workspace_settings.
|
||||||
|
*/
|
||||||
|
private function loadDomainLastModified(): void
|
||||||
|
{
|
||||||
|
$domains = array_unique(array_column(self::SETTING_FIELDS, 'domain'));
|
||||||
|
|
||||||
|
$records = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
|
->whereIn('domain', $domains)
|
||||||
|
->whereNotNull('updated_by_user_id')
|
||||||
|
->with('updatedByUser:id,name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$domainInfo = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
/** @var WorkspaceSetting $record */
|
||||||
|
$domain = $record->domain;
|
||||||
|
$updatedAt = $record->updated_at;
|
||||||
|
|
||||||
|
if (! $updatedAt instanceof Carbon) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $record->updatedByUser;
|
||||||
|
|
||||||
|
$domainInfo[$domain] = [
|
||||||
|
'user_name' => $user instanceof User ? $user->name : 'Unknown',
|
||||||
|
'updated_at' => $updatedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->domainLastModified = $domainInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a section description that appends "last modified" info when available.
|
||||||
|
*/
|
||||||
|
private function sectionDescription(string $domain, string $baseDescription): string
|
||||||
|
{
|
||||||
|
$meta = $this->domainLastModified[$domain] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($meta)) {
|
||||||
|
return $baseDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Carbon $updatedAt */
|
||||||
|
$updatedAt = $meta['updated_at'];
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s — Last modified by %s, %s.',
|
||||||
|
$baseDescription,
|
||||||
|
$meta['user_name'],
|
||||||
|
$updatedAt->diffForHumans(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeResetAction(string $field): Action
|
||||||
|
{
|
||||||
|
return Action::make('reset_'.$field)
|
||||||
|
->label('Reset')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function () use ($field): void {
|
||||||
|
$this->resetSetting($field);
|
||||||
|
})
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
||||||
|
->tooltip(function () use ($field): ?string {
|
||||||
|
if (! $this->currentUserCanManage()) {
|
||||||
|
return 'You do not have permission to manage workspace settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->hasWorkspaceOverride($field)) {
|
||||||
|
return 'No workspace override to reset.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function helperTextFor(string $field): string
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null);
|
||||||
|
|
||||||
|
if (! $this->hasWorkspaceOverride($field)) {
|
||||||
|
return sprintf(
|
||||||
|
'Unset. Effective value: %s (%s).',
|
||||||
|
$effectiveValue,
|
||||||
|
$this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Effective value: %s.', $effectiveValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slaFieldHelperText(string $severity): string
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveValue = is_array($resolved['value'] ?? null)
|
||||||
|
? (int) ($resolved['value'][$severity] ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$systemDefault = is_array($resolved['system_default'] ?? null)
|
||||||
|
? (int) ($resolved['system_default'][$severity] ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (! $this->hasWorkspaceOverride('findings_sla_days')) {
|
||||||
|
return sprintf('Default: %d days.', $systemDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Effective: %d days.', $effectiveValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
|
||||||
|
*/
|
||||||
|
private function normalizedInputValues(): array
|
||||||
|
{
|
||||||
|
$normalizedValues = [];
|
||||||
|
$validationErrors = [];
|
||||||
|
|
||||||
|
foreach (self::SETTING_FIELDS as $field => $_setting) {
|
||||||
|
try {
|
||||||
|
$normalizedValues[$field] = $this->normalizeFieldInput(
|
||||||
|
field: $field,
|
||||||
|
value: $this->data[$field] ?? null,
|
||||||
|
);
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
foreach ($exception->errors() as $errorMessages) {
|
||||||
|
foreach ((array) $errorMessages as $message) {
|
||||||
|
$messages[] = (string) $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'findings_sla_days') {
|
||||||
|
$severityToField = array_flip(self::SLA_SUB_FIELDS);
|
||||||
|
|
||||||
|
$targeted = false;
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
if (preg_match('/include "(?<severity>critical|high|medium|low)"/i', $message, $matches) === 1) {
|
||||||
|
$severity = strtolower((string) $matches['severity']);
|
||||||
|
$subField = $severityToField[$severity] ?? null;
|
||||||
|
|
||||||
|
if (is_string($subField)) {
|
||||||
|
$validationErrors['data.'.$subField] ??= [];
|
||||||
|
$validationErrors['data.'.$subField][] = $message;
|
||||||
|
$targeted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $targeted) {
|
||||||
|
foreach (self::SLA_SUB_FIELDS as $subField => $_severity) {
|
||||||
|
$validationErrors['data.'.$subField] = $messages !== []
|
||||||
|
? $messages
|
||||||
|
: ['Invalid value.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validationErrors['data.'.$field] = $messages !== []
|
||||||
|
? $messages
|
||||||
|
: ['Invalid value.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$normalizedValues, $validationErrors];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFieldInput(string $field, mixed $value): mixed
|
||||||
|
{
|
||||||
|
$setting = $this->settingForField($field);
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) && $value === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setting['type'] === 'json') {
|
||||||
|
$value = $this->normalizeJsonInput($value);
|
||||||
|
|
||||||
|
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
|
||||||
|
$value = $this->normalizeKeyValueInput($value);
|
||||||
|
|
||||||
|
if ($value === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->settingDefinition($field);
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
data: ['value' => $value],
|
||||||
|
rules: ['value' => $definition->rules],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw ValidationException::withMessages($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definition->normalize($validator->validated()['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize KeyValue component state.
|
||||||
|
*
|
||||||
|
* Filament's KeyValue UI keeps an empty row by default, which can submit as
|
||||||
|
* ['' => ''] and would otherwise fail validation. We treat empty rows as unset.
|
||||||
|
*
|
||||||
|
* @param array<mixed> $value
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeKeyValueInput(array $value): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_array($item) && array_key_exists('key', $item)) {
|
||||||
|
$rowKey = $item['key'];
|
||||||
|
$rowValue = $item['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($rowKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmedKey = trim($rowKey);
|
||||||
|
|
||||||
|
if ($trimmedKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($rowValue)) {
|
||||||
|
$trimmedValue = trim($rowValue);
|
||||||
|
|
||||||
|
if ($trimmedValue === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$trimmedKey] = $trimmedValue;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rowValue === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$trimmedKey] = $rowValue;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmedKey = trim($key);
|
||||||
|
|
||||||
|
if ($trimmedKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($item)) {
|
||||||
|
$trimmedValue = trim($item);
|
||||||
|
|
||||||
|
if ($trimmedValue === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$trimmedKey] = $trimmedValue;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$trimmedKey] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeJsonInput(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'value' => ['The value must be valid JSON.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'value' => ['The value must be valid JSON.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'value' => ['The value must be a JSON object.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function valuesEqual(mixed $left, mixed $right): bool
|
||||||
|
{
|
||||||
|
if ($left === null || $right === null) {
|
||||||
|
return $left === $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($left) && is_array($right)) {
|
||||||
|
return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($left) && is_numeric($right)) {
|
||||||
|
return (int) $left === (int) $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $left === $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeCanonicalArray(array $value): string
|
||||||
|
{
|
||||||
|
$encoded = json_encode($this->sortNestedArray($value));
|
||||||
|
|
||||||
|
return is_string($encoded) ? $encoded : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $value
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
private function sortNestedArray(array $value): array
|
||||||
|
{
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value[$key] = $this->sortNestedArray($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($value);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatValueForInput(string $field, mixed $value): mixed
|
||||||
|
{
|
||||||
|
$setting = $this->settingForField($field);
|
||||||
|
|
||||||
|
if ($setting['type'] === 'json') {
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return is_string($encoded) ? $encoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatValueForDisplay(string $field, mixed $value): string
|
||||||
|
{
|
||||||
|
$setting = $this->settingForField($field);
|
||||||
|
|
||||||
|
if ($setting['type'] === 'json') {
|
||||||
|
if (! is_array($value) || $value === []) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return is_string($encoded) ? $encoded : '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($value) ? (string) (int) $value : 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceLabel(string $source): string
|
||||||
|
{
|
||||||
|
return match ($source) {
|
||||||
|
'workspace_override' => 'workspace override',
|
||||||
|
'tenant_override' => 'tenant override',
|
||||||
|
default => 'system default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{domain: string, key: string, type: 'int'|'json'}
|
||||||
|
*/
|
||||||
|
private function settingForField(string $field): array
|
||||||
|
{
|
||||||
|
if (! isset(self::SETTING_FIELDS[$field])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'data' => [sprintf('Unknown settings field: %s', $field)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SETTING_FIELDS[$field];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function settingDefinition(string $field): SettingDefinition
|
||||||
|
{
|
||||||
|
$setting = $this->settingForField($field);
|
||||||
|
|
||||||
|
return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasWorkspaceOverride(string $field): bool
|
||||||
|
{
|
||||||
|
return $this->workspaceOverrideForField($field) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspaceOverrideForField(string $field): mixed
|
||||||
|
{
|
||||||
|
return $this->workspaceOverrides[$field] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $workspaceOverrides
|
||||||
|
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $resolvedSettings
|
||||||
|
*/
|
||||||
|
private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void
|
||||||
|
{
|
||||||
|
$slaOverride = $workspaceOverrides['findings_sla_days'] ?? null;
|
||||||
|
$slaResolved = $resolvedSettings['findings_sla_days'] ?? null;
|
||||||
|
|
||||||
|
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
|
||||||
|
$data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity])
|
||||||
|
? (int) $slaOverride[$severity]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
|
||||||
|
*/
|
||||||
|
private function composeSlaSubFieldsIntoData(): void
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
$hasAnyValue = false;
|
||||||
|
|
||||||
|
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
|
||||||
|
$val = $this->data[$subField] ?? null;
|
||||||
|
|
||||||
|
if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) {
|
||||||
|
$values[$severity] = (int) $val;
|
||||||
|
$hasAnyValue = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentUserCanManage(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $this->workspace)
|
||||||
|
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeWorkspaceView(User $user): void
|
||||||
|
{
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeWorkspaceManage(User $user): void
|
||||||
|
{
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $this->workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
@ -31,6 +32,7 @@ public function getWidgets(): array
|
|||||||
return [
|
return [
|
||||||
DashboardKpis::class,
|
DashboardKpis::class,
|
||||||
NeedsAttention::class,
|
NeedsAttention::class,
|
||||||
|
BaselineCompareNow::class,
|
||||||
RecentDriftFindings::class,
|
RecentDriftFindings::class,
|
||||||
RecentOperations::class,
|
RecentOperations::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Models\ProviderConnection;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
@ -41,34 +41,28 @@ class TenantRequiredPermissions extends Page
|
|||||||
*/
|
*/
|
||||||
public array $viewModel = [];
|
public array $viewModel = [];
|
||||||
|
|
||||||
|
public ?Tenant $scopedTenant = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
public function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
return static::resolveScopedTenant();
|
return $this->scopedTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->scopedTenant = $tenant;
|
||||||
|
|
||||||
$queryFeatures = request()->query('features', $this->features);
|
$queryFeatures = request()->query('features', $this->features);
|
||||||
|
|
||||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
@ -147,7 +141,7 @@ public function resetFilters(): void
|
|||||||
|
|
||||||
private function refreshViewModel(): void
|
private function refreshViewModel(): void
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->viewModel = [];
|
$this->viewModel = [];
|
||||||
@ -174,27 +168,28 @@ private function refreshViewModel(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reRunVerificationUrl(): ?string
|
public function reRunVerificationUrl(): string
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manageProviderConnectionUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionId = ProviderConnection::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->orderByDesc('is_default')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->value('id');
|
|
||||||
|
|
||||||
if (! is_int($connectionId)) {
|
|
||||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveScopedTenant(): ?Tenant
|
protected static function resolveScopedTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
$routeTenant = request()->route('tenant');
|
$routeTenant = request()->route('tenant');
|
||||||
@ -209,6 +204,32 @@ protected static function resolveScopedTenant(): ?Tenant
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isWorkspaceMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isWorkspaceMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Verification\VerificationCheckStatus;
|
use App\Support\Verification\VerificationCheckStatus;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -75,6 +77,18 @@ class ManagedTenantOnboardingWizard extends Page
|
|||||||
|
|
||||||
protected static ?string $slug = 'onboarding';
|
protected static ?string $slug = 'onboarding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the simple-layout topbar to prevent lazy-loaded
|
||||||
|
* DatabaseNotifications from triggering Livewire update 404s
|
||||||
|
* on this workspace-scoped route.
|
||||||
|
*/
|
||||||
|
protected function getLayoutData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hasTopbar' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public Workspace $workspace;
|
public Workspace $workspace;
|
||||||
|
|
||||||
public ?Tenant $managedTenant = null;
|
public ?Tenant $managedTenant = null;
|
||||||
@ -347,7 +361,7 @@ public function content(Schema $schema): Schema
|
|||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('wizardStartVerification')
|
Action::make('wizardStartVerification')
|
||||||
->label('Start verification')
|
->label('Start verification')
|
||||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress')
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
|
||||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||||
? null
|
? null
|
||||||
@ -506,6 +520,12 @@ private function resumeLatestOnboardingSessionIfUnambiguous(): void
|
|||||||
|
|
||||||
private function initializeWizardData(): void
|
private function initializeWizardData(): void
|
||||||
{
|
{
|
||||||
|
// Ensure all entangled schema state paths exist at render time.
|
||||||
|
// Livewire v4 can throw when entangling to missing nested array keys.
|
||||||
|
$this->data['notes'] ??= '';
|
||||||
|
$this->data['override_blocked'] ??= false;
|
||||||
|
$this->data['override_reason'] ??= '';
|
||||||
|
|
||||||
if (! array_key_exists('connection_mode', $this->data)) {
|
if (! array_key_exists('connection_mode', $this->data)) {
|
||||||
$this->data['connection_mode'] = 'existing';
|
$this->data['connection_mode'] = 'existing';
|
||||||
}
|
}
|
||||||
@ -629,6 +649,10 @@ private function verificationStatus(): string
|
|||||||
return 'in_progress';
|
return 'in_progress';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($run->outcome === OperationRunOutcome::Blocked->value) {
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
|
||||||
if ($run->outcome === OperationRunOutcome::Succeeded->value) {
|
if ($run->outcome === OperationRunOutcome::Succeeded->value) {
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
@ -658,7 +682,7 @@ private function verificationStatus(): string
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) {
|
if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
|
||||||
return 'blocked';
|
return 'blocked';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1422,6 +1446,8 @@ public function startVerification(): void
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Another operation is already running')
|
->title('Another operation is already running')
|
||||||
->body('Please wait for the active run to finish.')
|
->body('Please wait for the active run to finish.')
|
||||||
@ -1436,9 +1462,64 @@ public function startVerification(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
|
foreach ($nextSteps as $index => $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
|
if ($label === '' || $url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('next_step_'.$index)
|
||||||
|
->label($label)
|
||||||
|
->url($url);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
->title('Verification blocked')
|
||||||
->success()
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -1536,7 +1617,7 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
|
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|array{status: 'scope_busy', run: OperationRun} $result */
|
||||||
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
||||||
$lockedConnection = ProviderConnection::query()
|
$lockedConnection = ProviderConnection::query()
|
||||||
->whereKey($connection->getKey())
|
->whereKey($connection->getKey())
|
||||||
@ -1560,6 +1641,7 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
$runsService = app(OperationRunService::class);
|
$runsService = app(OperationRunService::class);
|
||||||
|
|
||||||
$bootstrapRuns = [];
|
$bootstrapRuns = [];
|
||||||
|
$bootstrapCreated = [];
|
||||||
|
|
||||||
foreach ($types as $operationType) {
|
foreach ($types as $operationType) {
|
||||||
$definition = $registry->get($operationType);
|
$definition = $registry->get($operationType);
|
||||||
@ -1598,15 +1680,19 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
||||||
|
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => 'started',
|
'status' => 'started',
|
||||||
'runs' => $bootstrapRuns,
|
'runs' => $bootstrapRuns,
|
||||||
|
'created' => $bootstrapCreated,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($result['status'] === 'scope_busy') {
|
if ($result['status'] === 'scope_busy') {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Another operation is already running')
|
->title('Another operation is already running')
|
||||||
->body('Please wait for the active run to finish.')
|
->body('Please wait for the active run to finish.')
|
||||||
@ -1638,10 +1724,27 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
$this->onboardingSession->save();
|
$this->onboardingSession->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Bootstrap started')
|
|
||||||
->success()
|
foreach ($types as $operationType) {
|
||||||
->send();
|
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
||||||
|
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
||||||
|
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
||||||
|
|
||||||
|
$toast = $wasCreated
|
||||||
|
? OperationUxPresenter::queuedToast($operationType)
|
||||||
|
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
||||||
|
|
||||||
|
if ($runUrl !== null) {
|
||||||
|
$toast->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatchBootstrapJob(
|
private function dispatchBootstrapJob(
|
||||||
@ -1652,7 +1755,7 @@ private function dispatchBootstrapJob(
|
|||||||
OperationRun $run,
|
OperationRun $run,
|
||||||
): void {
|
): void {
|
||||||
match ($operationType) {
|
match ($operationType) {
|
||||||
'inventory.sync' => ProviderInventorySyncJob::dispatch(
|
'inventory_sync' => ProviderInventorySyncJob::dispatch(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
userId: $userId,
|
userId: $userId,
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
@ -1671,7 +1774,7 @@ private function dispatchBootstrapJob(
|
|||||||
private function resolveBootstrapCapability(string $operationType): ?string
|
private function resolveBootstrapCapability(string $operationType): ?string
|
||||||
{
|
{
|
||||||
return match ($operationType) {
|
return match ($operationType) {
|
||||||
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,12 +18,26 @@ class ManagedTenantsLanding extends Page
|
|||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||||
|
|
||||||
protected static ?string $title = 'Managed tenants';
|
protected static ?string $title = 'Managed tenants';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
||||||
|
|
||||||
public Workspace $workspace;
|
public Workspace $workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Filament simple layout renders the topbar by default, which includes
|
||||||
|
* lazy-loaded database notifications. On this workspace-scoped landing page,
|
||||||
|
* those background Livewire requests currently 404.
|
||||||
|
*/
|
||||||
|
protected function getLayoutData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hasTopbar' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(Workspace $workspace): void
|
public function mount(Workspace $workspace): void
|
||||||
{
|
{
|
||||||
$this->workspace = $workspace;
|
$this->workspace = $workspace;
|
||||||
|
|||||||
283
app/Filament/Resources/AlertDeliveryResource.php
Normal file
283
app/Filament/Resources/AlertDeliveryResource.php
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AlertDeliveryResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = AlertDelivery::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'alert-deliveries';
|
||||||
|
|
||||||
|
protected static ?string $cluster = AlertsCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'id';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Alert deliveries';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('viewAny', AlertDelivery::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDelivery) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('view', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with(['tenant', 'rule', 'destination'])
|
||||||
|
->when(
|
||||||
|
! $user instanceof User,
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
! is_int($workspaceId),
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
is_int($workspaceId),
|
||||||
|
fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$user instanceof User,
|
||||||
|
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
||||||
|
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
|
||||||
|
->orWhereNull('tenant_id');
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
Filament::getTenant() instanceof Tenant,
|
||||||
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
|
||||||
|
)
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Delivery')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||||
|
TextEntry::make('event_type')
|
||||||
|
->label('Event')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
||||||
|
TextEntry::make('severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('tenant.name')
|
||||||
|
->label('Tenant'),
|
||||||
|
TextEntry::make('rule.name')
|
||||||
|
->label('Rule')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('destination.name')
|
||||||
|
->label('Destination')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('attempt_count')
|
||||||
|
->label('Attempts'),
|
||||||
|
TextEntry::make('fingerprint_hash')
|
||||||
|
->label('Fingerprint')
|
||||||
|
->copyable(),
|
||||||
|
TextEntry::make('send_after')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('sent_at')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('last_error_code')
|
||||||
|
->label('Last error code')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('last_error_message')
|
||||||
|
->label('Last error message')
|
||||||
|
->placeholder('—')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->dateTime(),
|
||||||
|
TextEntry::make('updated_at')
|
||||||
|
->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Payload')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('payload')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('id', 'desc')
|
||||||
|
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
||||||
|
? static::getUrl('view', ['record' => $record])
|
||||||
|
: null)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Created')
|
||||||
|
->since(),
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('event_type')
|
||||||
|
->label('Event')
|
||||||
|
->badge(),
|
||||||
|
TextColumn::make('severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
||||||
|
TextColumn::make('rule.name')
|
||||||
|
->label('Rule')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('destination.name')
|
||||||
|
->label('Destination')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('attempt_count')
|
||||||
|
->label('Attempts'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->options([
|
||||||
|
AlertDelivery::STATUS_QUEUED => 'Queued',
|
||||||
|
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
||||||
|
AlertDelivery::STATUS_SENT => 'Sent',
|
||||||
|
AlertDelivery::STATUS_FAILED => 'Failed',
|
||||||
|
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||||
|
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
||||||
|
]),
|
||||||
|
SelectFilter::make('event_type')
|
||||||
|
->label('Event type')
|
||||||
|
->options(function (): array {
|
||||||
|
$options = AlertRuleResource::eventTypeOptions();
|
||||||
|
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}),
|
||||||
|
SelectFilter::make('alert_destination_id')
|
||||||
|
->label('Destination')
|
||||||
|
->options(function (): array {
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDestination::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
ViewAction::make()->label('View'),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListAlertDeliveries::route('/'),
|
||||||
|
'view' => Pages\ViewAlertDelivery::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAlertDeliveries extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDeliveryResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return app(OperateHubShell::class)->headerActions(
|
||||||
|
scopeActionName: 'operate_hub_scope_alerts',
|
||||||
|
returnActionName: 'operate_hub_return_alerts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewAlertDelivery extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDeliveryResource::class;
|
||||||
|
}
|
||||||
381
app/Filament/Resources/AlertDestinationResource.php
Normal file
381
app/Filament/Resources/AlertDestinationResource.php
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TagsInput;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AlertDestinationResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = AlertDestination::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'alert-destinations';
|
||||||
|
|
||||||
|
protected static ?string $cluster = AlertsCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Alert targets';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('viewAny', AlertDestination::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('create', AlertDestination::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('update', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('delete', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->when(
|
||||||
|
$workspaceId !== null,
|
||||||
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceId === null,
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Select::make('type')
|
||||||
|
->required()
|
||||||
|
->options(self::typeOptions())
|
||||||
|
->native(false)
|
||||||
|
->live(),
|
||||||
|
Toggle::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
TextInput::make('teams_webhook_url')
|
||||||
|
->label('Teams webhook URL')
|
||||||
|
->placeholder('https://...')
|
||||||
|
->url()
|
||||||
|
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
|
||||||
|
TagsInput::make('email_recipients')
|
||||||
|
->label('Email recipients')
|
||||||
|
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
|
||||||
|
->placeholder('ops@example.com')
|
||||||
|
->nestedRecursiveRules(['email']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('name')
|
||||||
|
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||||
|
? static::getUrl('edit', ['record' => $record])
|
||||||
|
: static::getUrl('view', ['record' => $record]))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
||||||
|
TextColumn::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
|
TextColumn::make('updated_at')
|
||||||
|
->since(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
EditAction::make()
|
||||||
|
->label('Edit')
|
||||||
|
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('toggle_enabled')
|
||||||
|
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
|
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||||
|
->action(function (AlertDestination $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enabled = ! (bool) $record->is_enabled;
|
||||||
|
$record->forceFill([
|
||||||
|
'is_enabled' => $enabled,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$actionId = $enabled
|
||||||
|
? AuditActionId::AlertDestinationEnabled
|
||||||
|
: AuditActionId::AlertDestinationDisabled;
|
||||||
|
|
||||||
|
self::audit($record, $actionId, [
|
||||||
|
'alert_destination_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'type' => (string) $record->type,
|
||||||
|
'is_enabled' => $enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($enabled ? 'Destination enabled' : 'Destination disabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Action::make('delete')
|
||||||
|
->label('Delete')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (AlertDestination $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::audit($record, AuditActionId::AlertDestinationDeleted, [
|
||||||
|
'alert_destination_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'type' => (string) $record->type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Destination deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
])->label('More'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([])->label('More'),
|
||||||
|
])
|
||||||
|
->emptyStateActions([
|
||||||
|
\Filament\Actions\CreateAction::make()
|
||||||
|
->label('Create target')
|
||||||
|
->disabled(fn (): bool => ! static::canCreate()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListAlertDestinations::route('/'),
|
||||||
|
'create' => Pages\CreateAlertDestination::route('/create'),
|
||||||
|
'view' => Pages\ViewAlertDestination::route('/{record}'),
|
||||||
|
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
|
||||||
|
{
|
||||||
|
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
|
||||||
|
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
|
||||||
|
|
||||||
|
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||||
|
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
|
||||||
|
|
||||||
|
if ($webhookUrl === '' && $record instanceof AlertDestination) {
|
||||||
|
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['config'] = [
|
||||||
|
'webhook_url' => $webhookUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||||
|
$recipients = Arr::wrap($data['email_recipients'] ?? []);
|
||||||
|
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
|
||||||
|
|
||||||
|
if ($recipients === [] && $record instanceof AlertDestination) {
|
||||||
|
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
|
||||||
|
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['config'] = [
|
||||||
|
'recipients' => array_values(array_unique($recipients)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['teams_webhook_url'], $data['email_recipients']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function typeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
|
||||||
|
AlertDestination::TYPE_EMAIL => 'Email',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function typeLabel(string $type): string
|
||||||
|
{
|
||||||
|
return self::typeOptions()[$type] ?? ucfirst($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function assertValidConfigPayload(array $data): void
|
||||||
|
{
|
||||||
|
$type = (string) ($data['type'] ?? '');
|
||||||
|
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||||
|
|
||||||
|
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||||
|
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
|
||||||
|
|
||||||
|
if ($webhook === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'teams_webhook_url' => ['The Teams webhook URL is required.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||||
|
$recipients = Arr::get($config, 'recipients', []);
|
||||||
|
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
|
||||||
|
|
||||||
|
if ($recipients === []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email_recipients' => ['At least one recipient is required for email destinations.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
|
||||||
|
{
|
||||||
|
$workspace = $record->workspace;
|
||||||
|
|
||||||
|
if ($workspace === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: $actionId->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => $metadata,
|
||||||
|
],
|
||||||
|
actor: auth()->user() instanceof User ? auth()->user() : null,
|
||||||
|
resourceType: 'alert_destination',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateAlertDestination extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$data['workspace_id'] = (int) $workspaceId;
|
||||||
|
$data = AlertDestinationResource::normalizePayload($data);
|
||||||
|
AlertDestinationResource::assertValidConfigPayload($data);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [
|
||||||
|
'alert_destination_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'type' => (string) $record->type,
|
||||||
|
'is_enabled' => (bool) $record->is_enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Destination created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||||
|
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditAlertDestination extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$record = $this->record;
|
||||||
|
$canManage = $user instanceof User
|
||||||
|
&& $record instanceof AlertDestination
|
||||||
|
&& $user->can('update', $record);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('send_test_message')
|
||||||
|
->label('Send test message')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Send test message')
|
||||||
|
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||||
|
->modalSubmitActionLabel('Send')
|
||||||
|
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||||
|
->disabled(fn (): bool => ! $canManage)
|
||||||
|
->action(function () use ($record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(AlertDestinationTestMessageService::class);
|
||||||
|
$result = $service->sendTest($record, $user);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('view_last_delivery')
|
||||||
|
->label('View last delivery')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
if ($this->lastTestStatus === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = ucfirst($this->lastTestStatus->status->value);
|
||||||
|
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
|
||||||
|
|
||||||
|
return $timestamp !== null
|
||||||
|
? "Last test: {$label} ({$timestamp})"
|
||||||
|
: "Last test: {$label}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
$data = AlertDestinationResource::normalizePayload(
|
||||||
|
data: $data,
|
||||||
|
record: $record instanceof AlertDestination ? $record : null,
|
||||||
|
);
|
||||||
|
AlertDestinationResource::assertValidConfigPayload($data);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [
|
||||||
|
'alert_destination_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'type' => (string) $record->type,
|
||||||
|
'is_enabled' => (bool) $record->is_enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Destination updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLastTestStatus(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDeepLinkUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = AlertDeliveryResource::getUrl('index');
|
||||||
|
$params = http_build_query([
|
||||||
|
'filters' => [
|
||||||
|
'event_type' => ['value' => 'alerts.test'],
|
||||||
|
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return "{$baseUrl}?{$params}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAlertDestinations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create target')
|
||||||
|
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create target')
|
||||||
|
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Alerts\AlertDestinationLastTestResolver;
|
||||||
|
use App\Services\Alerts\AlertDestinationTestMessageService;
|
||||||
|
use App\Support\Alerts\AlertDestinationLastTestStatus;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class ViewAlertDestination extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertDestinationResource::class;
|
||||||
|
|
||||||
|
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$record = $this->record;
|
||||||
|
$canManage = $user instanceof User
|
||||||
|
&& $record instanceof AlertDestination
|
||||||
|
&& $user->can('update', $record);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('send_test_message')
|
||||||
|
->label('Send test message')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Send test message')
|
||||||
|
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
|
||||||
|
->modalSubmitActionLabel('Send')
|
||||||
|
->visible(fn (): bool => $record instanceof AlertDestination)
|
||||||
|
->disabled(fn (): bool => ! $canManage)
|
||||||
|
->action(function () use ($record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(AlertDestinationTestMessageService::class);
|
||||||
|
$result = $service->sendTest($record, $user);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title($result['message'])
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolveLastTestStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('view_last_delivery')
|
||||||
|
->label('View last delivery')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (): ?string => $this->buildDeepLinkUrl())
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
|
||||||
|
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Last test')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('last_test_status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->state($lastTest->status->value)
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
|
||||||
|
TextEntry::make('last_test_timestamp')
|
||||||
|
->label('Timestamp')
|
||||||
|
->state($lastTest->timestamp?->toDateTimeString())
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Details')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('name'),
|
||||||
|
TextEntry::make('type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
|
||||||
|
TextEntry::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->dateTime(),
|
||||||
|
TextEntry::make('updated_at')
|
||||||
|
->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLastTestStatus(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDeepLinkUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
|
||||||
|
'filters' => [
|
||||||
|
'event_type' => ['value' => 'alerts.test'],
|
||||||
|
'alert_destination_id' => ['value' => (string) $record->getKey()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
477
app/Filament/Resources/AlertRuleResource.php
Normal file
477
app/Filament/Resources/AlertRuleResource.php
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||||
|
use App\Filament\Resources\AlertRuleResource\Pages;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AlertRuleResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = AlertRule::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'alert-rules';
|
||||||
|
|
||||||
|
protected static ?string $cluster = AlertsCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Alert rules';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('viewAny', AlertRule::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('create', AlertRule::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('update', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('delete', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('destinations')
|
||||||
|
->when(
|
||||||
|
$workspaceId !== null,
|
||||||
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceId === null,
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Rule')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Toggle::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->default(true),
|
||||||
|
Select::make('event_type')
|
||||||
|
->required()
|
||||||
|
->options(self::eventTypeOptions())
|
||||||
|
->native(false),
|
||||||
|
Select::make('minimum_severity')
|
||||||
|
->required()
|
||||||
|
->options(self::severityOptions())
|
||||||
|
->native(false),
|
||||||
|
]),
|
||||||
|
Section::make('Applies to')
|
||||||
|
->schema([
|
||||||
|
Select::make('tenant_scope_mode')
|
||||||
|
->label('Applies to tenants')
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
|
||||||
|
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
|
||||||
|
])
|
||||||
|
->default(AlertRule::TENANT_SCOPE_ALL)
|
||||||
|
->native(false)
|
||||||
|
->live()
|
||||||
|
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
|
||||||
|
Select::make('tenant_allowlist')
|
||||||
|
->label('Selected tenants')
|
||||||
|
->multiple()
|
||||||
|
->options(self::tenantOptions())
|
||||||
|
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
|
||||||
|
->native(false)
|
||||||
|
->helperText('Only these tenants will trigger this rule.'),
|
||||||
|
]),
|
||||||
|
Section::make('Delivery')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('cooldown_seconds')
|
||||||
|
->label('Cooldown (seconds)')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->nullable(),
|
||||||
|
Toggle::make('quiet_hours_enabled')
|
||||||
|
->label('Enable quiet hours')
|
||||||
|
->default(false)
|
||||||
|
->live(),
|
||||||
|
TextInput::make('quiet_hours_start')
|
||||||
|
->label('Quiet hours start')
|
||||||
|
->type('time')
|
||||||
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||||
|
TextInput::make('quiet_hours_end')
|
||||||
|
->label('Quiet hours end')
|
||||||
|
->type('time')
|
||||||
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||||
|
Select::make('quiet_hours_timezone')
|
||||||
|
->label('Quiet hours timezone')
|
||||||
|
->options(self::timezoneOptions())
|
||||||
|
->searchable()
|
||||||
|
->native(false)
|
||||||
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||||
|
Select::make('destination_ids')
|
||||||
|
->label('Destinations')
|
||||||
|
->multiple()
|
||||||
|
->required()
|
||||||
|
->options(self::destinationOptions())
|
||||||
|
->native(false),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('name')
|
||||||
|
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
||||||
|
? static::getUrl('edit', ['record' => $record])
|
||||||
|
: null)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('event_type')
|
||||||
|
->label('Event')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
|
||||||
|
TextColumn::make('minimum_severity')
|
||||||
|
->label('Min severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
|
||||||
|
TextColumn::make('destinations_count')
|
||||||
|
->label('Destinations')
|
||||||
|
->counts('destinations'),
|
||||||
|
TextColumn::make('is_enabled')
|
||||||
|
->label('Enabled')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||||
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
EditAction::make()
|
||||||
|
->label('Edit')
|
||||||
|
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('toggle_enabled')
|
||||||
|
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
|
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (AlertRule $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enabled = ! (bool) $record->is_enabled;
|
||||||
|
$record->forceFill([
|
||||||
|
'is_enabled' => $enabled,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$actionId = $enabled
|
||||||
|
? AuditActionId::AlertRuleEnabled
|
||||||
|
: AuditActionId::AlertRuleDisabled;
|
||||||
|
|
||||||
|
self::audit($record, $actionId, [
|
||||||
|
'alert_rule_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'event_type' => (string) $record->event_type,
|
||||||
|
'is_enabled' => $enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($enabled ? 'Rule enabled' : 'Rule disabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Action::make('delete')
|
||||||
|
->label('Delete')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (AlertRule $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::audit($record, AuditActionId::AlertRuleDeleted, [
|
||||||
|
'alert_rule_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'event_type' => (string) $record->event_type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Rule deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
])->label('More'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([])->label('More'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListAlertRules::route('/'),
|
||||||
|
'create' => Pages\CreateAlertRule::route('/create'),
|
||||||
|
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function normalizePayload(array $data): array
|
||||||
|
{
|
||||||
|
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
|
||||||
|
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
|
||||||
|
|
||||||
|
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
|
||||||
|
$tenantAllowlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
|
||||||
|
|
||||||
|
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
|
||||||
|
$data['tenant_allowlist'] = $tenantAllowlist;
|
||||||
|
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
|
||||||
|
$data['quiet_hours_enabled'] = $quietHoursEnabled;
|
||||||
|
|
||||||
|
if (! $quietHoursEnabled) {
|
||||||
|
$data['quiet_hours_start'] = null;
|
||||||
|
$data['quiet_hours_end'] = null;
|
||||||
|
$data['quiet_hours_timezone'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $destinationIds
|
||||||
|
*/
|
||||||
|
public static function syncDestinations(AlertRule $record, array $destinationIds): void
|
||||||
|
{
|
||||||
|
$allowedDestinationIds = AlertDestination::query()
|
||||||
|
->where('workspace_id', (int) $record->workspace_id)
|
||||||
|
->whereIn('id', $destinationIds)
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn (mixed $value): int => (int) $value)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$record->destinations()->syncWithPivotValues(
|
||||||
|
array_values(array_unique($allowedDestinationIds)),
|
||||||
|
['workspace_id' => (int) $record->workspace_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function eventTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||||
|
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||||
|
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||||
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||||
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function severityOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'low' => 'Low',
|
||||||
|
'medium' => 'Medium',
|
||||||
|
'high' => 'High',
|
||||||
|
'critical' => 'Critical',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function eventTypeLabel(string $eventType): string
|
||||||
|
{
|
||||||
|
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function destinationOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDestination::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function tenantOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function timezoneOptions(): array
|
||||||
|
{
|
||||||
|
$identifiers = \DateTimeZone::listIdentifiers();
|
||||||
|
sort($identifiers);
|
||||||
|
|
||||||
|
return array_combine($identifiers, $identifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
|
||||||
|
{
|
||||||
|
$workspace = $record->workspace;
|
||||||
|
|
||||||
|
if ($workspace === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: $actionId->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => $metadata,
|
||||||
|
],
|
||||||
|
actor: $actor instanceof User ? $actor : null,
|
||||||
|
resourceType: 'alert_rule',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertRuleResource;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class CreateAlertRule extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertRuleResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, int>
|
||||||
|
*/
|
||||||
|
private array $destinationIds = [];
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$data['workspace_id'] = (int) $workspaceId;
|
||||||
|
|
||||||
|
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (mixed $value): int => (int) $value,
|
||||||
|
Arr::wrap($data['destination_ids'] ?? []),
|
||||||
|
))));
|
||||||
|
|
||||||
|
unset($data['destination_ids']);
|
||||||
|
|
||||||
|
return AlertRuleResource::normalizePayload($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertRule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||||
|
|
||||||
|
AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [
|
||||||
|
'alert_rule_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'event_type' => (string) $record->event_type,
|
||||||
|
'minimum_severity' => (string) $record->minimum_severity,
|
||||||
|
'is_enabled' => (bool) $record->is_enabled,
|
||||||
|
'destination_ids' => $this->destinationIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Rule created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertRuleResource;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class EditAlertRule extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertRuleResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, int>
|
||||||
|
*/
|
||||||
|
private array $destinationIds = [];
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeFill(array $data): array
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if ($record instanceof AlertRule) {
|
||||||
|
$data['destination_ids'] = $record->destinations()
|
||||||
|
->pluck('alert_destinations.id')
|
||||||
|
->map(static fn (mixed $value): int => (int) $value)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (mixed $value): int => (int) $value,
|
||||||
|
Arr::wrap($data['destination_ids'] ?? []),
|
||||||
|
))));
|
||||||
|
|
||||||
|
unset($data['destination_ids']);
|
||||||
|
|
||||||
|
return AlertRuleResource::normalizePayload($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof AlertRule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||||
|
|
||||||
|
AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [
|
||||||
|
'alert_rule_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'event_type' => (string) $record->event_type,
|
||||||
|
'minimum_severity' => (string) $record->minimum_severity,
|
||||||
|
'is_enabled' => (bool) $record->is_enabled,
|
||||||
|
'destination_ids' => $this->destinationIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Rule updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertRuleResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAlertRules extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AlertRuleResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create rule')
|
||||||
|
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create rule')
|
||||||
|
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
use App\Exceptions\InvalidPolicyTypeException;
|
use App\Exceptions\InvalidPolicyTypeException;
|
||||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
|
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||||
use App\Jobs\RunBackupScheduleJob;
|
use App\Jobs\RunBackupScheduleJob;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\SupportedPolicyTypesRule;
|
use App\Rules\SupportedPolicyTypesRule;
|
||||||
@ -22,18 +21,20 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\DeleteBulkAction;
|
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -46,18 +47,23 @@
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class BackupScheduleResource extends Resource
|
class BackupScheduleResource extends Resource
|
||||||
{
|
{
|
||||||
|
protected static ?string $model = BackupSchedule::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
@ -166,6 +172,17 @@ public static function canDeleteAny(): bool
|
|||||||
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -240,6 +257,9 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('next_run_at', 'asc')
|
->defaultSort('next_run_at', 'asc')
|
||||||
|
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
|
||||||
|
? static::getUrl('edit', ['record' => $record])
|
||||||
|
: null)
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('is_enabled')
|
TextColumn::make('is_enabled')
|
||||||
->label('Enabled')
|
->label('Enabled')
|
||||||
@ -280,32 +300,40 @@ public static function table(Table $table): Table
|
|||||||
->label('Last run status')
|
->label('Last run status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(function (?string $state): string {
|
->formatStateUsing(function (?string $state): string {
|
||||||
if (! filled($state)) {
|
$outcome = static::scheduleStatusToOutcome($state);
|
||||||
|
|
||||||
|
if (! filled($outcome)) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label;
|
||||||
})
|
})
|
||||||
->color(function (?string $state): string {
|
->color(function (?string $state): string {
|
||||||
if (! filled($state)) {
|
$outcome = static::scheduleStatusToOutcome($state);
|
||||||
|
|
||||||
|
if (! filled($outcome)) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color;
|
||||||
})
|
})
|
||||||
->icon(function (?string $state): ?string {
|
->icon(function (?string $state): ?string {
|
||||||
if (! filled($state)) {
|
$outcome = static::scheduleStatusToOutcome($state);
|
||||||
|
|
||||||
|
if (! filled($outcome)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon;
|
||||||
})
|
})
|
||||||
->iconColor(function (?string $state): string {
|
->iconColor(function (?string $state): string {
|
||||||
if (! filled($state)) {
|
$outcome = static::scheduleStatusToOutcome($state);
|
||||||
|
|
||||||
|
if (! filled($outcome)) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state);
|
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||||
|
|
||||||
return $spec->iconColor ?? $spec->color;
|
return $spec->iconColor ?? $spec->color;
|
||||||
}),
|
}),
|
||||||
@ -335,6 +363,11 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
TrashedFilter::make()
|
||||||
|
->label('Archived')
|
||||||
|
->placeholder('Active')
|
||||||
|
->trueLabel('All')
|
||||||
|
->falseLabel('Archived'),
|
||||||
SelectFilter::make('enabled_state')
|
SelectFilter::make('enabled_state')
|
||||||
->label('Enabled')
|
->label('Enabled')
|
||||||
->options([
|
->options([
|
||||||
@ -366,6 +399,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Run now')
|
->label('Run now')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
@ -384,104 +418,38 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
/** @var OperationRunService $operationRunService */
|
/** @var OperationRunService $operationRunService */
|
||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
$operationRun = $operationRunService->ensureRun(
|
$nonce = (string) Str::uuid();
|
||||||
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule.run_now',
|
type: 'backup_schedule_run',
|
||||||
inputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'nonce' => $nonce,
|
||||||
],
|
],
|
||||||
initiator: $userModel
|
context: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'trigger' => 'run_now',
|
||||||
|
],
|
||||||
|
initiator: $userModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('This schedule already has a queued or running backup.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
||||||
$run = null;
|
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
|
||||||
try {
|
|
||||||
$run = BackupScheduleRun::create([
|
|
||||||
'backup_schedule_id' => $record->id,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'user_id' => $userId,
|
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
} catch (UniqueConstraintViolationException) {
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('Please wait a moment and try again.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
summaryCounts: [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'SCHEDULE_CONFLICT',
|
|
||||||
'message' => 'Unable to queue a unique backup schedule run.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun->update([
|
|
||||||
'context' => array_merge($operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
resourceType: 'backup_schedule_run',
|
resourceType: 'operation_run',
|
||||||
resourceId: (string) $run->id,
|
resourceId: (string) $operationRun->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'backup_schedule_run_id' => $run->id,
|
'operation_run_id' => $operationRun->getKey(),
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'trigger' => 'run_now',
|
'trigger' => 'run_now',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
|
||||||
});
|
});
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
@ -494,6 +462,7 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
@ -501,6 +470,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Retry')
|
->label('Retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
@ -519,104 +489,38 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
/** @var OperationRunService $operationRunService */
|
/** @var OperationRunService $operationRunService */
|
||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
$operationRun = $operationRunService->ensureRun(
|
$nonce = (string) Str::uuid();
|
||||||
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule.retry',
|
type: 'backup_schedule_run',
|
||||||
inputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'nonce' => $nonce,
|
||||||
],
|
],
|
||||||
initiator: $userModel
|
context: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'trigger' => 'retry',
|
||||||
|
],
|
||||||
|
initiator: $userModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Retry already queued')
|
|
||||||
->body('This schedule already has a queued or running retry.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
||||||
$run = null;
|
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
|
||||||
try {
|
|
||||||
$run = BackupScheduleRun::create([
|
|
||||||
'backup_schedule_id' => $record->id,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'user_id' => $userId,
|
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
} catch (UniqueConstraintViolationException) {
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Retry already queued')
|
|
||||||
->body('Please wait a moment and try again.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
summaryCounts: [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'SCHEDULE_CONFLICT',
|
|
||||||
'message' => 'Unable to queue a unique backup schedule retry run.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRun->update([
|
|
||||||
'context' => array_merge($operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
resourceType: 'backup_schedule_run',
|
resourceType: 'operation_run',
|
||||||
resourceId: (string) $run->id,
|
resourceId: (string) $operationRun->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'backup_schedule_run_id' => $run->id,
|
'operation_run_id' => $operationRun->getKey(),
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'trigger' => 'retry',
|
'trigger' => 'retry',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
|
||||||
});
|
});
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
@ -629,6 +533,7 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
@ -637,11 +542,141 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
DeleteAction::make()
|
Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
Gate::authorize('delete', $record);
|
||||||
|
|
||||||
|
if ($record->trashed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup_schedule.archived',
|
||||||
|
resourceType: 'backup_schedule',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'backup_schedule_id' => $record->getKey(),
|
||||||
|
'backup_schedule_name' => $record->name,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup schedule archived')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
Gate::authorize('restore', $record);
|
||||||
|
|
||||||
|
if (! $record->trashed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup_schedule.restored',
|
||||||
|
resourceType: 'backup_schedule',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'backup_schedule_id' => $record->getKey(),
|
||||||
|
'backup_schedule_name' => $record->name,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup schedule restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
UiEnforcement::forAction(
|
||||||
|
Action::make('forceDelete')
|
||||||
|
->label('Force delete')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
Gate::authorize('forceDelete', $record);
|
||||||
|
|
||||||
|
if (! $record->trashed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->operationRuns()->exists()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot force delete backup schedule')
|
||||||
|
->body('Backup schedules referenced by historical runs cannot be removed.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup_schedule.force_deleted',
|
||||||
|
resourceType: 'backup_schedule',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'backup_schedule_id' => $record->getKey(),
|
||||||
|
'backup_schedule_name' => $record->name,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->forceDelete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup schedule permanently deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
@ -674,96 +709,52 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$bulkRun = null;
|
$bulkRun = null;
|
||||||
|
|
||||||
$createdRunIds = [];
|
$createdOperationRunIds = [];
|
||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$operationRun = $operationRunService->ensureRun(
|
$nonce = (string) Str::uuid();
|
||||||
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule.run_now',
|
type: 'backup_schedule_run',
|
||||||
inputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'nonce' => $nonce,
|
||||||
],
|
],
|
||||||
initiator: $user
|
context: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'trigger' => 'bulk_run_now',
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
$createdOperationRunIds[] = (int) $operationRun->getKey();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
||||||
$run = null;
|
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
|
||||||
try {
|
|
||||||
$run = BackupScheduleRun::create([
|
|
||||||
'backup_schedule_id' => $record->id,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'user_id' => $userId,
|
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
} catch (UniqueConstraintViolationException) {
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
summaryCounts: [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'SCHEDULE_CONFLICT',
|
|
||||||
'message' => 'Unable to queue a unique backup schedule run.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
|
||||||
|
|
||||||
$operationRun->update([
|
|
||||||
'context' => array_merge($operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
resourceType: 'backup_schedule_run',
|
resourceType: 'operation_run',
|
||||||
resourceId: (string) $run->id,
|
resourceId: (string) $operationRun->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'backup_schedule_run_id' => $run->id,
|
'operation_run_id' => $operationRun->getKey(),
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'trigger' => 'bulk_run_now',
|
'trigger' => 'bulk_run_now',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
|
||||||
}, emitQueuedNotification: false);
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Runs dispatched')
|
->title('Runs dispatched')
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
|
||||||
|
|
||||||
if (count($createdRunIds) === 0) {
|
if (count($createdOperationRunIds) === 0) {
|
||||||
$notification->warning();
|
$notification->warning();
|
||||||
} else {
|
} else {
|
||||||
$notification->success();
|
$notification->success();
|
||||||
@ -774,12 +765,12 @@ public static function table(Table $table): Table
|
|||||||
Action::make('view_runs')
|
Action::make('view_runs')
|
||||||
->label('View in Operations')
|
->label('View in Operations')
|
||||||
->url(OperationRunLinks::index($tenant)),
|
->url(OperationRunLinks::index($tenant)),
|
||||||
])->sendToDatabase($user);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
|
|
||||||
if (count($createdRunIds) > 0) {
|
if (count($createdOperationRunIds) > 0) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -815,96 +806,52 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$bulkRun = null;
|
$bulkRun = null;
|
||||||
|
|
||||||
$createdRunIds = [];
|
$createdOperationRunIds = [];
|
||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$operationRun = $operationRunService->ensureRun(
|
$nonce = (string) Str::uuid();
|
||||||
|
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_schedule.retry',
|
type: 'backup_schedule_run',
|
||||||
inputs: [
|
identityInputs: [
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'nonce' => $nonce,
|
||||||
],
|
],
|
||||||
initiator: $user
|
context: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'trigger' => 'bulk_retry',
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
$createdOperationRunIds[] = (int) $operationRun->getKey();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
|
||||||
$run = null;
|
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
|
||||||
try {
|
|
||||||
$run = BackupScheduleRun::create([
|
|
||||||
'backup_schedule_id' => $record->id,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'user_id' => $userId,
|
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
} catch (UniqueConstraintViolationException) {
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
summaryCounts: [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
],
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'SCHEDULE_CONFLICT',
|
|
||||||
'message' => 'Unable to queue a unique backup schedule retry run.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
|
||||||
|
|
||||||
$operationRun->update([
|
|
||||||
'context' => array_merge($operationRun->context ?? [], [
|
|
||||||
'backup_schedule_id' => (int) $record->getKey(),
|
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
resourceType: 'backup_schedule_run',
|
resourceType: 'operation_run',
|
||||||
resourceId: (string) $run->id,
|
resourceId: (string) $operationRun->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'backup_schedule_id' => $record->id,
|
'backup_schedule_id' => $record->id,
|
||||||
'backup_schedule_run_id' => $run->id,
|
'operation_run_id' => $operationRun->getKey(),
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'trigger' => 'bulk_retry',
|
'trigger' => 'bulk_retry',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob(operationRun: $operationRun, backupScheduleId: (int) $record->getKey()));
|
||||||
}, emitQueuedNotification: false);
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Retries dispatched')
|
->title('Retries dispatched')
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
|
||||||
|
|
||||||
if (count($createdRunIds) === 0) {
|
if (count($createdOperationRunIds) === 0) {
|
||||||
$notification->warning();
|
$notification->warning();
|
||||||
} else {
|
} else {
|
||||||
$notification->success();
|
$notification->success();
|
||||||
@ -915,24 +862,19 @@ public static function table(Table $table): Table
|
|||||||
Action::make('view_runs')
|
Action::make('view_runs')
|
||||||
->label('View in Operations')
|
->label('View in Operations')
|
||||||
->url(OperationRunLinks::index($tenant)),
|
->url(OperationRunLinks::index($tenant)),
|
||||||
])->sendToDatabase($user);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
|
|
||||||
if (count($createdRunIds) > 0) {
|
if (count($createdOperationRunIds) > 0) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
])->label('More'),
|
||||||
DeleteBulkAction::make('bulk_delete')
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -946,10 +888,15 @@ public static function getEloquentQuery(): Builder
|
|||||||
->orderBy('next_run_at');
|
->orderBy('next_run_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::getEloquentQuery()->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BackupScheduleRunsRelationManager::class,
|
BackupScheduleOperationRunsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1119,6 +1066,18 @@ protected static function policyTypeLabelMap(): array
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function scheduleStatusToOutcome(?string $status): ?string
|
||||||
|
{
|
||||||
|
return match (strtolower(trim((string) $status))) {
|
||||||
|
'running' => OperationRunOutcome::Pending->value,
|
||||||
|
'success' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'skipped' => OperationRunOutcome::Blocked->value,
|
||||||
|
'failed', 'canceled' => OperationRunOutcome::Failed->value,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected static function dayOfWeekOptions(): array
|
protected static function dayOfWeekOptions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -4,11 +4,26 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
|
||||||
class EditBackupSchedule extends EditRecord
|
class EditBackupSchedule extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = BackupScheduleResource::class;
|
protected static string $resource = BackupScheduleResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
$record = BackupScheduleResource::getEloquentQuery()
|
||||||
|
->withTrashed()
|
||||||
|
->find($key);
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
{
|
{
|
||||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||||
|
|||||||
@ -12,8 +12,37 @@ class ListBackupSchedules extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [$this->makeHeaderCreateAction()];
|
||||||
Actions\CreateAction::make(),
|
}
|
||||||
];
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [$this->makeEmptyStateCreateAction()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeHeaderCreateAction(): Actions\CreateAction
|
||||||
|
{
|
||||||
|
return $this->makeCreateAction()
|
||||||
|
->visible(fn (): bool => $this->tableHasRecords());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||||
|
{
|
||||||
|
return $this->makeCreateAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeCreateAction(): Actions\CreateAction
|
||||||
|
{
|
||||||
|
return Actions\CreateAction::make()
|
||||||
|
->label('New backup schedule')
|
||||||
|
->disabled(fn (): bool => ! BackupScheduleResource::canCreate())
|
||||||
|
->tooltip(fn (): ?string => BackupScheduleResource::canCreate()
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to create backup schedules.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||||
|
|
||||||
|
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\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'operationRuns';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Executions';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('Enqueued')
|
||||||
|
->dateTime(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('type')
|
||||||
|
->label('Type')
|
||||||
|
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||||
|
|
||||||
|
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('outcome')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('counts')
|
||||||
|
->label('Counts')
|
||||||
|
->getStateUsing(function (OperationRun $record): string {
|
||||||
|
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
|
||||||
|
|
||||||
|
$total = (int) ($counts['total'] ?? 0);
|
||||||
|
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
||||||
|
$failed = (int) ($counts['failed'] ?? 0);
|
||||||
|
|
||||||
|
if ($total === 0 && $succeeded === 0 && $failed === 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->filters([])
|
||||||
|
->headerActions([])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->url(function (OperationRun $record): string {
|
||||||
|
$tenant = Tenant::currentOrFail();
|
||||||
|
|
||||||
|
return OperationRunLinks::view($record, $tenant);
|
||||||
|
})
|
||||||
|
->openUrlInNewTab(true),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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::currentOrFail()->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([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -36,6 +36,7 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -43,10 +44,28 @@ class BackupSetResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = BackupSet::class;
|
protected static ?string $model = BackupSet::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
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->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -63,6 +82,17 @@ public static function canCreate(): bool
|
|||||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -452,7 +482,7 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
])->label('More'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -12,10 +12,32 @@ class ListBackupSets extends ListRecords
|
|||||||
{
|
{
|
||||||
protected static string $resource = BackupSetResource::class;
|
protected static string $resource = BackupSetResource::class;
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return [
|
||||||
|
$create,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -105,10 +104,9 @@ public function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->info()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -196,10 +194,9 @@ public function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->info()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -337,12 +334,15 @@ public function table(Table $table): Table
|
|||||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||||
->openUrlInNewTab(true),
|
->openUrlInNewTab(true),
|
||||||
$removeItem,
|
$removeItem,
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
$bulkRemove,
|
$bulkRemove,
|
||||||
]),
|
])->label('More'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
410
app/Filament/Resources/BaselineProfileResource.php
Normal file
410
app/Filament/Resources/BaselineProfileResource.php
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BaselineProfileResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = BaselineProfile::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'baseline-profiles';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Baselines';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = self::resolveWorkspace();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return self::hasManageCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
return self::hasManageCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
return self::hasManageCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
return self::canViewAny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with(['activeSnapshot', 'createdByUser'])
|
||||||
|
->when(
|
||||||
|
$workspaceId !== null,
|
||||||
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceId === null,
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Profile')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('A descriptive name for this baseline profile.'),
|
||||||
|
Textarea::make('description')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(1000)
|
||||||
|
->helperText('Explain the purpose and scope of this baseline.'),
|
||||||
|
TextInput::make('version_label')
|
||||||
|
->label('Version label')
|
||||||
|
->maxLength(50)
|
||||||
|
->placeholder('e.g. v2.1 — February rollout')
|
||||||
|
->helperText('Optional label to identify this version.'),
|
||||||
|
Select::make('status')
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
BaselineProfile::STATUS_DRAFT => 'Draft',
|
||||||
|
BaselineProfile::STATUS_ACTIVE => 'Active',
|
||||||
|
BaselineProfile::STATUS_ARCHIVED => 'Archived',
|
||||||
|
])
|
||||||
|
->default(BaselineProfile::STATUS_DRAFT)
|
||||||
|
->native(false)
|
||||||
|
->helperText('Only active baselines are enforced during compliance checks.'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Scope')
|
||||||
|
->schema([
|
||||||
|
Select::make('scope_jsonb.policy_types')
|
||||||
|
->label('Policy type scope')
|
||||||
|
->multiple()
|
||||||
|
->options(self::policyTypeOptions())
|
||||||
|
->helperText('Leave empty to include all policy types.')
|
||||||
|
->native(false),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Profile')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('name'),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
|
||||||
|
TextEntry::make('version_label')
|
||||||
|
->label('Version')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('description')
|
||||||
|
->placeholder('No description')
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Scope')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('scope_jsonb.policy_types')
|
||||||
|
->label('Policy type scope')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function (string $state): string {
|
||||||
|
$options = self::policyTypeOptions();
|
||||||
|
|
||||||
|
return $options[$state] ?? $state;
|
||||||
|
})
|
||||||
|
->placeholder('All policy types'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Metadata')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('createdByUser.name')
|
||||||
|
->label('Created by')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('activeSnapshot.captured_at')
|
||||||
|
->label('Last snapshot')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('No snapshot yet'),
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->dateTime(),
|
||||||
|
TextEntry::make('updated_at')
|
||||||
|
->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
$workspace = self::resolveWorkspace();
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->defaultSort('name')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('version_label')
|
||||||
|
->label('Version')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('activeSnapshot.captured_at')
|
||||||
|
->label('Last snapshot')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('No snapshot'),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
|
->icon('heroicon-o-eye'),
|
||||||
|
ActionGroup::make([
|
||||||
|
Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->visible(fn (): bool => self::hasManageCapability()),
|
||||||
|
self::archiveTableAction($workspace),
|
||||||
|
])->label('More'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([])->label('More'),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No baseline profiles')
|
||||||
|
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('create')
|
||||||
|
->label('Create baseline profile')
|
||||||
|
->url(fn (): string => static::getUrl('create'))
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->visible(fn (): bool => self::hasManageCapability()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBaselineProfiles::route('/'),
|
||||||
|
'create' => Pages\CreateBaselineProfile::route('/create'),
|
||||||
|
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
||||||
|
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function policyTypeOptions(): array
|
||||||
|
{
|
||||||
|
return collect(InventoryPolicyTypeMeta::all())
|
||||||
|
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
|
||||||
|
])
|
||||||
|
->sort()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
|
||||||
|
{
|
||||||
|
$workspace = $record->workspace;
|
||||||
|
|
||||||
|
if ($workspace === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = auth()->user();
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: $actionId->value,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $actor instanceof User ? $actor : null,
|
||||||
|
resourceType: 'baseline_profile',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveWorkspace(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasManageCapability(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = self::resolveWorkspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function archiveTableAction(?Workspace $workspace): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->icon('heroicon-o-archive-box')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Archive baseline profile')
|
||||||
|
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
|
||||||
|
->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability())
|
||||||
|
->action(function (BaselineProfile $record): void {
|
||||||
|
if (! self::hasManageCapability()) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
|
||||||
|
|
||||||
|
self::audit($record, AuditActionId::BaselineProfileArchived, [
|
||||||
|
'baseline_profile_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Baseline profile archived')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($workspace instanceof Workspace) {
|
||||||
|
$action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->destructive()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBaselineProfile extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$data['workspace_id'] = (int) $workspaceId;
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||||
|
|
||||||
|
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||||
|
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof BaselineProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
|
||||||
|
'baseline_profile_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Baseline profile created')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBaselineProfile extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||||
|
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
if (! $record instanceof BaselineProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
|
||||||
|
'baseline_profile_id' => (int) $record->getKey(),
|
||||||
|
'name' => (string) $record->name,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Baseline profile updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBaselineProfiles extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create baseline profile')
|
||||||
|
->disabled(fn (): bool => ! BaselineProfileResource::canCreate())
|
||||||
|
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Baselines\BaselineCaptureService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBaselineProfile extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BaselineProfileResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->captureAction(),
|
||||||
|
EditAction::make()
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function captureAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('capture')
|
||||||
|
->label('Capture Snapshot')
|
||||||
|
->icon('heroicon-o-camera')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->disabled(fn (): bool => ! $this->hasManageCapability())
|
||||||
|
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Capture Baseline Snapshot')
|
||||||
|
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
|
||||||
|
->form([
|
||||||
|
Select::make('source_tenant_id')
|
||||||
|
->label('Source Tenant')
|
||||||
|
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
||||||
|
->required()
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Permission denied')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
||||||
|
|
||||||
|
if (! $sourceTenant instanceof Tenant) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Source tenant not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $sourceTenant, $user);
|
||||||
|
|
||||||
|
if (! $result['ok']) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot start capture')
|
||||||
|
->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $result['run'] ?? null;
|
||||||
|
|
||||||
|
if (! $run instanceof \App\Models\OperationRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot start capture')
|
||||||
|
->body('Reason: missing operation run')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewAction = Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($run, $sourceTenant));
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||||
|
->actions([$viewAction])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $run->type)
|
||||||
|
->actions([$viewAction])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function getWorkspaceTenantOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasManageCapability(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class BaselineTenantAssignmentsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'tenantAssignments';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Tenant assignments';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('assignedByUser.name')
|
||||||
|
->label('Assigned by')
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('Assigned at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
$this->assignTenantAction(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
$this->removeAssignmentAction(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No tenants assigned')
|
||||||
|
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
||||||
|
->emptyStateActions([
|
||||||
|
$this->assignTenantAction(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignTenantAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('assign')
|
||||||
|
->label('Assign Tenant')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->form([
|
||||||
|
Select::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->getAvailableTenantOptions())
|
||||||
|
->required()
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Permission denied')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
$tenantId = (int) $data['tenant_id'];
|
||||||
|
|
||||||
|
$existing = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof BaselineTenantAssignment) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant already assigned')
|
||||||
|
->body('This tenant already has a baseline assignment in this workspace.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditAssignment($profile, $assignment, $user, 'created');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant assigned')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeAssignmentAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('remove')
|
||||||
|
->label('Remove')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Remove tenant assignment')
|
||||||
|
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
|
||||||
|
->action(function (BaselineTenantAssignment $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Permission denied')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$this->auditAssignment($profile, $record, $user, 'removed');
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Assignment removed')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function getAvailableTenantOptions(): array
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$assignedTenantIds = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$query = Tenant::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
if (! empty($assignedTenantIds)) {
|
||||||
|
$query->whereNotIn('id', $assignedTenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->pluck('name', 'id')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditAssignment(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
BaselineTenantAssignment $assignment,
|
||||||
|
User $user,
|
||||||
|
string $action,
|
||||||
|
): void {
|
||||||
|
$workspace = Workspace::query()->find($profile->workspace_id);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->find($assignment->tenant_id);
|
||||||
|
|
||||||
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: 'baseline.assignment.'.$action,
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
|
'tenant_id' => (int) $assignment->tenant_id,
|
||||||
|
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'baseline_profile',
|
||||||
|
resourceId: (string) $profile->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasManageCapability(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,18 +3,17 @@
|
|||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
||||||
use App\Jobs\EntraGroupSyncJob;
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
use App\Models\EntraGroupSyncRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
class ListEntraGroups extends ListRecords
|
||||||
@ -24,16 +23,16 @@ class ListEntraGroups extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('view_group_sync_runs')
|
Action::make('view_operations')
|
||||||
->label('Group Sync Runs')
|
->label('Operations')
|
||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
|
||||||
->visible(fn (): bool => (bool) Tenant::current()),
|
->visible(fn (): bool => (bool) Tenant::current()),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('sync_groups')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('primary')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -47,18 +46,20 @@ protected function getHeaderActions(): array
|
|||||||
// --- Phase 3: Canonical Operation Run Start ---
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'directory_groups.sync',
|
type: 'entra_group_sync',
|
||||||
inputs: ['selection_key' => $selectionKey],
|
identityInputs: ['selection_key' => $selectionKey],
|
||||||
initiator: $user
|
context: [
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'trigger' => 'manual',
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Group sync already active')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View Run')
|
||||||
@ -70,55 +71,21 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
|
|
||||||
$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(
|
dispatch(new EntraGroupSyncJob(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
selectionKey: $selectionKey,
|
selectionKey: $selectionKey,
|
||||||
slotKey: null,
|
slotKey: null,
|
||||||
runId: (int) $run->getKey(),
|
runId: null,
|
||||||
operationRun: $opRun
|
operationRun: $opRun
|
||||||
));
|
));
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Group sync started')
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->body('Sync dispatched.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View Run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,168 +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 App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\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 actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
])
|
|
||||||
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
|
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->actions([])
|
|
||||||
->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;
|
|
||||||
}
|
|
||||||
@ -7,18 +7,26 @@
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -32,15 +40,19 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class FindingResource extends Resource
|
class FindingResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Finding::class;
|
protected static ?string $model = Finding::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Findings';
|
protected static ?string $navigationLabel = 'Findings';
|
||||||
|
|
||||||
@ -58,7 +70,7 @@ public static function canViewAny(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_VIEW, $tenant);
|
return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
@ -75,7 +87,7 @@ public static function canView(Model $record): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
|
if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +98,17 @@ public static function canView(Model $record): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -115,26 +138,48 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||||
TextEntry::make('subject_type')->label('Subject type'),
|
TextEntry::make('subject_type')->label('Subject type'),
|
||||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||||
TextEntry::make('baseline_run_id')
|
TextEntry::make('baseline_operation_run_id')
|
||||||
->label('Baseline run')
|
->label('Baseline run')
|
||||||
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
|
||||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
|
||||||
: null)
|
: null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
TextEntry::make('current_run_id')
|
TextEntry::make('current_operation_run_id')
|
||||||
->label('Current run')
|
->label('Current run')
|
||||||
->url(fn (Finding $record): ?string => $record->current_run_id
|
->url(fn (Finding $record): ?string => $record->current_operation_run_id
|
||||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
|
||||||
: null)
|
: null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||||
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||||
|
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
|
||||||
|
TextEntry::make('sla_days')->label('SLA days')->placeholder('—'),
|
||||||
|
TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('owner_user_id')
|
||||||
|
->label('Owner')
|
||||||
|
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||||
|
TextEntry::make('assignee_user_id')
|
||||||
|
->label('Assignee')
|
||||||
|
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||||
|
TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
|
||||||
|
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
|
||||||
|
TextEntry::make('closed_by_user_id')
|
||||||
|
->label('Closed by')
|
||||||
|
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Diff')
|
Section::make('Diff')
|
||||||
|
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings_diff')
|
ViewEntry::make('settings_diff')
|
||||||
->label('')
|
->label('')
|
||||||
@ -260,22 +305,65 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
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_type')->label('Subject type')->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('due_at')
|
||||||
|
->label('Due')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||||
|
->label('Assignee')
|
||||||
|
->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Tables\Filters\Filter::make('open')
|
||||||
|
->label('Open')
|
||||||
|
->default()
|
||||||
|
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
||||||
|
Tables\Filters\Filter::make('overdue')
|
||||||
|
->label('Overdue')
|
||||||
|
->query(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())),
|
||||||
|
Tables\Filters\Filter::make('high_severity')
|
||||||
|
->label('High severity')
|
||||||
|
->query(fn (Builder $query): Builder => $query->whereIn('severity', [
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
])),
|
||||||
|
Tables\Filters\Filter::make('my_assigned')
|
||||||
|
->label('My assigned')
|
||||||
|
->query(function (Builder $query): Builder {
|
||||||
|
$userId = auth()->id();
|
||||||
|
|
||||||
|
if (! is_numeric($userId)) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('assignee_user_id', (int) $userId);
|
||||||
|
}),
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options([
|
->options([
|
||||||
Finding::STATUS_NEW => 'New',
|
Finding::STATUS_NEW => 'New',
|
||||||
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
Finding::STATUS_TRIAGED => 'Triaged',
|
||||||
|
Finding::STATUS_ACKNOWLEDGED => 'Triaged (legacy acknowledged)',
|
||||||
|
Finding::STATUS_IN_PROGRESS => 'In progress',
|
||||||
|
Finding::STATUS_REOPENED => 'Reopened',
|
||||||
|
Finding::STATUS_RESOLVED => 'Resolved',
|
||||||
|
Finding::STATUS_CLOSED => 'Closed',
|
||||||
|
Finding::STATUS_RISK_ACCEPTED => 'Risk accepted',
|
||||||
])
|
])
|
||||||
->default(Finding::STATUS_NEW),
|
->label('Status'),
|
||||||
Tables\Filters\SelectFilter::make('finding_type')
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
->options([
|
->options([
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
|
Finding::FINDING_TYPE_PERMISSION_POSTURE => 'Permission posture',
|
||||||
|
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles',
|
||||||
])
|
])
|
||||||
->default(Finding::FINDING_TYPE_DRIFT),
|
->label('Type'),
|
||||||
Tables\Filters\Filter::make('scope_key')
|
Tables\Filters\Filter::make('scope_key')
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('scope_key')
|
TextInput::make('scope_key')
|
||||||
@ -295,78 +383,45 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\Filter::make('run_ids')
|
Tables\Filters\Filter::make('run_ids')
|
||||||
->label('Run IDs')
|
->label('Run IDs')
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('baseline_run_id')
|
TextInput::make('baseline_operation_run_id')
|
||||||
->label('Baseline run id')
|
->label('Baseline run id')
|
||||||
->numeric(),
|
->numeric(),
|
||||||
TextInput::make('current_run_id')
|
TextInput::make('current_operation_run_id')
|
||||||
->label('Current run id')
|
->label('Current run id')
|
||||||
->numeric(),
|
->numeric(),
|
||||||
])
|
])
|
||||||
->query(function (Builder $query, array $data): Builder {
|
->query(function (Builder $query, array $data): Builder {
|
||||||
$baselineRunId = $data['baseline_run_id'] ?? null;
|
$baselineRunId = $data['baseline_operation_run_id'] ?? null;
|
||||||
if (is_numeric($baselineRunId)) {
|
if (is_numeric($baselineRunId)) {
|
||||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentRunId = $data['current_run_id'] ?? null;
|
$currentRunId = $data['current_operation_run_id'] ?? null;
|
||||||
if (is_numeric($currentRunId)) {
|
if (is_numeric($currentRunId)) {
|
||||||
$query->where('current_run_id', (int) $currentRunId);
|
$query->where('current_operation_run_id', (int) $currentRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->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(),
|
Actions\ViewAction::make(),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
|
...static::workflowActions(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('acknowledge_selected')
|
BulkAction::make('triage_selected')
|
||||||
->label('Acknowledge selected')
|
->label('Triage selected')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (Collection $records): void {
|
->action(function (Collection $records, FindingWorkflowService $workflow): void {
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -374,8 +429,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$acknowledgedCount = 0;
|
$triagedCount = 0;
|
||||||
$skippedCount = 0;
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
if (! $record instanceof Finding) {
|
if (! $record instanceof Finding) {
|
||||||
@ -390,33 +446,346 @@ public static function table(Table $table): Table
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->status !== Finding::STATUS_NEW) {
|
if (! in_array((string) $record->status, [
|
||||||
|
Finding::STATUS_NEW,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
], true)) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$record->acknowledge($user);
|
try {
|
||||||
$acknowledgedCount++;
|
$workflow->triage($record, $tenant, $user);
|
||||||
|
$triagedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
|
||||||
if ($skippedCount > 0) {
|
if ($skippedCount > 0) {
|
||||||
$body .= " Skipped {$skippedCount}.";
|
$body .= " Skipped {$skippedCount}.";
|
||||||
}
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk acknowledge completed')
|
->title('Bulk triage completed')
|
||||||
->body($body)
|
->body($body)
|
||||||
->success()
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('assign_selected')
|
||||||
|
->label('Assign selected')
|
||||||
|
->icon('heroicon-o-user-plus')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Select::make('assignee_user_id')
|
||||||
|
->label('Assignee')
|
||||||
|
->placeholder('Unassigned')
|
||||||
|
->options(fn (): array => static::tenantMemberOptions())
|
||||||
|
->searchable(),
|
||||||
|
Select::make('owner_user_id')
|
||||||
|
->label('Owner')
|
||||||
|
->placeholder('Unassigned')
|
||||||
|
->options(fn (): array => static::tenantMemberOptions())
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null;
|
||||||
|
$ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null;
|
||||||
|
|
||||||
|
$assignedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $record->hasOpenStatus()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||||
|
$assignedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk assign completed')
|
||||||
|
->body($body)
|
||||||
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('resolve_selected')
|
||||||
|
->label('Resolve selected')
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('resolved_reason')
|
||||||
|
->label('Resolution reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) ($data['resolved_reason'] ?? '');
|
||||||
|
|
||||||
|
$resolvedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $record->hasOpenStatus()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workflow->resolve($record, $tenant, $user, $reason);
|
||||||
|
$resolvedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk resolve completed')
|
||||||
|
->body($body)
|
||||||
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('close_selected')
|
||||||
|
->label('Close selected')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('closed_reason')
|
||||||
|
->label('Close reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) ($data['closed_reason'] ?? '');
|
||||||
|
|
||||||
|
$closedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $record->hasOpenStatus()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workflow->close($record, $tenant, $user, $reason);
|
||||||
|
$closedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Closed {$closedCount} finding".($closedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk close completed')
|
||||||
|
->body($body)
|
||||||
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('risk_accept_selected')
|
||||||
|
->label('Risk accept selected')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('closed_reason')
|
||||||
|
->label('Risk acceptance reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) ($data['closed_reason'] ?? '');
|
||||||
|
|
||||||
|
$acceptedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $record->hasOpenStatus()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workflow->riskAccept($record, $tenant, $user, $reason);
|
||||||
|
$acceptedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk risk accept completed')
|
||||||
|
->body($body)
|
||||||
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
])->label('More'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,6 +794,7 @@ public static function getEloquentQuery(): Builder
|
|||||||
$tenantId = Tenant::current()?->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
|
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||||
->addSelect([
|
->addSelect([
|
||||||
'subject_display_name' => InventoryItem::query()
|
'subject_display_name' => InventoryItem::query()
|
||||||
->select('display_name')
|
->select('display_name')
|
||||||
@ -442,4 +812,300 @@ public static function getPages(): array
|
|||||||
'view' => Pages\ViewFinding::route('/{record}'),
|
'view' => Pages\ViewFinding::route('/{record}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Actions\Action>
|
||||||
|
*/
|
||||||
|
public static function workflowActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
static::triageAction(),
|
||||||
|
static::startProgressAction(),
|
||||||
|
static::assignAction(),
|
||||||
|
static::resolveAction(),
|
||||||
|
static::closeAction(),
|
||||||
|
static::riskAcceptAction(),
|
||||||
|
static::reopenAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function triageAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('triage')
|
||||||
|
->label('Triage')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
|
Finding::STATUS_NEW,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
], true))
|
||||||
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding triaged',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function startProgressAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('start_progress')
|
||||||
|
->label('Start progress')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
|
Finding::STATUS_TRIAGED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
], true))
|
||||||
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding moved to in progress',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assignAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('assign')
|
||||||
|
->label('Assign')
|
||||||
|
->icon('heroicon-o-user-plus')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
|
->fillForm(fn (Finding $record): array => [
|
||||||
|
'assignee_user_id' => $record->assignee_user_id,
|
||||||
|
'owner_user_id' => $record->owner_user_id,
|
||||||
|
])
|
||||||
|
->form([
|
||||||
|
Select::make('assignee_user_id')
|
||||||
|
->label('Assignee')
|
||||||
|
->placeholder('Unassigned')
|
||||||
|
->options(fn (): array => static::tenantMemberOptions())
|
||||||
|
->searchable(),
|
||||||
|
Select::make('owner_user_id')
|
||||||
|
->label('Owner')
|
||||||
|
->placeholder('Unassigned')
|
||||||
|
->options(fn (): array => static::tenantMemberOptions())
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding assignment updated',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null,
|
||||||
|
is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('resolve')
|
||||||
|
->label('Resolve')
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('resolved_reason')
|
||||||
|
->label('Resolution reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding resolved',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
(string) ($data['resolved_reason'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function closeAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('close')
|
||||||
|
->label('Close')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('closed_reason')
|
||||||
|
->label('Close reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding closed',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
(string) ($data['closed_reason'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function riskAcceptAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('risk_accept')
|
||||||
|
->label('Risk accept')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('closed_reason')
|
||||||
|
->label('Risk acceptance reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding marked as risk accepted',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
(string) ($data['closed_reason'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reopenAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('reopen')
|
||||||
|
->label('Reopen')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||||
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding reopened',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(Finding, Tenant, User): Finding $callback
|
||||||
|
*/
|
||||||
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callback($record, $tenant, $user);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Workflow action failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($successTitle)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function tenantMemberOptions(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||||
|
->orderBy('users.name')
|
||||||
|
->pluck('users.name', 'users.id')
|
||||||
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,16 @@
|
|||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
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 App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -13,6 +21,7 @@
|
|||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
class ListFindings extends ListRecords
|
||||||
{
|
{
|
||||||
@ -20,10 +29,89 @@ class ListFindings extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('acknowledge_all_matching')
|
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
|
||||||
->label('Acknowledge all matching')
|
$actions[] = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('backfill_lifecycle')
|
||||||
|
->label('Backfill findings lifecycle')
|
||||||
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Backfill findings lifecycle')
|
||||||
|
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||||
|
->action(function (OperationRunService $operationRuns): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = \Filament\Facades\Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$opRun = $operationRuns->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'findings.lifecycle.backfill',
|
||||||
|
identityInputs: [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'trigger' => 'backfill',
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiator_user_id' => (int) $user->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||||
|
|
||||||
|
if ($opRun->wasRecentlyCreated === false) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
||||||
|
BackfillFindingLifecycleJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('triage_all_matching')
|
||||||
|
->label('Triage all matching')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -31,7 +119,7 @@ protected function getHeaderActions(): array
|
|||||||
->modalDescription(function (): string {
|
->modalDescription(function (): string {
|
||||||
$count = $this->getAllMatchingCount();
|
$count = $this->getAllMatchingCount();
|
||||||
|
|
||||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||||
})
|
})
|
||||||
->form(function (): array {
|
->form(function (): array {
|
||||||
$count = $this->getAllMatchingCount();
|
$count = $this->getAllMatchingCount();
|
||||||
@ -42,49 +130,94 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
TextInput::make('confirmation')
|
TextInput::make('confirmation')
|
||||||
->label('Type ACKNOWLEDGE to confirm')
|
->label('Type TRIAGE to confirm')
|
||||||
->required()
|
->required()
|
||||||
->in(['ACKNOWLEDGE'])
|
->in(['TRIAGE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
'in' => 'Please type TRIAGE to confirm.',
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->action(function (array $data): void {
|
->action(function (FindingWorkflowService $workflow): void {
|
||||||
$query = $this->buildAllMatchingQuery();
|
$query = $this->buildAllMatchingQuery();
|
||||||
$count = (clone $query)->count();
|
$count = (clone $query)->count();
|
||||||
|
|
||||||
if ($count === 0) {
|
if ($count === 0) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No matching findings')
|
->title('No matching findings')
|
||||||
->body('There are no new findings matching the current filters.')
|
->body('There are no new findings matching the current filters to triage.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = $query->update([
|
$user = auth()->user();
|
||||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
$tenant = \Filament\Facades\Filament::getTenant();
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => auth()->id(),
|
if (! $user instanceof User) {
|
||||||
]);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$triagedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
$query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void {
|
||||||
|
foreach ($findings as $finding) {
|
||||||
|
if (! $finding instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array((string) $finding->status, [
|
||||||
|
Finding::STATUS_NEW,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
], true)) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workflow->triage($finding, $tenant, $user);
|
||||||
|
$triagedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$this->deselectAllTableRecords();
|
$this->deselectAllTableRecords();
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
|
|
||||||
|
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk acknowledge completed')
|
->title('Bulk triage completed')
|
||||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
->body($body)
|
||||||
->success()
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->apply();
|
||||||
];
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
protected function buildAllMatchingQuery(): Builder
|
||||||
@ -106,6 +239,27 @@ protected function buildAllMatchingQuery(): Builder
|
|||||||
$query->where('finding_type', $findingType);
|
$query->where('finding_type', $findingType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive('overdue')) {
|
||||||
|
$query->whereNotNull('due_at')->where('due_at', '<', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive('high_severity')) {
|
||||||
|
$query->whereIn('severity', [
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive('my_assigned')) {
|
||||||
|
$userId = auth()->id();
|
||||||
|
|
||||||
|
if (is_numeric($userId)) {
|
||||||
|
$query->where('assignee_user_id', (int) $userId);
|
||||||
|
} else {
|
||||||
|
$query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
||||||
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
||||||
if (is_string($scopeKey) && $scopeKey !== '') {
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
||||||
@ -113,19 +267,36 @@ protected function buildAllMatchingQuery(): Builder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
||||||
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
$baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
|
||||||
if (is_numeric($baselineRunId)) {
|
if (is_numeric($baselineRunId)) {
|
||||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
$currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
|
||||||
if (is_numeric($currentRunId)) {
|
if (is_numeric($currentRunId)) {
|
||||||
$query->where('current_run_id', (int) $currentRunId);
|
$query->where('current_operation_run_id', (int) $currentRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function filterIsActive(string $filterName): bool
|
||||||
|
{
|
||||||
|
$state = $this->getTableFilterState($filterName);
|
||||||
|
|
||||||
|
if ($state === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($state)) {
|
||||||
|
$isActive = Arr::get($state, 'isActive');
|
||||||
|
|
||||||
|
return $isActive === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getAllMatchingCount(): int
|
protected function getAllMatchingCount(): int
|
||||||
{
|
{
|
||||||
return (int) $this->buildAllMatchingQuery()->count();
|
return (int) $this->buildAllMatchingQuery()->count();
|
||||||
@ -141,13 +312,13 @@ protected function getStatusFilterValue(): string
|
|||||||
: Finding::STATUS_NEW;
|
: Finding::STATUS_NEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getFindingTypeFilterValue(): string
|
protected function getFindingTypeFilterValue(): ?string
|
||||||
{
|
{
|
||||||
$state = $this->getTableFilterState('finding_type') ?? [];
|
$state = $this->getTableFilterState('finding_type') ?? [];
|
||||||
$value = Arr::get($state, 'value');
|
$value = Arr::get($state, 'value');
|
||||||
|
|
||||||
return is_string($value) && $value !== ''
|
return is_string($value) && $value !== ''
|
||||||
? $value
|
? $value
|
||||||
: Finding::FINDING_TYPE_DRIFT;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,20 @@
|
|||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
class ViewFinding extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingResource::class;
|
protected static string $resource = FindingResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
|
->label('Actions')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,8 @@ class InventoryItemResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = InventoryItem::class;
|
protected static ?string $model = InventoryItem::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static ?string $cluster = InventoryCluster::class;
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 1;
|
protected static ?int $navigationSort = 1;
|
||||||
@ -125,14 +127,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
TextEntry::make('external_id')->label('External ID'),
|
TextEntry::make('external_id')->label('External ID'),
|
||||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
|
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
|
||||||
TextEntry::make('last_seen_run_id')
|
TextEntry::make('last_seen_operation_run_id')
|
||||||
->label('Last sync run')
|
->label('Last inventory sync')
|
||||||
|
->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id))
|
||||||
->url(function (InventoryItem $record): ?string {
|
->url(function (InventoryItem $record): ?string {
|
||||||
if (! $record->last_seen_run_id) {
|
if (! $record->last_seen_operation_run_id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current());
|
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||||
})
|
})
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
TextEntry::make('support_restore')
|
TextEntry::make('support_restore')
|
||||||
@ -233,7 +236,7 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('last_seen_at')
|
Tables\Columns\TextColumn::make('last_seen_at')
|
||||||
->label('Last seen')
|
->label('Last seen')
|
||||||
->since(),
|
->since(),
|
||||||
Tables\Columns\TextColumn::make('lastSeenRun.status')
|
Tables\Columns\TextColumn::make('lastSeenRun.outcome')
|
||||||
->label('Run')
|
->label('Run')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(function (?string $state): string {
|
->formatStateUsing(function (?string $state): string {
|
||||||
@ -241,28 +244,28 @@ public static function table(Table $table): Table
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label;
|
||||||
})
|
})
|
||||||
->color(function (?string $state): string {
|
->color(function (?string $state): string {
|
||||||
if (! filled($state)) {
|
if (! filled($state)) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color;
|
||||||
})
|
})
|
||||||
->icon(function (?string $state): ?string {
|
->icon(function (?string $state): ?string {
|
||||||
if (! filled($state)) {
|
if (! filled($state)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
|
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon;
|
||||||
})
|
})
|
||||||
->iconColor(function (?string $state): ?string {
|
->iconColor(function (?string $state): ?string {
|
||||||
if (! filled($state)) {
|
if (! filled($state)) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
|
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state);
|
||||||
|
|
||||||
return $spec->iconColor ?? $spec->color;
|
return $spec->iconColor ?? $spec->color;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Jobs\RunInventorySyncJob;
|
use App\Jobs\RunInventorySyncJob;
|
||||||
use App\Models\InventorySyncRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -45,7 +44,7 @@ protected function getHeaderActions(): array
|
|||||||
Action::make('run_inventory_sync')
|
Action::make('run_inventory_sync')
|
||||||
->label('Run Inventory Sync')
|
->label('Run Inventory Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('primary')
|
||||||
->form([
|
->form([
|
||||||
Select::make('policy_types')
|
Select::make('policy_types')
|
||||||
->label('Policy types')
|
->label('Policy types')
|
||||||
@ -152,18 +151,23 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory.sync',
|
type: 'inventory_sync',
|
||||||
inputs: $computed['selection'],
|
identityInputs: [
|
||||||
initiator: $user
|
'selection_hash' => $computed['selection_hash'],
|
||||||
|
],
|
||||||
|
context: array_merge($computed['selection'], [
|
||||||
|
'selection_hash' => $computed['selection_hash'],
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->title('Inventory sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View Run')
|
||||||
@ -176,57 +180,26 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
|
||||||
$existing = InventorySyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_hash', $computed['selection_hash'])
|
|
||||||
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
|
||||||
if ($existing instanceof InventorySyncRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync already active')
|
|
||||||
->body('A matching inventory sync run is already pending or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
|
||||||
|
|
||||||
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
|
||||||
if (! is_array($policyTypes)) {
|
|
||||||
$policyTypes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.dispatched',
|
action: 'inventory.sync.dispatched',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'operation_run_id' => (int) $opRun->getKey(),
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $computed['selection_hash'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorId: $user->id,
|
actorId: $user->id,
|
||||||
actorEmail: $user->email,
|
actorEmail: $user->email,
|
||||||
actorName: $user->name,
|
actorName: $user->name,
|
||||||
resourceType: 'inventory_sync_run',
|
resourceType: 'operation_run',
|
||||||
resourceId: (string) $run->id,
|
resourceId: (string) $opRun->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $opRun): void {
|
||||||
RunInventorySyncJob::dispatch(
|
RunInventorySyncJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
inventorySyncRunId: (int) $run->id,
|
|
||||||
operationRun: $opRun
|
operationRun: $opRun
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,228 +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 App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\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 actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
])
|
|
||||||
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->actions([])
|
|
||||||
->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;
|
|
||||||
}
|
|
||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\VerificationCheckAcknowledgement;
|
use App\Models\VerificationCheckAcknowledgement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -317,6 +318,14 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -330,19 +339,19 @@ public static function table(Table $table): Table
|
|||||||
->all();
|
->all();
|
||||||
})
|
})
|
||||||
->default(function (): ?string {
|
->default(function (): ?string {
|
||||||
$tenant = Filament::getTenant();
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $activeTenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (string) $tenant->getKey();
|
return (string) $activeTenant->getKey();
|
||||||
})
|
})
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('type')
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
@ -52,10 +53,28 @@ class PolicyResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = Policy::class;
|
protected static ?string $model = Policy::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
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->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
@ -452,10 +471,8 @@ public static function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Policy sync already active')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -584,7 +601,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
@ -624,19 +641,30 @@ public static function table(Table $table): Table
|
|||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||||
->title('Policy delete queued')
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body("Queued deletion for {$count} policies.")
|
->body("Queued deletion for {$count} policies.")
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->body("Queued deletion for {$count} policies.")
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
@ -711,18 +739,6 @@ public static function table(Table $table): Table
|
|||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk restore started')
|
|
||||||
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
@ -784,10 +800,8 @@ public static function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Policy sync already active')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -881,18 +895,6 @@ public static function table(Table $table): Table
|
|||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($count >= 20) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk export started')
|
|
||||||
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
class ListPolicies extends ListRecords
|
||||||
@ -37,6 +36,9 @@ private function makeSyncAction(): Actions\Action
|
|||||||
->label('Sync from Intune')
|
->label('Sync from Intune')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Sync policies from Intune')
|
||||||
|
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||||
->action(function (self $livewire): void {
|
->action(function (self $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -65,10 +67,8 @@ private function makeSyncAction(): Actions\Action
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Policy sync already active')
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -94,7 +94,6 @@ private function makeSyncAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->tooltip('You do not have permission to sync policies.')
|
->tooltip('You do not have permission to sync policies.')
|
||||||
->destructive()
|
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Jobs\CapturePolicySnapshotJob;
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -23,7 +26,12 @@ class ViewPolicy extends ViewRecord
|
|||||||
|
|
||||||
protected function getActions(): array
|
protected function getActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [$this->makeCaptureSnapshotAction()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeCaptureSnapshotAction(): Action
|
||||||
|
{
|
||||||
|
$action = UiEnforcement::forAction(
|
||||||
Action::make('capture_snapshot')
|
Action::make('capture_snapshot')
|
||||||
->label('Capture snapshot')
|
->label('Capture snapshot')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -39,7 +47,7 @@ protected function getActions(): array
|
|||||||
->default(true)
|
->default(true)
|
||||||
->helperText('Captures policy scope tag IDs.'),
|
->helperText('Captures policy scope tag IDs.'),
|
||||||
])
|
])
|
||||||
->action(function (array $data) {
|
->action(function (array $data, AuditLogger $auditLogger) {
|
||||||
$policy = $this->record;
|
$policy = $this->record;
|
||||||
|
|
||||||
$tenant = $policy->tenant;
|
$tenant = $policy->tenant;
|
||||||
@ -61,6 +69,9 @@ protected function getActions(): array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$includeAssignments = (bool) ($data['include_assignments'] ?? false);
|
||||||
|
$includeScopeTags = (bool) ($data['include_scope_tags'] ?? false);
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'policy.capture_snapshot',
|
type: 'policy.capture_snapshot',
|
||||||
@ -68,13 +79,13 @@ protected function getActions(): array
|
|||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void {
|
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $includeAssignments, $includeScopeTags): void {
|
||||||
CapturePolicySnapshotJob::dispatch(
|
CapturePolicySnapshotJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
policyId: (int) $policy->getKey(),
|
policyId: (int) $policy->getKey(),
|
||||||
includeAssignments: (bool) ($data['include_assignments'] ?? false),
|
includeAssignments: $includeAssignments,
|
||||||
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
|
includeScopeTags: $includeScopeTags,
|
||||||
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
|
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
|
||||||
operationRun: $operationRun,
|
operationRun: $operationRun,
|
||||||
context: [],
|
context: [],
|
||||||
@ -83,8 +94,8 @@ protected function getActions(): array
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'policy_id' => (int) $policy->getKey(),
|
'policy_id' => (int) $policy->getKey(),
|
||||||
'include_assignments' => (bool) ($data['include_assignments'] ?? false),
|
'include_assignments' => $includeAssignments,
|
||||||
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false),
|
'include_scope_tags' => $includeScopeTags,
|
||||||
],
|
],
|
||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
@ -105,6 +116,26 @@ protected function getActions(): array
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'policy.capture_snapshot_dispatched',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $opRun->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'operation_run_id' => (int) $opRun->getKey(),
|
||||||
|
'include_assignments' => $includeAssignments,
|
||||||
|
'include_scope_tags' => $includeScopeTags,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
@ -115,7 +146,16 @@ protected function getActions(): array
|
|||||||
|
|
||||||
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||||
})
|
})
|
||||||
->color('primary'),
|
->color('primary')
|
||||||
];
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to capture policy snapshots.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
if (! $action instanceof Action) {
|
||||||
|
throw new \RuntimeException('Capture snapshot action must resolve to a Filament action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,10 +51,28 @@ class PolicyVersionResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = PolicyVersion::class;
|
protected static ?string $model = PolicyVersion::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
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->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
@ -285,20 +303,6 @@ public static function table(Table $table): Table
|
|||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version prune queued')
|
|
||||||
->body("Queued prune for {$count} policy versions.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($initiator);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -458,20 +462,6 @@ public static function table(Table $table): Table
|
|||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version force delete queued')
|
|
||||||
->body("Queued force delete for {$count} policy versions.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($initiator);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -819,14 +809,17 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
})(),
|
})(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
$bulkPruneVersions,
|
$bulkPruneVersions,
|
||||||
$bulkRestoreVersions,
|
$bulkRestoreVersions,
|
||||||
$bulkForceDeleteVersions,
|
$bulkForceDeleteVersions,
|
||||||
]),
|
])->label('More'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,20 +18,32 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
@ -42,18 +54,50 @@ class ProviderConnectionResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $model = ProviderConnection::class;
|
protected static ?string $model = ProviderConnection::class;
|
||||||
|
|
||||||
protected static ?string $slug = 'tenants/{tenant}/provider-connections';
|
protected static ?string $slug = 'provider-connections';
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Connections';
|
protected static ?string $navigationLabel = 'Provider Connections';
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'display_name';
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
|
public static function getNavigationParentItem(): ?string
|
||||||
|
{
|
||||||
|
return 'Integrations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantForCreate();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
protected static function hasTenantCapability(string $capability): bool
|
protected static function hasTenantCapability(string $capability): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveScopedTenant();
|
||||||
@ -72,6 +116,12 @@ protected static function hasTenantCapability(string $capability): bool
|
|||||||
|
|
||||||
protected static function resolveScopedTenant(): ?Tenant
|
protected static function resolveScopedTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
|
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||||
|
|
||||||
|
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||||
|
return static::resolveTenantByExternalId($tenantExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
$routeTenant = request()->route('tenant');
|
$routeTenant = request()->route('tenant');
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
if ($routeTenant instanceof Tenant) {
|
||||||
@ -84,12 +134,277 @@ protected static function resolveScopedTenant(): ?Tenant
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
$recordTenant = static::resolveTenantFromRouteRecord();
|
||||||
|
|
||||||
|
if ($recordTenant instanceof Tenant) {
|
||||||
|
return $recordTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contextTenantExternalId = static::resolveContextTenantExternalId();
|
||||||
|
|
||||||
|
if (is_string($contextTenantExternalId) && $contextTenantExternalId !== '') {
|
||||||
|
return static::resolveTenantByExternalId($contextTenantExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
|
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
||||||
|
{
|
||||||
|
if ($record instanceof ProviderConnection) {
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant && is_numeric($record->tenant_id)) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $record->tenant_id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::resolveScopedTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveRequestedTenantExternalId(): ?string
|
||||||
|
{
|
||||||
|
$queryTenant = request()->query('tenant_id');
|
||||||
|
|
||||||
|
if (is_string($queryTenant) && $queryTenant !== '') {
|
||||||
|
return $queryTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::resolveTenantExternalIdFromLivewireRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveContextTenantExternalId(): ?string
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$contextTenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||||
|
|
||||||
|
if ($workspaceId !== null && $contextTenantId !== null) {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->whereKey($contextTenantId)
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return (string) $tenant->external_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($filamentTenant instanceof Tenant) {
|
||||||
|
return (string) $filamentTenant->external_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveTenantForCreate(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantExternalId = static::resolveRequestedTenantExternalId() ?? static::resolveContextTenantExternalId();
|
||||||
|
|
||||||
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = static::resolveTenantByExternalId($tenantExternalId);
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User || $workspaceId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
|
||||||
|
{
|
||||||
|
if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$url = \Livewire\Livewire::originalUrl();
|
||||||
|
|
||||||
|
if (is_string($url) && $url !== '') {
|
||||||
|
$externalId = static::extractTenantExternalIdFromUrl($url);
|
||||||
|
|
||||||
|
if (is_string($externalId) && $externalId !== '') {
|
||||||
|
return $externalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Ignore and fall back to referer.
|
||||||
|
}
|
||||||
|
|
||||||
|
$referer = request()->headers->get('referer');
|
||||||
|
|
||||||
|
if (! is_string($referer) || $referer === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::extractTenantExternalIdFromUrl($referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractTenantExternalIdFromUrl(string $url): ?string
|
||||||
|
{
|
||||||
|
$query = parse_url($url, PHP_URL_QUERY);
|
||||||
|
|
||||||
|
if (is_string($query) && $query !== '') {
|
||||||
|
parse_str($query, $queryParams);
|
||||||
|
|
||||||
|
$tenantExternalId = $queryParams['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||||
|
return $tenantExternalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = parse_url($url, PHP_URL_PATH);
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
$path = $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveTenantByExternalId(?string $externalId): ?Tenant
|
||||||
|
{
|
||||||
|
if (! is_string($externalId) || $externalId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('external_id', $externalId)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveTenantFromRouteRecord(): ?Tenant
|
||||||
|
{
|
||||||
|
$record = request()->route('record');
|
||||||
|
|
||||||
|
if ($record instanceof ProviderConnection) {
|
||||||
|
return static::resolveTenantForRecord($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($record)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerConnection = ProviderConnection::query()
|
||||||
|
->with('tenant')
|
||||||
|
->whereKey((int) $record)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $providerConnection instanceof ProviderConnection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::resolveTenantForRecord($providerConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyMembershipScope(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($filamentTenant instanceof Tenant) {
|
||||||
|
$workspaceId = (int) $filamentTenant->workspace_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_int($workspaceId) || ! $user instanceof User) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('provider_connections.workspace_id', $workspaceId)
|
||||||
|
->whereExists(function ($membershipScope) use ($user, $workspaceId): void {
|
||||||
|
$membershipScope
|
||||||
|
->selectRaw('1')
|
||||||
|
->from('tenants as scoped_tenants')
|
||||||
|
->join('tenant_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
|
||||||
|
$join->on('scoped_memberships.tenant_id', '=', 'scoped_tenants.id')
|
||||||
|
->where('scoped_memberships.user_id', '=', (int) $user->getKey());
|
||||||
|
})
|
||||||
|
->whereColumn('scoped_tenants.id', 'provider_connections.tenant_id')
|
||||||
|
->where('scoped_tenants.workspace_id', '=', $workspaceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! is_int($workspaceId) || ! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->select(['tenants.external_id', 'tenants.name', 'tenants.environment'])
|
||||||
|
->join('tenant_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
|
||||||
|
$join->on('filter_memberships.tenant_id', '=', 'tenants.id')
|
||||||
|
->where('filter_memberships.user_id', '=', (int) $user->getKey());
|
||||||
|
})
|
||||||
|
->where('tenants.workspace_id', $workspaceId)
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (Tenant $tenant): array {
|
||||||
|
$environment = strtoupper((string) ($tenant->environment ?? ''));
|
||||||
|
$label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name;
|
||||||
|
|
||||||
|
return [(string) $tenant->external_id => $label];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeErrorMessage(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value) || trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', strip_tags($value));
|
||||||
|
$normalized = is_string($normalized) ? trim($normalized) : '';
|
||||||
|
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::limit($normalized, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Connection')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('display_name')
|
TextInput::make('display_name')
|
||||||
->label('Display name')
|
->label('Display name')
|
||||||
@ -106,6 +421,11 @@ public static function form(Schema $schema): Schema
|
|||||||
->label('Default connection')
|
->label('Default connection')
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Status')
|
||||||
|
->schema([
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
->disabled()
|
->disabled()
|
||||||
@ -114,6 +434,9 @@ public static function form(Schema $schema): Schema
|
|||||||
->label('Health')
|
->label('Health')
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,19 +444,41 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$query->with('tenant');
|
||||||
$tenantId = static::resolveScopedTenant()?->getKey();
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||||
return $query->whereRaw('1 = 0');
|
|
||||||
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
||||||
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query
|
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($tenantExternalId): void {
|
||||||
->where('workspace_id', (int) $workspaceId)
|
$tenantQuery->where('external_id', $tenantExternalId);
|
||||||
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
});
|
||||||
})
|
})
|
||||||
->defaultSort('display_name')
|
->defaultSort('display_name')
|
||||||
|
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->description(function (ProviderConnection $record): ?string {
|
||||||
|
$environment = $record->tenant?->environment;
|
||||||
|
|
||||||
|
if (! is_string($environment) || trim($environment) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtoupper($environment);
|
||||||
|
})
|
||||||
|
->url(function (ProviderConnection $record): ?string {
|
||||||
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||||
|
}),
|
||||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
||||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
||||||
@ -153,8 +498,44 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||||
|
->label('Last error reason')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('last_error_message')
|
||||||
|
->label('Last error message')
|
||||||
|
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
||||||
|
->toggleable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
SelectFilter::make('tenant')
|
||||||
|
->label('Tenant')
|
||||||
|
->default(static::resolveScopedTenant()?->external_id)
|
||||||
|
->options(static::tenantFilterOptions())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($value): void {
|
||||||
|
$tenantQuery->where('external_id', $value);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
SelectFilter::make('provider')
|
||||||
|
->label('Provider')
|
||||||
|
->options([
|
||||||
|
'microsoft' => 'Microsoft',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('provider_connections.provider', $value);
|
||||||
|
}),
|
||||||
SelectFilter::make('status')
|
SelectFilter::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
->options([
|
->options([
|
||||||
@ -170,7 +551,7 @@ public static function table(Table $table): Table
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->where('status', $value);
|
return $query->where('provider_connections.status', $value);
|
||||||
}),
|
}),
|
||||||
SelectFilter::make('health_status')
|
SelectFilter::make('health_status')
|
||||||
->label('Health')
|
->label('Health')
|
||||||
@ -187,8 +568,11 @@ public static function table(Table $table): Table
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->where('health_status', $value);
|
return $query->where('provider_connections.health_status', $value);
|
||||||
}),
|
}),
|
||||||
|
Filter::make('default_only')
|
||||||
|
->label('Default only')
|
||||||
|
->query(fn (Builder $query): Builder => $query->where('provider_connections.is_default', true)),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -204,8 +588,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -241,10 +625,9 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Run already queued')
|
|
||||||
->body('A connection check is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -280,10 +663,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Connection check queued')
|
|
||||||
->body('Health check was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -302,8 +684,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -315,7 +697,7 @@ public static function table(Table $table): Table
|
|||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'inventory.sync',
|
operationType: 'inventory_sync',
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||||
ProviderInventorySyncJob::dispatch(
|
ProviderInventorySyncJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
@ -343,10 +725,9 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Run already queued')
|
|
||||||
->body('An inventory sync is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -376,10 +757,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Inventory sync queued')
|
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -398,8 +778,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -439,10 +819,9 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Run already queued')
|
|
||||||
->body('A compliance snapshot is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -472,10 +851,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
->title('Compliance snapshot queued')
|
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -493,9 +871,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -553,8 +932,8 @@ public static function table(Table $table): Table
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -566,6 +945,29 @@ public static function table(Table $table): Table
|
|||||||
clientSecret: (string) $data['client_secret'],
|
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,
|
||||||
|
'client_id' => (string) $data['client_id'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
@ -580,24 +982,28 @@ public static function table(Table $table): Table
|
|||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$status = $hadCredentials ? 'connected' : 'error';
|
||||||
|
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
|
||||||
|
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
|
||||||
|
|
||||||
$record->update([
|
$record->update([
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'health_status' => 'unknown',
|
'health_status' => 'unknown',
|
||||||
'last_health_check_at' => null,
|
'last_health_check_at' => null,
|
||||||
'last_error_reason_code' => null,
|
'last_error_reason_code' => $errorReasonCode,
|
||||||
'last_error_message' => null,
|
'last_error_message' => $errorMessage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -653,7 +1059,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -698,7 +1104,7 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('More')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
])
|
])
|
||||||
@ -707,19 +1113,11 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$query = parent::getEloquentQuery()
|
||||||
$tenantId = static::resolveScopedTenant()?->getKey();
|
->with('tenant');
|
||||||
|
|
||||||
$query = parent::getEloquentQuery();
|
return static::applyMembershipScope($query)
|
||||||
|
->latest('provider_connections.id');
|
||||||
if ($workspaceId === null) {
|
|
||||||
return $query->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->latest('id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -727,33 +1125,65 @@ public static function getPages(): array
|
|||||||
return [
|
return [
|
||||||
'index' => Pages\ListProviderConnections::route('/'),
|
'index' => Pages\ListProviderConnections::route('/'),
|
||||||
'create' => Pages\CreateProviderConnection::route('/create'),
|
'create' => Pages\CreateProviderConnection::route('/create'),
|
||||||
|
'view' => Pages\ViewProviderConnection::route('/{record}'),
|
||||||
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizeTenantExternalId(mixed $tenant): ?string
|
||||||
|
{
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return (string) $tenant->external_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($tenant) && $tenant !== '') {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($tenant)) {
|
||||||
|
$tenantModel = Tenant::query()->whereKey((int) $tenant)->first();
|
||||||
|
|
||||||
|
if ($tenantModel instanceof Tenant) {
|
||||||
|
return (string) $tenantModel->external_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed> $parameters
|
* @param array<mixed> $parameters
|
||||||
*/
|
*/
|
||||||
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||||
{
|
{
|
||||||
if (! array_key_exists('tenant', $parameters)) {
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$parameters['tenant'] = $tenant->external_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedTenant = static::resolveScopedTenant();
|
|
||||||
|
|
||||||
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
|
|
||||||
$parameters['tenant'] = $resolvedTenant->external_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$panel ??= 'admin';
|
$panel ??= 'admin';
|
||||||
|
$tenantExternalId = null;
|
||||||
|
|
||||||
if (array_key_exists('tenant', $parameters)) {
|
if (array_key_exists('tenant', $parameters)) {
|
||||||
$tenant = null;
|
$tenantExternalId = static::normalizeTenantExternalId($parameters['tenant']);
|
||||||
|
unset($parameters['tenant']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
|
if ($tenantExternalId === null && $tenant instanceof Tenant) {
|
||||||
|
$tenantExternalId = (string) $tenant->external_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantExternalId === null) {
|
||||||
|
$record = $parameters['record'] ?? null;
|
||||||
|
|
||||||
|
if ($record instanceof ProviderConnection) {
|
||||||
|
$tenantExternalId = static::resolveTenantForRecord($record)?->external_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantExternalId === null) {
|
||||||
|
$tenantExternalId = static::resolveScopedTenant()?->external_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists('tenant_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
|
||||||
|
$parameters['tenant_id'] = $tenantExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,18 +84,12 @@ protected function afterCreate(): void
|
|||||||
|
|
||||||
private function currentTenant(): ?Tenant
|
private function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
$tenant = request()->route('tenant');
|
$tenant = ProviderConnectionResource::resolveTenantForCreate();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($tenant) && $tenant !== '') {
|
return null;
|
||||||
return Tenant::query()
|
|
||||||
->where('external_id', $tenant)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Tenant::current();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -28,10 +30,47 @@ class EditProviderConnection extends EditRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
public ?string $scopedTenantExternalId = null;
|
||||||
|
|
||||||
protected bool $shouldMakeDefault = false;
|
protected bool $shouldMakeDefault = false;
|
||||||
|
|
||||||
protected bool $defaultWasChanged = false;
|
protected bool $defaultWasChanged = false;
|
||||||
|
|
||||||
|
public function mount($record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
|
||||||
|
$recordTenant = $this->record instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::resolveTenantForRecord($this->record)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($recordTenant instanceof Tenant) {
|
||||||
|
$this->scopedTenantExternalId = (string) $recordTenant->external_id;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdFromQuery = request()->query('tenant_id');
|
||||||
|
|
||||||
|
if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') {
|
||||||
|
$this->scopedTenantExternalId = $tenantIdFromQuery;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = request()->route('tenant');
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$this->scopedTenantExternalId = (string) $tenant->external_id;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($tenant) && $tenant !== '') {
|
||||||
|
$this->scopedTenantExternalId = $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
{
|
{
|
||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
@ -42,9 +81,16 @@ protected function mutateFormDataBeforeSave(array $data): array
|
|||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->currentTenant();
|
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
$tenant = $record instanceof ProviderConnection
|
||||||
|
? ($record->tenant ?? $this->currentTenant())
|
||||||
|
: $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
||||||
|
|
||||||
if ($this->shouldMakeDefault && ! $record->is_default) {
|
if ($this->shouldMakeDefault && ! $record->is_default) {
|
||||||
@ -210,10 +256,9 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Run already queued')
|
|
||||||
->body('A connection check is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -249,10 +294,9 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Connection check queued')
|
|
||||||
->body('Health check was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -284,7 +328,7 @@ protected function getHeaderActions(): array
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = $this->currentTenant();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -297,6 +341,29 @@ protected function getHeaderActions(): array
|
|||||||
clientSecret: (string) $data['client_secret'],
|
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,
|
||||||
|
'client_id' => (string) $data['client_id'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
@ -313,6 +380,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& $record->status !== 'disabled'
|
&& $record->status !== 'disabled'
|
||||||
&& ! $record->is_default
|
&& ! $record->is_default
|
||||||
@ -397,7 +465,7 @@ protected function getHeaderActions(): array
|
|||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'inventory.sync',
|
operationType: 'inventory_sync',
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||||
ProviderInventorySyncJob::dispatch(
|
ProviderInventorySyncJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
@ -425,10 +493,9 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Run already queued')
|
|
||||||
->body('An inventory sync is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -458,10 +525,9 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Inventory sync queued')
|
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -538,10 +604,9 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Run already queued')
|
|
||||||
->body('A compliance snapshot is already queued or running.')
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -571,10 +636,9 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->title('Compliance snapshot queued')
|
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
@ -593,6 +657,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = $this->currentTenant();
|
$tenant = $this->currentTenant();
|
||||||
@ -602,15 +667,18 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
|
$errorReasonCode = null;
|
||||||
|
$errorMessage = null;
|
||||||
|
|
||||||
$record->update([
|
$record->update([
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'health_status' => 'unknown',
|
'health_status' => 'unknown',
|
||||||
'last_health_check_at' => null,
|
'last_health_check_at' => null,
|
||||||
'last_error_reason_code' => null,
|
'last_error_reason_code' => $errorReasonCode,
|
||||||
'last_error_message' => null,
|
'last_error_message' => $errorMessage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -640,8 +708,8 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
if (! $hadCredentials) {
|
if (! $hadCredentials) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection enabled (credentials missing)')
|
->title('Connection enabled (needs consent)')
|
||||||
->body('Add credentials before running checks or operations.')
|
->body('Grant admin consent before running checks or operations.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -744,7 +812,9 @@ protected function getFormActions(): array
|
|||||||
|
|
||||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||||
{
|
{
|
||||||
$tenant = $this->currentTenant();
|
$tenant = $record instanceof ProviderConnection
|
||||||
|
? ($record->tenant ?? $this->currentTenant())
|
||||||
|
: $this->currentTenant();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -767,6 +837,20 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
|||||||
|
|
||||||
private function currentTenant(): ?Tenant
|
private function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
|
if (isset($this->record) && $this->record instanceof ProviderConnection) {
|
||||||
|
$recordTenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
if ($recordTenant instanceof Tenant) {
|
||||||
|
return $recordTenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
|
||||||
|
return Tenant::query()
|
||||||
|
->where('external_id', $this->scopedTenantExternalId)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = request()->route('tenant');
|
$tenant = request()->route('tenant');
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
@ -779,6 +863,12 @@ private function currentTenant(): ?Tenant
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantFromCreateResolution = ProviderConnectionResource::resolveTenantForCreate();
|
||||||
|
|
||||||
|
if ($tenantFromCreateResolution instanceof Tenant) {
|
||||||
|
return $tenantFromCreateResolution;
|
||||||
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return Tenant::current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,25 +3,240 @@
|
|||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListProviderConnections extends ListRecords
|
class ListProviderConnections extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\CreateAction::make()
|
Actions\CreateAction::make()
|
||||||
->authorize(fn (): bool => true)
|
->label('New connection')
|
||||||
)
|
->url(function (): string {
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
|
||||||
->tooltip('You do not have permission to create provider connections.')
|
|
||||||
->apply(),
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
||||||
|
return ProviderConnectionResource::getUrl('create');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnectionResource::getUrl('create', [
|
||||||
|
'tenant_id' => $tenantExternalId,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->visible(function () use ($resolver): bool {
|
||||||
|
if (! $this->tableHasRecords()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant);
|
||||||
|
})
|
||||||
|
->disabled(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function () use ($resolver): ?string {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return 'Select a tenant to create provider connections.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||||
|
return 'You do not have permission to create provider connections.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
->authorize(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $resolver->isMember($user, $tenant);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function makeEmptyStateCreateAction(): Actions\CreateAction
|
||||||
|
{
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return Actions\CreateAction::make()
|
||||||
|
->label('New connection')
|
||||||
|
->url(function (): string {
|
||||||
|
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
|
||||||
|
|
||||||
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
||||||
|
return ProviderConnectionResource::getUrl('create');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnectionResource::getUrl('create', [
|
||||||
|
'tenant_id' => $tenantExternalId,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->visible(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant);
|
||||||
|
})
|
||||||
|
->disabled(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function () use ($resolver): ?string {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return 'Select a tenant to create provider connections.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||||
|
return 'You do not have permission to create provider connections.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
->authorize(function () use ($resolver): bool {
|
||||||
|
$tenant = $this->resolveTenantForCreateAction();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $resolver->isMember($user, $tenant);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantExternalIdForCreateAction(): ?string
|
||||||
|
{
|
||||||
|
$filterValue = data_get($this->getTableFilterState('tenant'), 'value');
|
||||||
|
|
||||||
|
if (is_string($filterValue) && $filterValue !== '') {
|
||||||
|
return $filterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requested = ProviderConnectionResource::resolveRequestedTenantExternalId()
|
||||||
|
?? ProviderConnectionResource::resolveContextTenantExternalId();
|
||||||
|
|
||||||
|
if (is_string($requested) && $requested !== '') {
|
||||||
|
return $requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
|
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantForCreateAction(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
|
||||||
|
|
||||||
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('external_id', $tenantExternalId)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTableEmptyStateHeading(): ?string
|
||||||
|
{
|
||||||
|
return 'No provider connections found';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTableEmptyStateDescription(): ?string
|
||||||
|
{
|
||||||
|
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [$this->makeEmptyStateCreateAction()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewProviderConnection extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('edit')
|
||||||
|
->label('Edit')
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Contracts\Hardening\WriteGateInterface;
|
||||||
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||||
@ -36,6 +38,7 @@
|
|||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -50,6 +53,7 @@
|
|||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -60,6 +64,17 @@ class RestoreRunResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = RestoreRun::class;
|
protected static ?string $model = RestoreRun::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
@ -759,191 +774,7 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
static::rerunActionWithGate(),
|
||||||
Actions\Action::make('rerun')
|
|
||||||
->label('Rerun')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(function (RestoreRun $record): bool {
|
|
||||||
$backupSet = $record->backupSet;
|
|
||||||
|
|
||||||
return ! $record->trashed()
|
|
||||||
&& $record->isDeletable()
|
|
||||||
&& $backupSet !== null
|
|
||||||
&& ! $backupSet->trashed();
|
|
||||||
})
|
|
||||||
->action(function (
|
|
||||||
RestoreRun $record,
|
|
||||||
RestoreService $restoreService,
|
|
||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
|
||||||
HasTable $livewire
|
|
||||||
) {
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
$backupSet = $record->backupSet;
|
|
||||||
|
|
||||||
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run cannot be rerun')
|
|
||||||
->body('Restore run or backup set is archived or unavailable.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! (bool) $record->is_dry_run) {
|
|
||||||
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
|
|
||||||
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
|
|
||||||
$actorEmail = auth()->user()?->email;
|
|
||||||
$actorName = auth()->user()?->name;
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
||||||
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
|
||||||
|
|
||||||
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
|
|
||||||
$metadata = [
|
|
||||||
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
|
||||||
'environment' => app()->environment('production') ? 'prod' : 'test',
|
|
||||||
'highlander_label' => $highlanderLabel,
|
|
||||||
'confirmed_at' => now()->toIso8601String(),
|
|
||||||
'confirmed_by' => $actorEmail,
|
|
||||||
'confirmed_by_name' => $actorName,
|
|
||||||
'rerun_of_restore_run_id' => $record->id,
|
|
||||||
];
|
|
||||||
|
|
||||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
|
||||||
selectedItemIds: $selectedItemIds,
|
|
||||||
groupMapping: $groupMapping,
|
|
||||||
);
|
|
||||||
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore already queued')
|
|
||||||
->body('Reusing the active restore run.')
|
|
||||||
->info()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$newRun = RestoreRun::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'backup_set_id' => $backupSet->id,
|
|
||||||
'requested_by' => $actorEmail,
|
|
||||||
'is_dry_run' => false,
|
|
||||||
'status' => RestoreRunStatus::Queued->value,
|
|
||||||
'idempotency_key' => $idempotencyKey,
|
|
||||||
'requested_items' => $selectedItemIds,
|
|
||||||
'preview' => $preview,
|
|
||||||
'metadata' => $metadata,
|
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
|
||||||
]);
|
|
||||||
} catch (QueryException $exception) {
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore already queued')
|
|
||||||
->body('Reusing the active restore run.')
|
|
||||||
->info()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'restore.queued',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'restore_run_id' => $newRun->id,
|
|
||||||
'backup_set_id' => $backupSet->id,
|
|
||||||
'rerun_of_restore_run_id' => $record->id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'restore_run',
|
|
||||||
resourceId: (string) $newRun->id,
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'restore_run.rerun',
|
|
||||||
resourceType: 'restore_run',
|
|
||||||
resourceId: (string) $newRun->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'original_restore_run_id' => $record->id,
|
|
||||||
'backup_set_id' => $backupSet->id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
);
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$newRun = $restoreService->execute(
|
|
||||||
tenant: $tenant,
|
|
||||||
backupSet: $backupSet,
|
|
||||||
selectedItemIds: $record->requested_items ?? null,
|
|
||||||
dryRun: (bool) $record->is_dry_run,
|
|
||||||
actorEmail: auth()->user()?->email,
|
|
||||||
actorName: auth()->user()?->name,
|
|
||||||
groupMapping: $record->group_mapping ?? []
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run failed to start')
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'restore_run.rerun',
|
|
||||||
resourceType: 'restore_run',
|
|
||||||
resourceId: (string) $newRun->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'original_restore_run_id' => $record->id,
|
|
||||||
'backup_set_id' => $backupSet->id,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
fn () => Tenant::current(),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
@ -1520,6 +1351,37 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
|
||||||
|
} catch (ProviderAccessHardeningRequired $e) {
|
||||||
|
app(\App\Services\Intune\AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'intune_rbac.write_blocked',
|
||||||
|
status: 'blocked',
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_type' => 'restore.execute',
|
||||||
|
'reason_code' => $e->reasonCode,
|
||||||
|
'backup_set_id' => $data['backup_set_id'] ?? null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Write operation blocked')
|
||||||
|
->body($e->reasonMessage)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'backup_set_id' => $e->reasonMessage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var BackupSet $backupSet */
|
/** @var BackupSet $backupSet */
|
||||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||||
|
|
||||||
@ -1673,11 +1535,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||||
->title('Restore already queued')
|
$existingOpRun = $existingOpRunId > 0
|
||||||
->body('Reusing the active restore run.')
|
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||||
->info()
|
: null;
|
||||||
->send();
|
|
||||||
|
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||||
|
->body('Reusing the active restore run.');
|
||||||
|
|
||||||
|
if ($existingOpRun) {
|
||||||
|
$toast->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
@ -1699,11 +1573,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||||
->title('Restore already queued')
|
$existingOpRun = $existingOpRunId > 0
|
||||||
->body('Reusing the active restore run.')
|
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||||
->info()
|
: null;
|
||||||
->send();
|
|
||||||
|
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||||
|
->body('Reusing the active restore run.');
|
||||||
|
|
||||||
|
if ($existingOpRun) {
|
||||||
|
$toast->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
@ -1727,7 +1613,35 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
);
|
);
|
||||||
|
|
||||||
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
$initiator = auth()->user();
|
||||||
|
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
||||||
|
|
||||||
|
$opRun = $runs->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'restore.execute',
|
||||||
|
inputs: [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||||
|
],
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
||||||
|
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
return $restoreRun->refresh();
|
return $restoreRun->refresh();
|
||||||
}
|
}
|
||||||
@ -1911,4 +1825,343 @@ private static function normalizeGroupMapping(mixed $mapping): array
|
|||||||
|
|
||||||
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
|
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the rerun table action with UiEnforcement + write gate disabled state.
|
||||||
|
*
|
||||||
|
* UiEnforcement::apply() overrides ->disabled() and ->tooltip(), so the gate
|
||||||
|
* check must compose on top of the enforcement action AFTER apply(). This method
|
||||||
|
* extracts the rerun action into its own builder to keep the table definition clean.
|
||||||
|
*/
|
||||||
|
private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||||
|
{
|
||||||
|
/** @var Actions\Action $action */
|
||||||
|
$action = UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('rerun')
|
||||||
|
->label('Rerun')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(function (RestoreRun $record): bool {
|
||||||
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
|
return ! $record->trashed()
|
||||||
|
&& $record->isDeletable()
|
||||||
|
&& $backupSet !== null
|
||||||
|
&& ! $backupSet->trashed();
|
||||||
|
})
|
||||||
|
->action(function (
|
||||||
|
RestoreRun $record,
|
||||||
|
RestoreService $restoreService,
|
||||||
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
|
HasTable $livewire
|
||||||
|
) {
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
|
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run cannot be rerun')
|
||||||
|
->body('Restore run or backup set is archived or unavailable.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
|
||||||
|
} catch (ProviderAccessHardeningRequired $e) {
|
||||||
|
app(\App\Services\Intune\AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'intune_rbac.write_blocked',
|
||||||
|
status: 'blocked',
|
||||||
|
actorEmail: auth()->user()?->email,
|
||||||
|
actorName: auth()->user()?->name,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_type' => 'restore.rerun',
|
||||||
|
'reason_code' => $e->reasonCode,
|
||||||
|
'backup_set_id' => $backupSet?->getKey(),
|
||||||
|
'original_restore_run_id' => $record->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Write operation blocked')
|
||||||
|
->body($e->reasonMessage)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $record->is_dry_run) {
|
||||||
|
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
|
||||||
|
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
|
||||||
|
$actorEmail = auth()->user()?->email;
|
||||||
|
$actorName = auth()->user()?->name;
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
|
||||||
|
|
||||||
|
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
|
$metadata = [
|
||||||
|
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
|
||||||
|
'environment' => app()->environment('production') ? 'prod' : 'test',
|
||||||
|
'highlander_label' => $highlanderLabel,
|
||||||
|
'confirmed_at' => now()->toIso8601String(),
|
||||||
|
'confirmed_by' => $actorEmail,
|
||||||
|
'confirmed_by_name' => $actorName,
|
||||||
|
'rerun_of_restore_run_id' => $record->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||||
|
$existingOpRun = $existingOpRunId > 0
|
||||||
|
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||||
|
->body('Reusing the active restore run.');
|
||||||
|
|
||||||
|
if ($existingOpRun) {
|
||||||
|
$toast->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'requested_by' => $actorEmail,
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'status' => RestoreRunStatus::Queued->value,
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
'requested_items' => $selectedItemIds,
|
||||||
|
'preview' => $preview,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
|
]);
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||||
|
$existingOpRun = $existingOpRunId > 0
|
||||||
|
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||||
|
->body('Reusing the active restore run.');
|
||||||
|
|
||||||
|
if ($existingOpRun) {
|
||||||
|
$toast->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.queued',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $newRun->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'rerun_of_restore_run_id' => $record->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $newRun->id,
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
$initiator = auth()->user();
|
||||||
|
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
||||||
|
|
||||||
|
$opRun = $runs->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'restore.execute',
|
||||||
|
inputs: [
|
||||||
|
'restore_run_id' => (int) $newRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
||||||
|
],
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
||||||
|
$newRun->update(['operation_run_id' => $opRun->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore_run.rerun',
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $newRun->id,
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'original_restore_run_id' => $record->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
);
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newRun = $restoreService->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $record->requested_items ?? null,
|
||||||
|
dryRun: (bool) $record->is_dry_run,
|
||||||
|
actorEmail: auth()->user()?->email,
|
||||||
|
actorName: auth()->user()?->name,
|
||||||
|
groupMapping: $record->group_mapping ?? []
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run failed to start')
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore_run.rerun',
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $newRun->id,
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'original_restore_run_id' => $record->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
// Compose write gate disabled/tooltip on top of UiEnforcement's RBAC check.
|
||||||
|
// UiEnforcement::apply() sets its own ->disabled() / ->tooltip();
|
||||||
|
// we override here to merge both concerns.
|
||||||
|
$action->disabled(function (?Model $record = null): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check RBAC capability first (mirrors UiEnforcement logic)
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check write gate
|
||||||
|
return app(WriteGateInterface::class)->wouldBlock($tenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
$action->tooltip(function (?Model $record = null): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return 'Tenant unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check RBAC capability first
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check write gate
|
||||||
|
try {
|
||||||
|
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
|
||||||
|
} catch (ProviderAccessHardeningRequired $e) {
|
||||||
|
return $e->reasonMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -12,10 +12,32 @@ class ListRestoreRuns extends ListRecords
|
|||||||
{
|
{
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
$create->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
$create = Actions\CreateAction::make();
|
||||||
|
UiEnforcement::forAction($create)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return [
|
||||||
|
$create,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
352
app/Filament/Resources/ReviewPackResource.php
Normal file
352
app/Filament/Resources/ReviewPackResource.php
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ReviewPackResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = ReviewPack::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Review Packs';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 50;
|
||||||
|
|
||||||
|
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::REVIEW_PACK_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::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof ReviewPack) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Status')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
|
||||||
|
TextEntry::make('tenant.name')->label('Tenant'),
|
||||||
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('file_size')
|
||||||
|
->label('File size')
|
||||||
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||||
|
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Summary')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.data_freshness.permission_posture')
|
||||||
|
->label('Permission posture freshness')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('summary.data_freshness.entra_admin_roles')
|
||||||
|
->label('Entra admin roles freshness')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('summary.data_freshness.findings')
|
||||||
|
->label('Findings freshness')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('summary.data_freshness.hardening')
|
||||||
|
->label('Hardening freshness')
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Options')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('options.include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||||
|
TextEntry::make('options.include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Metadata')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||||
|
TextEntry::make('operationRun.id')
|
||||||
|
->label('Operation run')
|
||||||
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||||
|
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||||
|
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||||
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('generated_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('file_size')
|
||||||
|
->label('Size')
|
||||||
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('Created')
|
||||||
|
->since()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(collect(ReviewPackStatus::cases())
|
||||||
|
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
|
||||||
|
->all()),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('download')
|
||||||
|
->label('Download')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||||
|
->url(function (ReviewPack $record): string {
|
||||||
|
return app(ReviewPackService::class)->generateDownloadUrl($record);
|
||||||
|
})
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('expire')
|
||||||
|
->label('Expire')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
|
||||||
|
->action(function (ReviewPack $record): void {
|
||||||
|
if ($record->file_path && $record->file_disk) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Review pack expired')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No review packs yet')
|
||||||
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||||
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||||
|
->emptyStateActions([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('generate_first')
|
||||||
|
->label('Generate first pack')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
static::executeGeneration($data);
|
||||||
|
})
|
||||||
|
->form([
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListReviewPacks::route('/'),
|
||||||
|
'view' => Pages\ViewReviewPack::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function executeGeneration(array $data): void
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(ReviewPackService::class);
|
||||||
|
|
||||||
|
if ($service->checkActiveRun($tenant)) {
|
||||||
|
Notification::make()->warning()->title('A review pack is already being generated.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'include_pii' => (bool) ($data['include_pii'] ?? true),
|
||||||
|
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||||
|
];
|
||||||
|
|
||||||
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
|
|
||||||
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Review pack already available')
|
||||||
|
->body('A matching review pack is already ready. No new run was started.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_pack')
|
||||||
|
->label('View pack')
|
||||||
|
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
|
||||||
|
class ListReviewPacks extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ReviewPackResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('generate_pack')
|
||||||
|
->label('Generate Pack')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
ReviewPackResource::executeGeneration($data);
|
||||||
|
})
|
||||||
|
->form([
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
|
||||||
|
class ViewReviewPack extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ReviewPackResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('download')
|
||||||
|
->label('Download')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||||
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('regenerate')
|
||||||
|
->label('Regenerate')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
$options = array_merge($record->options ?? [], [
|
||||||
|
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||||
|
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReviewPackResource::executeGeneration($options);
|
||||||
|
})
|
||||||
|
->form(function (): array {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
$currentOptions = $record->options ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantResource::class;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
|
||||||
|
|
||||||
if ($workspaceId !== null) {
|
|
||||||
$data['workspace_id'] = $workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterCreate(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$this->record->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,9 +13,21 @@ class ListTenants extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
Actions\Action::make('add_tenant')
|
||||||
->disabled(fn (): bool => ! TenantResource::canCreate())
|
->label('Add tenant')
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
->icon('heroicon-m-plus')
|
||||||
|
->url(route('admin.onboarding'))
|
||||||
|
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('add_tenant')
|
||||||
|
->label('Add tenant')
|
||||||
|
->icon('heroicon-m-plus')
|
||||||
|
->url(route('admin.onboarding')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,16 +4,21 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||||
|
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RbacHealthService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use App\Services\Providers\ProviderConnectionResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -23,11 +28,18 @@ class ViewTenant extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
public function getHeaderWidgetsColumns(): int|array
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantArchivedBanner::class,
|
TenantArchivedBanner::class,
|
||||||
RecentOperationsSummary::class,
|
RecentOperationsSummary::class,
|
||||||
|
TenantVerificationReport::class,
|
||||||
|
AdminRolesSummaryWidget::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +51,7 @@ protected function getHeaderActions(): array
|
|||||||
Actions\Action::make('provider_connections')
|
Actions\Action::make('provider_connections')
|
||||||
->label('Provider connections')
|
->label('Provider connections')
|
||||||
->icon('heroicon-o-link')
|
->icon('heroicon-o-link')
|
||||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin'))
|
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
->apply(),
|
->apply(),
|
||||||
@ -63,60 +75,195 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label('Verify configuration')
|
->label('Verify configuration')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
StartVerification $verification,
|
||||||
TenantPermissionService $permissionService,
|
): void {
|
||||||
RbacHealthService $rbacHealthService,
|
$user = auth()->user();
|
||||||
AuditLogger $auditLogger,
|
|
||||||
ProviderConnectionResolver $connectionResolver,
|
|
||||||
ProviderNextStepsRegistry $nextStepsRegistry,
|
|
||||||
) {
|
|
||||||
$resolution = $connectionResolver->resolveDefault($record, 'microsoft');
|
|
||||||
|
|
||||||
if (! $resolution->resolved) {
|
if (! $user instanceof User) {
|
||||||
$reasonCode = $resolution->effectiveReasonCode();
|
abort(403);
|
||||||
$nextSteps = $nextStepsRegistry->forReason($record, $reasonCode, $resolution->connection);
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
if (! $user->canAccessTenant($record)) {
|
||||||
->title('Verification blocked')
|
abort(404);
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
}
|
||||||
->warning();
|
|
||||||
|
$result = $verification->providerConnectionCheckForTenant(
|
||||||
|
tenant: $record,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'surface' => [
|
||||||
|
'kind' => 'tenant_view_header',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Another operation is already running')
|
||||||
|
->body('Please wait for the active run to finish.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
foreach ($nextSteps as $index => $step) {
|
||||||
if (! is_array($step)) {
|
if (! is_array($step)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? $step['label'] : null;
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
$url = is_string($step['url'] ?? null) ? $step['url'] : null;
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
if ($label === null || $url === null) {
|
if ($label === '' || $url === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->actions([
|
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||||
Actions\Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
->label($label)
|
||||||
->url($url),
|
->url($url);
|
||||||
]);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
Notification::make()
|
||||||
|
->title('Verification blocked')
|
||||||
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
TenantResource::rbacAction(),
|
TenantResource::rbacAction(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('refresh_rbac')
|
||||||
|
->label('Refresh RBAC status')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
|
->action(function (Tenant $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->ensureRun(
|
||||||
|
tenant: $record,
|
||||||
|
type: OperationRunType::RbacHealthCheck->value,
|
||||||
|
inputs: [
|
||||||
|
'tenant_id' => (int) $record->getKey(),
|
||||||
|
'surface' => 'tenant_view_header',
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||||
|
|
||||||
|
if ($opRun->wasRecentlyCreated === false) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshTenantRbacHealthJob::dispatch(
|
||||||
|
(int) $record->getKey(),
|
||||||
|
(int) $user->getKey(),
|
||||||
|
$opRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Deactivate')
|
->label('Deactivate')
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
@ -30,6 +31,20 @@ protected function afterCreate(): void
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $this->record,
|
||||||
|
action: 'workspace.created',
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $this->record->getKey(),
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'workspace_id' => (int) $this->record->getKey(),
|
||||||
|
'slug' => (string) $this->record->slug,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
|
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,30 @@
|
|||||||
namespace App\Filament\Resources\Workspaces\Pages;
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditWorkspace extends EditRecord
|
class EditWorkspace extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = WorkspaceResource::class;
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $this->record,
|
||||||
|
action: 'workspace.updated',
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $this->record->getKey(),
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'workspace_id' => (int) $this->record->getKey(),
|
||||||
|
'slug' => (string) $this->record->slug,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user