diff --git a/core/app/Filament/Admin/Resources/Edges/Pages/ListEdges.php b/core/app/Filament/Admin/Resources/Edges/Pages/ListEdges.php index 3736358..290bbea 100644 --- a/core/app/Filament/Admin/Resources/Edges/Pages/ListEdges.php +++ b/core/app/Filament/Admin/Resources/Edges/Pages/ListEdges.php @@ -3,7 +3,10 @@ namespace App\Filament\Admin\Resources\Edges\Pages; use App\Filament\Admin\Resources\Edges\EdgeResource; +use App\Http\Controllers\Admin\EdgeOperationsController; +use Filament\Actions\Action; use Filament\Actions\CreateAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListEdges extends ListRecords @@ -12,6 +15,19 @@ class ListEdges extends ListRecords protected function getHeaderActions(): array { - return [CreateAction::make()]; + return [ + Action::make('reconcileAllDomains') + ->label('Reconcile all domains') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->action(function (): void { + $response = app(EdgeOperationsController::class)->reconcile(request()); + $operation = $response->getData(true)['data']; + Notification::make()->info()->title('Global edge reconciliation queued') + ->body("Operation {$operation['operation_id']} will coalesce domain deployments in bounded chunks.")->send(); + }), + CreateAction::make(), + ]; } } diff --git a/core/app/Filament/Domain/Resources/Domains/DomainResource.php b/core/app/Filament/Domain/Resources/Domains/DomainResource.php index b398a47..1bbfe8c 100644 --- a/core/app/Filament/Domain/Resources/Domains/DomainResource.php +++ b/core/app/Filament/Domain/Resources/Domains/DomainResource.php @@ -35,7 +35,7 @@ class DomainResource extends Resource public static function getNavigationGroup(): string|\UnitEnum|null { - return Filament::getCurrentPanel()?->getId() === 'admin' ? 'Customers' : null; + return Filament::getCurrentPanel()?->getId() === 'admin' ? 'Customers' : 'Domains'; } public static function form(Schema $schema): Schema diff --git a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php index 1bf1bf9..e920044 100644 --- a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php +++ b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php @@ -7,7 +7,6 @@ use App\Filament\Domain\Resources\Domains\DomainResource; use App\Http\Controllers\CacheController; use App\Http\Controllers\DnsDeploymentController; -use App\Http\Controllers\ProxyController; use App\Jobs\EnsureManagedCertificates; use App\Jobs\ImportDnsZone; use App\Jobs\ReconcileDnsZone; @@ -67,14 +66,6 @@ protected function getHeaderActions(): array Notification::make()->info()->title('DNS reconciliation queued') ->body("Operation {$operation['id']} will preserve the previous valid zone until activation succeeds.")->send(); }), - Action::make('deployEdge')->label('Reconcile edge delivery')->icon('heroicon-o-cloud-arrow-up') - ->visible(fn (): bool => $this->record->dnsRecords()->where('mode', 'proxied')->exists()) - ->action(function (): void { - $response = app(ProxyController::class)->deploy(request(), $this->record); - $operation = $response->getData(true)['data']; - Notification::make()->info()->title('Edge reconciliation queued') - ->body("Operation {$operation['operation_id']} will deploy the latest desired revision.")->send(); - }), Action::make('tlsMode')->label('TLS mode')->icon('heroicon-o-lock-closed')->schema([ Select::make('mode')->options(['managed' => 'Managed', 'custom' => 'Custom', 'disabled' => 'Disabled'])->required(), ])->fillForm(fn (): array => ['mode' => $this->record->tls_mode]) @@ -422,7 +413,7 @@ static function () use ($zone): void { ->icon('heroicon-o-globe-alt') ->color('gray') ->button(), - ActionGroup::make($group(['deployEdge', 'proxyDefaults', 'rollbackProxy', 'moveEdgePool'])) + ActionGroup::make($group(['proxyDefaults', 'rollbackProxy', 'moveEdgePool'])) ->label('Delivery') ->icon('heroicon-o-cloud') ->button(), diff --git a/core/app/Providers/Filament/DomainPanelProvider.php b/core/app/Providers/Filament/DomainPanelProvider.php index f82064f..c98bec6 100644 --- a/core/app/Providers/Filament/DomainPanelProvider.php +++ b/core/app/Providers/Filament/DomainPanelProvider.php @@ -35,6 +35,7 @@ public function panel(Panel $panel): Panel ->readOnlyRelationManagersOnResourceViewPagesByDefault(false) ->colors(['primary' => Color::Blue]) ->sidebarCollapsibleOnDesktop() + ->navigationGroups(['Domains', 'Observe', 'Account']) ->discoverResources(in: app_path('Filament/Domain/Resources'), for: 'App\\Filament\\Domain\\Resources') ->discoverPages(in: app_path('Filament/Domain/Pages'), for: 'App\\Filament\\Domain\\Pages') ->pages([DomainDashboard::class, ApiTokens::class]) diff --git a/core/phpunit.xml b/core/phpunit.xml index 571c445..294bc4a 100644 --- a/core/phpunit.xml +++ b/core/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/core/resources/css/filament/shared/theme.css b/core/resources/css/filament/shared/theme.css index 5970589..14e2155 100644 --- a/core/resources/css/filament/shared/theme.css +++ b/core/resources/css/filament/shared/theme.css @@ -251,6 +251,137 @@ text-align: center; } +.cdn-empty-title { + color: rgb(51 65 85); + font-size: 0.875rem; + font-weight: 650; +} + +.dark .cdn-empty-title { + color: rgb(226 232 240); +} + +.cdn-empty-description { + margin-top: 0.2rem; + font-size: 0.8125rem; + line-height: 1.25rem; +} + +.cdn-empty-actions { + display: flex; + justify-content: center; + margin-top: 1rem; +} + +.cdn-list-row { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-radius: 0.65rem; + padding: 0.7rem 0.75rem; +} + +.cdn-list-row:nth-child(odd) { + background: rgb(248 250 252); +} + +.dark .cdn-list-row:nth-child(odd) { + background: rgb(30 41 59 / 0.55); +} + +.cdn-row-aside { + display: flex; + flex: none; + align-items: center; + gap: 0.5rem; +} + +.cdn-form-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + border-top: 1px solid rgb(226 232 240); + padding-top: 1.25rem; +} + +.dark .cdn-form-actions { + border-top-color: rgb(51 65 85); +} + +.cdn-inline-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cdn-field-label { + display: block; + margin-bottom: 0.4rem; + color: rgb(51 65 85); + font-size: 0.8125rem; + font-weight: 650; +} + +.dark .cdn-field-label { + color: rgb(226 232 240); +} + +.cdn-field-error { + margin-top: 0.35rem; + color: rgb(220 38 38); + font-size: 0.8125rem; +} + +.cdn-secret-value { + display: block; + overflow-wrap: anywhere; + border-radius: 0.65rem; + background: rgb(248 250 252); + padding: 0.85rem; + user-select: all; +} + +.cdn-table-wrap { + max-width: 100%; + overflow-x: auto; +} + +.cdn-data-table { + width: 100%; + border-collapse: collapse; + text-align: start; + font-size: 0.875rem; +} + +.cdn-data-table thead { + color: rgb(100 116 139); + font-size: 0.75rem; + text-transform: uppercase; +} + +.cdn-data-table th, +.cdn-data-table td { + padding: 0.65rem 0.75rem; +} + +.cdn-data-table tbody tr + tr { + border-top: 1px solid rgb(226 232 240); +} + +.dark .cdn-data-table thead { + color: rgb(148 163 184); +} + +.dark .cdn-data-table tbody tr + tr { + border-top-color: rgb(255 255 255 / 0.1); +} + +.dark .cdn-secret-value { + background: rgb(30 41 59); +} + .dark .cdn-empty-state { border-color: rgb(71 85 105); color: rgb(148 163 184); @@ -288,6 +419,11 @@ .cdn-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .cdn-inline-form { + flex-direction: row; + align-items: end; + } } @media (min-width: 64rem) { diff --git a/core/resources/views/components/ui/form-actions.blade.php b/core/resources/views/components/ui/form-actions.blade.php new file mode 100644 index 0000000..218ecc7 --- /dev/null +++ b/core/resources/views/components/ui/form-actions.blade.php @@ -0,0 +1,18 @@ +@props([ + 'submit' => 'save', + 'label' => 'Save changes', + 'loadingLabel' => 'Saving…', + 'color' => 'primary', +]) + +
class('cdn-form-actions') }}> + + {{ $label }} + {{ $loadingLabel }} + +
diff --git a/core/resources/views/filament/admin/pages/dashboard.blade.php b/core/resources/views/filament/admin/pages/dashboard.blade.php index e862cad..da98a75 100644 --- a/core/resources/views/filament/admin/pages/dashboard.blade.php +++ b/core/resources/views/filament/admin/pages/dashboard.blade.php @@ -2,11 +2,7 @@
@@ -19,9 +15,9 @@
{{ $lane['label'] }}
{{ $lane['key'] }} · {{ $lane['oldest'] }}
- + {{ $lane['depth'] === null ? 'Unavailable' : number_format($lane['depth']) }} - + @endforeach @@ -35,10 +31,10 @@
{{ str($entry->action)->replace(['.', '_'], ' ')->headline() }}
{{ $entry->actor?->email ?? 'System' }} · {{ $entry->created_at?->diffForHumans() }}
- #{{ $entry->id }} + #{{ $entry->id }} @empty -
No audit activity has been recorded yet.
+ @endforelse diff --git a/core/resources/views/filament/shared/pages/api-tokens.blade.php b/core/resources/views/filament/shared/pages/api-tokens.blade.php index 5183f2f..2249442 100644 --- a/core/resources/views/filament/shared/pages/api-tokens.blade.php +++ b/core/resources/views/filament/shared/pages/api-tokens.blade.php @@ -1,18 +1,24 @@ @if ($plainTextToken) - - {{ $plainTextToken }} + + {{ $plainTextToken }} @endif -
- - - - Create + +
+ + + + + @error('name') @enderror +
+ + Create token + Creating… +
- @error('name')

{{ $message }}

@enderror
@@ -28,10 +34,10 @@ Created {{ $token->created_at?->diffForHumans() }} - Revoke + Revoke @empty -

No API tokens.

+ @endforelse
diff --git a/core/tests/Feature/FilamentWorkflowTest.php b/core/tests/Feature/FilamentWorkflowTest.php index 0a27ced..cda7f18 100644 --- a/core/tests/Feature/FilamentWorkflowTest.php +++ b/core/tests/Feature/FilamentWorkflowTest.php @@ -269,7 +269,7 @@ public function test_administrator_can_queue_global_reconciliation_and_bounded_u Queue::assertPushed(BuildUsageRollups::class); } - public function test_domain_reconcile_actions_reuse_the_policy_aware_deployment_endpoints(): void + public function test_domain_dns_reconcile_action_reuses_the_policy_aware_endpoint(): void { Queue::fake(); $user = User::factory()->create(); @@ -297,12 +297,9 @@ public function test_domain_reconcile_actions_reuse_the_policy_aware_deployment_ $this->actingAs($user); Livewire::test(ViewDomain::class, ['record' => $domain->id]) - ->callAction('reconcileDns')->assertHasNoActionErrors() - ->callAction('deployEdge')->assertHasNoActionErrors(); + ->callAction('reconcileDns')->assertHasNoActionErrors(); $this->assertDatabaseHas('operations', ['type' => 'dns.zone_reconcile', 'actor_id' => $user->id]); - $this->assertDatabaseHas('operations', ['type' => 'edge.domain_reconcile', 'actor_id' => $user->id]); Queue::assertPushed(ReconcileDnsZone::class, fn (ReconcileDnsZone $job): bool => $job->domainId === $domain->id); - Queue::assertPushed(ReconcileEdgeDomain::class, fn (ReconcileEdgeDomain $job): bool => $job->domainId === $domain->id); } } diff --git a/docs/manual-browser-qualification.md b/docs/manual-browser-qualification.md index 47106e1..e134986 100644 --- a/docs/manual-browser-qualification.md +++ b/docs/manual-browser-qualification.md @@ -43,7 +43,7 @@ For every phase, check desktop and narrow mobile widths, browser-console errors, ### Administrator checkpoints -1. Sign in at `/admin`. Confirm the blue active-navigation treatment, collapsible desktop sidebar, readable responsive stat cards, **Control plane**, **Customers**, **Edge network**, and **Operations** groups, `Local Administrator`, and no missing-theme or console asset errors. The dashboard must show Domains, Users, DNS clusters, Serving edges, Work in progress, Failed operations, Queue lanes, Recent audit activity, and Common tasks without raw unstyled lists. +1. Open `/` first. Confirm the CDNFoundry landing page (not the Laravel starter screen) links to the domain workspace, administration, and `/api/health`, remains readable in light/dark mode, and has no missing assets. Sign in at `/admin`. Confirm the blue active-navigation treatment, collapsible desktop sidebar, readable responsive stat cards, **Control plane**, **Customers**, **Edge network**, **Operations**, **Observe**, and **Account** groups, `Local Administrator`, and no missing-theme or console asset errors. The dashboard must show Domains, Users, DNS clusters, Serving edges, Work in progress, Failed operations, Queue lanes, Recent audit activity, and Common tasks without raw unstyled lists. 2. Open **Users** and create: | Field | Value | @@ -63,7 +63,7 @@ For every phase, check desktop and narrow mobile widths, browser-console errors, ### Domain-user authorization checkpoints 1. Sign in at `/app` as `user@example.test` / `cdnfoundry-user-test`. -2. Confirm administrator navigation is absent. Confirm the dashboard shows only assigned-domain totals and recent domains, plus the three-step **Start serving a domain** guide; an unassigned domain name must not appear. +2. Confirm administrator navigation is absent and **Domains**, **Observe**, and **Account** are the only application groups. Confirm the dashboard shows only assigned-domain totals and recent domains, plus the three-step **Start serving a domain** guide; an unassigned domain name must not appear. 3. Repeat the personal token and profile checks; changes must affect only this user. 4. Directly request `/admin/users`, `/admin/dns-clusters`, `/admin/audit-logs`, and `/horizon`; all must be forbidden. @@ -148,6 +148,13 @@ dig @127.0.0.1 -p 1053 browser-test.example.test TXT Use PowerAdmin only to inspect derived state. Never edit desired state there. +As administrator, open **DNS clusters**, choose **Reconcile all zones**, confirm +the warning, and copy the resulting `dns.global_reconcile` operation ID. On one +active domain choose **Domain actions → Reconcile authoritative DNS** and confirm +the existing or new `dns.zone_reconcile` operation is shown without a duplicate +active operation. Repeat as its assigned domain user; an unassigned domain must +remain not found. + ### Phase 2 completion gate - Implementation: present. @@ -313,6 +320,10 @@ unchanged MX/TXT/CAA records. 4. Confirm the domain view header shows four compact action menus—**Domain actions**, **Delivery**, **Cache**, and **TLS**—without horizontal overflow at desktop or mobile widths. Confirm the page renders **Domain status**, **Edge delivery**, **Authoritative DNS deployment**, **Cache**, and **TLS** as one ordered stack of cards, with fields reducing to one column on a narrow viewport. Proxy defaults must appear as one readable summary (for example, `Enabled · HTTP/1.1 + HTTP/2 · HTTPS redirect off · 0 origin retries · Maintenance off`) rather than raw JSON or separate boolean/list fragments. Confirm proxied-host count, desired/active revision, placement/pools, failure, and recent validated revisions. The desired revision, active edge revision, retained rollback revisions, and each DNS cluster acknowledgement must show dates rather than bare revision numbers. 5. Send HTTP and HTTPS through both real edges. Confirm correct origin selection, Host, SNI, IPv4/IPv6 behavior, unknown-host/SNI rejection, and continued serving of the last valid revision after a deliberately invalid candidate. 6. Move the domain shared → quarantine → dedicated. For each move record the target-ready acknowledgement, target DNS answer, non-null drain deadline, source-removal artifact, final acknowledgement, and active pool. A failed/rejected target must leave source DNS and traffic active. +7. As administrator, open **Edges**, choose **Reconcile all domains**, confirm the + warning, and verify one `edge.global_reconcile` operation processes domains + in bounded chunks. This is maintenance reconciliation; the per-domain UI must + still have no manual deploy action because normal saves deploy automatically. Saving **Proxy defaults** alone does not turn a DNS-only record into a proxied hostname. Confirm its notice says that no hostname will be deployed until an A, @@ -582,10 +593,13 @@ two Vector batch intervals. Record exact UTC generation times and byte counts. `/admin/telemetry/usage.csv`; both must be forbidden. Confirm no page button navigates to a token-protected `/api/admin/...` URL. 4. Inspect the latest 20 **Finalized usage** rows and open **Global usage CSV**. - Rebuild the generated - complete UTC interval through the documented administrator API/action using - one `Idempotency-Key`; replay it and record the same operation/result. Export - again and confirm the selected domain row and contract version are unchanged. + Choose **Rebuild usage**, optionally select the disposable domain, enter a + complete UTC-hour range no longer than 31 days, confirm, and copy the + `usage.rebuild` operation ID. Confirm an end before the start and a range over + 31 days are rejected. Separately rebuild the same interval through the API + with one `Idempotency-Key`; replay it and record the same operation/result. + Export again and confirm the selected domain row and contract version are + unchanged. 5. Stop only ClickHouse with `docker compose -f compose.dev.yml stop clickhouse`. Refresh both analytics pages: each must render a clear analytics-unavailable message while its panel/navigation stays usable. During the interruption,