diff --git a/README.md b/README.md index ef39789..2661d90 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ make dev-scale-e2e `make dev-e2e` uses the real HTTP APIs, PostgreSQL, queues, PowerDNS, DNSdist, mTLS edge control, pool migration, Pebble DNS-01 issuance, cache/purge delivery, and OpenResty HTTP/HTTPS runtime. Browser acceptance is deliberately manual and is specified in -[manual browser qualification](docs/manual-browser-qualification.md). +[manual browser qualification](docs/manual-browser-qualification.md). The +[frontend route coverage audit](docs/frontend-route-coverage.md) maps every +application route family to its human, automation-only, or agent-only surface. Production uses immutable commit-SHA GHCR images through [`compose.prod.yml`](compose.prod.yml); it has no `latest` tags and no production diff --git a/core/app/Filament/Admin/Pages/Telemetry.php b/core/app/Filament/Admin/Pages/Telemetry.php index b0d1d32..9c6eed7 100644 --- a/core/app/Filament/Admin/Pages/Telemetry.php +++ b/core/app/Filament/Admin/Pages/Telemetry.php @@ -2,9 +2,15 @@ namespace App\Filament\Admin\Pages; +use App\Http\Controllers\Admin\UsageController; +use App\Models\Domain; use App\Models\UsageRollup; use App\Support\AnalyticsStore; use Carbon\CarbonImmutable; +use Filament\Actions\Action; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Support\Facades\Http; use Throwable; @@ -21,6 +27,39 @@ class Telemetry extends Page protected string $view = 'filament.admin.pages.telemetry'; + protected function getHeaderActions(): array + { + return [ + Action::make('rebuildUsage') + ->label('Rebuild usage') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->schema([ + Select::make('domain_id')->label('Domain (optional)') + ->options(fn (): array => Domain::query()->orderBy('name')->limit(500)->pluck('name', 'id')->all()) + ->searchable(), + DateTimePicker::make('from')->label('From (UTC)')->timezone('UTC')->seconds(false)->required(), + DateTimePicker::make('to')->label('To (UTC)')->timezone('UTC')->seconds(false)->required()->after('from'), + ]) + ->fillForm(fn (): array => [ + 'from' => CarbonImmutable::now('UTC')->subHour()->startOfHour(), + 'to' => CarbonImmutable::now('UTC')->startOfHour(), + ]) + ->requiresConfirmation() + ->action(function (array $data): void { + request()->merge([ + 'domain_id' => $data['domain_id'] ?? null, + 'from' => CarbonImmutable::parse($data['from'], 'UTC')->startOfHour()->toIso8601String(), + 'to' => CarbonImmutable::parse($data['to'], 'UTC')->startOfHour()->toIso8601String(), + ]); + $response = app(UsageController::class)->rebuild(request()); + $operation = $response->getData(true)['data']; + Notification::make()->info()->title('Usage rebuild queued') + ->body("Operation {$operation['operation_id']} will rebuild complete UTC hours without changing serving behavior.")->send(); + }), + ]; + } + public function getStateProperty(): array { $store = app(AnalyticsStore::class); diff --git a/core/app/Filament/Admin/Resources/DnsClusters/Pages/ListDnsClusters.php b/core/app/Filament/Admin/Resources/DnsClusters/Pages/ListDnsClusters.php index e1d1c24..c030dbe 100644 --- a/core/app/Filament/Admin/Resources/DnsClusters/Pages/ListDnsClusters.php +++ b/core/app/Filament/Admin/Resources/DnsClusters/Pages/ListDnsClusters.php @@ -3,7 +3,10 @@ namespace App\Filament\Admin\Resources\DnsClusters\Pages; use App\Filament\Admin\Resources\DnsClusters\DnsClusterResource; +use App\Http\Controllers\Admin\DnsOperationController; +use Filament\Actions\Action; use Filament\Actions\CreateAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListDnsClusters extends ListRecords @@ -12,6 +15,19 @@ class ListDnsClusters extends ListRecords protected function getHeaderActions(): array { - return [CreateAction::make()]; + return [ + Action::make('reconcileAllZones') + ->label('Reconcile all zones') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->action(function (): void { + $response = app(DnsOperationController::class)->reconcile(request()); + $operation = $response->getData(true)['data']; + Notification::make()->info()->title('Global DNS reconciliation queued') + ->body("Operation {$operation['id']} will process active zones in bounded chunks.")->send(); + }), + CreateAction::make(), + ]; } } diff --git a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php index f019194..1bf1bf9 100644 --- a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php +++ b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php @@ -6,6 +6,8 @@ use App\Enums\DomainLifecycleState; 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; @@ -57,6 +59,22 @@ public function getSubheading(): ?string protected function getHeaderActions(): array { $actions = [ + Action::make('reconcileDns')->label('Reconcile authoritative DNS')->icon('heroicon-o-arrow-path') + ->visible(fn (): bool => $this->record->lifecycle_state === DomainLifecycleState::Active) + ->action(function (): void { + $response = app(DnsDeploymentController::class)->reconcile(request(), $this->record); + $operation = $response->getData(true)['data']; + 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]) @@ -399,12 +417,12 @@ static function () use ($zone): void { ->all(); return [ - ActionGroup::make($group(['verifyNameservers', 'forceVerifyNameservers', 'activate', 'disable', 'importZone', 'exportZone'])) + ActionGroup::make($group(['verifyNameservers', 'forceVerifyNameservers', 'reconcileDns', 'activate', 'disable', 'importZone', 'exportZone'])) ->label('Domain actions') ->icon('heroicon-o-globe-alt') ->color('gray') ->button(), - ActionGroup::make($group(['proxyDefaults', 'rollbackProxy', 'moveEdgePool'])) + ActionGroup::make($group(['deployEdge', 'proxyDefaults', 'rollbackProxy', 'moveEdgePool'])) ->label('Delivery') ->icon('heroicon-o-cloud') ->button(), diff --git a/core/app/Filament/Shared/Pages/ApiTokens.php b/core/app/Filament/Shared/Pages/ApiTokens.php index eeee959..9815a17 100644 --- a/core/app/Filament/Shared/Pages/ApiTokens.php +++ b/core/app/Filament/Shared/Pages/ApiTokens.php @@ -14,6 +14,8 @@ class ApiTokens extends Page protected static ?string $navigationLabel = 'API tokens'; + protected static string|\UnitEnum|null $navigationGroup = 'Account'; + protected static ?string $slug = 'tokens'; protected string $view = 'filament.shared.pages.api-tokens'; diff --git a/core/app/Providers/Filament/AdminPanelProvider.php b/core/app/Providers/Filament/AdminPanelProvider.php index 3fb159b..2297aa1 100644 --- a/core/app/Providers/Filament/AdminPanelProvider.php +++ b/core/app/Providers/Filament/AdminPanelProvider.php @@ -36,7 +36,7 @@ public function panel(Panel $panel): Panel ->colors(['primary' => Color::Blue]) ->sidebarCollapsibleOnDesktop() ->databaseNotifications() - ->navigationGroups(['Control plane', 'Customers', 'Edge network', 'Operations']) + ->navigationGroups(['Control plane', 'Customers', 'Edge network', 'Operations', 'Observe', 'Account']) ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources') ->resources([DomainResource::class]) ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') diff --git a/core/resources/css/app.css b/core/resources/css/app.css index 62b9686..dceab20 100644 --- a/core/resources/css/app.css +++ b/core/resources/css/app.css @@ -7,3 +7,47 @@ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } + +@layer base { + body { margin: 0; } +} + +@layer components { + .cdn-landing-body { min-height: 100vh; background: #f8fafc; color: #0f172a; font-family: var(--font-sans); } + .cdn-landing-shell { width: min(72rem, calc(100% - 2rem)); margin-inline: auto; } + .cdn-landing-header { display: flex; min-height: 5rem; align-items: center; justify-content: space-between; gap: 1rem; border-bottom: 1px solid #e2e8f0; } + .cdn-landing-brand { display: inline-flex; align-items: center; gap: .65rem; color: inherit; font-weight: 750; letter-spacing: -.02em; text-decoration: none; } + .cdn-landing-mark { display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: .65rem; background: #2563eb; color: white; font-size: .875rem; } + .cdn-landing-link { color: #475569; font-size: .875rem; font-weight: 600; text-decoration: none; } + .cdn-landing-link:hover { color: #1d4ed8; } + .cdn-landing-hero { display: grid; align-items: center; gap: 3rem; padding-block: clamp(4rem, 10vw, 8rem); } + .cdn-landing-eyebrow { margin: 0 0 1rem; color: #2563eb; font-size: .75rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } + .cdn-landing-hero h1 { max-width: 48rem; margin: 0; font-size: clamp(2.4rem, 7vw, 4.5rem); font-weight: 750; letter-spacing: -.055em; line-height: 1.02; } + .cdn-landing-copy { max-width: 42rem; margin: 1.5rem 0 0; color: #475569; font-size: 1.05rem; line-height: 1.75; } + .cdn-landing-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; } + .cdn-landing-button { display: inline-flex; min-height: 2.75rem; align-items: center; justify-content: center; border: 1px solid #2563eb; border-radius: .7rem; background: #2563eb; color: white; padding: .65rem 1rem; font-size: .875rem; font-weight: 700; text-decoration: none; } + .cdn-landing-button:hover { background: #1d4ed8; } + .cdn-landing-button-secondary { border-color: #cbd5e1; background: white; color: #1e293b; } + .cdn-landing-button-secondary:hover { border-color: #94a3b8; background: #f8fafc; } + .cdn-landing-panel { border: 1px solid #dbeafe; border-radius: 1.25rem; background: white; padding: 1.5rem; box-shadow: 0 24px 60px rgb(15 23 42 / .08); } + .cdn-landing-panel-label { color: #64748b; font-size: .75rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } + .cdn-landing-panel ul { display: grid; gap: .25rem; margin: 1rem 0 0; padding: 0; list-style: none; } + .cdn-landing-panel li { display: flex; gap: 1rem; align-items: center; border-radius: .75rem; padding: .9rem; } + .cdn-landing-panel li:nth-child(odd) { background: #f8fafc; } + .cdn-landing-panel li > span { color: #2563eb; font-size: .75rem; font-weight: 750; } + .cdn-landing-panel strong, .cdn-landing-panel small { display: block; } + .cdn-landing-panel small { margin-top: .2rem; color: #64748b; } +} + +@media (min-width: 56rem) { + .cdn-landing-hero { grid-template-columns: minmax(0, 1.45fr) minmax(18rem, .55fr); } +} + +@media (prefers-color-scheme: dark) { + .cdn-landing-body { background: #090e1a; color: #f8fafc; } + .cdn-landing-header { border-color: #1e293b; } + .cdn-landing-link, .cdn-landing-copy { color: #94a3b8; } + .cdn-landing-button-secondary, .cdn-landing-panel { border-color: #334155; background: #111827; color: #f8fafc; } + .cdn-landing-button-secondary:hover, .cdn-landing-panel li:nth-child(odd) { background: #1e293b; } + .cdn-landing-panel small, .cdn-landing-panel-label { color: #94a3b8; } +} diff --git a/core/resources/views/components/ui/data-table.blade.php b/core/resources/views/components/ui/data-table.blade.php new file mode 100644 index 0000000..92a93b6 --- /dev/null +++ b/core/resources/views/components/ui/data-table.blade.php @@ -0,0 +1,13 @@ +@props(['caption' => null]) + +
class('cdn-table-wrap') }}> + + @if (filled($caption)) + + @endif + @if (isset($header)) + {{ $header }} + @endif + {{ $slot }} +
{{ $caption }}
+
diff --git a/core/resources/views/components/ui/empty-state.blade.php b/core/resources/views/components/ui/empty-state.blade.php new file mode 100644 index 0000000..ba2d68c --- /dev/null +++ b/core/resources/views/components/ui/empty-state.blade.php @@ -0,0 +1,14 @@ +@props([ + 'title' => null, + 'description' => null, +]) + +
class('cdn-empty-state') }} role="status"> + @if (filled($title)) +
{{ $title }}
+ @endif +
{{ $description ?? $slot }}
+ @if (isset($actions)) +
{{ $actions }}
+ @endif +
diff --git a/core/resources/views/components/ui/list-row.blade.php b/core/resources/views/components/ui/list-row.blade.php new file mode 100644 index 0000000..9498054 --- /dev/null +++ b/core/resources/views/components/ui/list-row.blade.php @@ -0,0 +1,20 @@ +@props([ + 'title', + 'meta' => null, + 'href' => null, +]) + +@php($tag = $href ? 'a' : 'div') + +<{{ $tag }} {{ $attributes->class('cdn-list-row') }} @if ($href) href="{{ $href }}" @endif> +
+
{{ $title }}
+ @if (filled($meta)) +
{{ $meta }}
+ @endif + {{ $slot }} +
+ @if (isset($aside)) +
{{ $aside }}
+ @endif + diff --git a/core/resources/views/components/ui/stat-card.blade.php b/core/resources/views/components/ui/stat-card.blade.php new file mode 100644 index 0000000..ba0e35f --- /dev/null +++ b/core/resources/views/components/ui/stat-card.blade.php @@ -0,0 +1,21 @@ +@props([ + 'label', + 'value', + 'description' => null, + 'tone' => 'neutral', + 'href' => null, +]) + +@php($tag = $href ? 'a' : 'div') + +<{{ $tag }} + {{ $attributes->class('cdn-stat-card') }} + data-tone="{{ $tone }}" + @if ($href) href="{{ $href }}" aria-label="Open {{ $label }}" @endif +> +
{{ $label }}
+
{{ $value }}
+ @if (filled($description)) +
{{ $description }}
+ @endif + diff --git a/core/resources/views/components/ui/status-pill.blade.php b/core/resources/views/components/ui/status-pill.blade.php new file mode 100644 index 0000000..d3bcf28 --- /dev/null +++ b/core/resources/views/components/ui/status-pill.blade.php @@ -0,0 +1,5 @@ +@props(['tone' => 'neutral']) + +class('cdn-status-pill') }} data-tone="{{ $tone }}"> + {{ $slot }} + diff --git a/core/resources/views/filament/admin/pages/platform-settings.blade.php b/core/resources/views/filament/admin/pages/platform-settings.blade.php index 70a4c8f..ff79d98 100644 --- a/core/resources/views/filament/admin/pages/platform-settings.blade.php +++ b/core/resources/views/filament/admin/pages/platform-settings.blade.php @@ -1,6 +1,6 @@
{{ $this->form }} - Validate and save platform settings +
diff --git a/core/resources/views/filament/admin/pages/system-dns-identity.blade.php b/core/resources/views/filament/admin/pages/system-dns-identity.blade.php index 7e97c4e..0fa6eef 100644 --- a/core/resources/views/filament/admin/pages/system-dns-identity.blade.php +++ b/core/resources/views/filament/admin/pages/system-dns-identity.blade.php @@ -1,13 +1,16 @@
{{ $this->form }} - Validate and preview + @if ($preview)

Review the normalized high-risk DNS identity payload before applying it.

{{ json_encode($preview, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
- Confirm and queue update + + Confirm and queue update + Queuing update… +
@endif
diff --git a/core/resources/views/filament/admin/pages/telemetry.blade.php b/core/resources/views/filament/admin/pages/telemetry.blade.php index 49e6e74..15282b6 100644 --- a/core/resources/views/filament/admin/pages/telemetry.blade.php +++ b/core/resources/views/filament/admin/pages/telemetry.blade.php @@ -14,12 +14,12 @@
- ClickHouse {{ $state['available'] ? 'available' : 'unavailable' }} - Vector metrics {{ $state['buffer']['available'] ? 'available' : 'unavailable' }} - {{ ($state['meta']['partial'] ?? true) ? 'Partial / provisional' : 'Finalized' }} + ClickHouse {{ $state['available'] ? 'available' : 'unavailable' }} + Vector metrics {{ $state['buffer']['available'] ? 'available' : 'unavailable' }} + {{ ($state['meta']['partial'] ?? true) ? 'Partial / provisional' : 'Finalized' }}
@if (!$state['available']) -
Analytics unavailable. Traffic serving is independent and remains active. Finalized PostgreSQL usage is still shown below.
+ @endif
@@ -33,43 +33,31 @@ ['label' => 'Origin errors', 'value' => number_format((int) ($summary['origin_errors'] ?? 0)), 'description' => 'Origin failures requiring review', 'tone' => ((int) ($summary['origin_errors'] ?? 0)) > 0 ? 'warning' : 'success'], ['label' => 'Security blocks', 'value' => number_format((int) ($summary['security_blocks'] ?? 0)), 'description' => 'Bounded protection decisions', 'tone' => 'warning'], ] as $stat) -
-
{{ $stat['label'] }}
-
{{ $stat['value'] }}
-
{{ $stat['description'] }}
-
+ @endforeach
-
- - - + + @forelse ($state['traffic'] as $row) @empty @endforelse - -
UTC hourRequestsTransfer
UTC hourRequestsTransfer
{{ $row['bucket'] ?? 'Unknown' }}{{ number_format((int) ($row['requests'] ?? 0)) }}{{ $formatBytes(((int) ($row['bytes_in'] ?? 0)) + ((int) ($row['bytes_out'] ?? 0))) }}
No traffic was recorded in this range.
-
+
-
- - - + + @forelse ($state['dns'] as $row) @empty @endforelse - -
TypeResponseQueries
TypeResponseQueries
{{ $row['qtype'] ?? 'Unknown' }}{{ $row['rcode'] ?? 'Unknown' }}{{ number_format((int) ($row['queries'] ?? 0)) }}
No DNS activity was recorded in this range.
-
+
@@ -88,7 +76,7 @@ @if (isset($row['status'])){{ $row['status'] }}@endif @empty -
No {{ $stream }} events in the last hour.
+ @endforelse @@ -109,7 +97,7 @@ {{ is_numeric($value) ? number_format((float) $value, 0) : $value }} @empty -
Vector delivery metrics are unavailable.
+ @endforelse @@ -118,18 +106,14 @@
Global usage CSV
-
- - - + + @forelse ($state['usage'] as $row) @empty @endforelse - -
Domain / intervalRequestsTransferDNSState
Domain / intervalRequestsTransferDNSState
{{ $row['domain'] }}
{{ $row['interval'] }}
{{ number_format($row['requests']) }}{{ $formatBytes($row['bytes']) }}{{ number_format($row['dns_queries']) }}{{ str($row['status'])->headline() }}
No finalized usage intervals are available yet.
-
+ diff --git a/core/resources/views/filament/domain/pages/analytics.blade.php b/core/resources/views/filament/domain/pages/analytics.blade.php index 49b272f..b17aa89 100644 --- a/core/resources/views/filament/domain/pages/analytics.blade.php +++ b/core/resources/views/filament/domain/pages/analytics.blade.php @@ -34,11 +34,11 @@ @if ($state['domain'])
- ClickHouse {{ $state['available'] ? 'available' : 'unavailable' }} - {{ ($state['meta']['partial'] ?? true) ? 'Partial / provisional' : 'Finalized' }} + ClickHouse {{ $state['available'] ? 'available' : 'unavailable' }} + {{ ($state['meta']['partial'] ?? true) ? 'Partial / provisional' : 'Finalized' }}
@if (!$state['available']) -
Analytics unavailable. DNS and edge serving continue normally; finalized PostgreSQL usage remains available below.
+ @endif
@@ -55,11 +55,7 @@ ['label' => 'Origin errors', 'value' => number_format((int) ($summary['origin_errors'] ?? 0)), 'description' => 'Origin failures', 'tone' => ((int) ($summary['origin_errors'] ?? 0)) > 0 ? 'warning' : 'success'], ['label' => 'Security blocks', 'value' => number_format((int) ($summary['security_blocks'] ?? 0)), 'description' => 'Protection decisions', 'tone' => 'warning'], ] as $stat) -
-
{{ $stat['label'] }}
-
{{ $stat['value'] }}
-
{{ $stat['description'] }}
-
+ @endforeach @@ -79,7 +75,7 @@
{{ $title }}
{{ $metrics ?: 'No activity' }}
@empty -
No data was recorded for this view.
+ @endforelse @@ -101,7 +97,7 @@ @if (isset($row['status'])){{ $row['status'] }}@endif @empty -
No {{ $stream }} events in the last hour.
+ @endforelse @@ -114,18 +110,14 @@
Usage CSV export
-
- - - + + @forelse ($state['usage'] as $row) @empty @endforelse - -
UTC intervalRequestsTransferDNSState
UTC intervalRequestsTransferDNSState
{{ $row['interval'] }}{{ number_format($row['requests']) }}{{ $formatBytes($row['bytes']) }}{{ number_format($row['dns_queries']) }}{{ str($row['status'])->headline() }}
No finalized usage intervals are available yet.
-
+ @endif diff --git a/core/resources/views/filament/domain/pages/dashboard.blade.php b/core/resources/views/filament/domain/pages/dashboard.blade.php index 6f86449..7eafdf8 100644 --- a/core/resources/views/filament/domain/pages/dashboard.blade.php +++ b/core/resources/views/filament/domain/pages/dashboard.blade.php @@ -2,11 +2,7 @@
@@ -19,12 +15,12 @@
{{ $domain->display_name ?: $domain->name }}
{{ $domain->name }} · {{ $domain->dns_records_count }} records · {{ $domain->proxied_records_count }} proxied
- + {{ str($domain->lifecycle_state->value)->replace('_', ' ')->headline() }} - + @empty -
No domains are assigned yet. Create your first domain to begin.
+ @endforelse diff --git a/core/resources/views/welcome.blade.php b/core/resources/views/welcome.blade.php index 22d6130..7a7516e 100644 --- a/core/resources/views/welcome.blade.php +++ b/core/resources/views/welcome.blade.php @@ -3,220 +3,41 @@ - - {{ config('app.name', 'Laravel') }} - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif + + + CDNFoundry + @vite(['resources/css/app.css', 'resources/js/app.js']) - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

+ +
+
+ + + CDNFoundry + + Service health +
+ +
+
+

Private CDN control plane

+

Operate DNS, edge delivery, TLS, cache, and security from one place.

+

Choose the workspace that matches your role. Runtime traffic and security decisions remain independent of this control plane.

+
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif + + +
diff --git a/core/tests/Feature/FilamentPanelAccessTest.php b/core/tests/Feature/FilamentPanelAccessTest.php index b9928d8..d888274 100644 --- a/core/tests/Feature/FilamentPanelAccessTest.php +++ b/core/tests/Feature/FilamentPanelAccessTest.php @@ -77,7 +77,7 @@ public function test_administrator_operational_pages_render_and_domain_users_can $admin = User::factory()->admin()->create(); $user = User::factory()->create(); - foreach (['/admin/users', '/admin/domains', '/admin/dns-clusters', '/admin/edges', '/admin/edge-pools', '/admin/operations', '/admin/audit-logs', '/admin/system-dns-identity', '/admin/platform-settings', '/admin/tokens', '/admin/profile'] as $path) { + foreach (['/admin/users', '/admin/domains', '/admin/dns-clusters', '/admin/edges', '/admin/edge-pools', '/admin/operations', '/admin/audit-logs', '/admin/system-dns-identity', '/admin/platform-settings', '/admin/telemetry', '/admin/tokens', '/admin/profile'] as $path) { $this->actingAs($admin)->get($path)->assertOk(); $this->actingAs($user)->get($path)->assertForbidden(); } diff --git a/core/tests/Feature/FilamentWorkflowTest.php b/core/tests/Feature/FilamentWorkflowTest.php index 5077655..0a27ced 100644 --- a/core/tests/Feature/FilamentWorkflowTest.php +++ b/core/tests/Feature/FilamentWorkflowTest.php @@ -3,16 +3,24 @@ namespace Tests\Feature; use App\Enums\DomainLifecycleState; +use App\Filament\Admin\Pages\Telemetry; +use App\Filament\Admin\Resources\DnsClusters\Pages\ListDnsClusters; use App\Filament\Admin\Resources\Edges\Pages\EditEdge; +use App\Filament\Admin\Resources\Edges\Pages\ListEdges; use App\Filament\Admin\Resources\Edges\RelationManagers\CellsRelationManager; use App\Filament\Domain\Resources\Domains\Pages\ViewDomain; use App\Filament\Domain\Resources\Domains\RelationManagers\DnsRecordsRelationManager; +use App\Jobs\BuildUsageRollups; +use App\Jobs\ReconcileAllDnsZones; +use App\Jobs\ReconcileAllEdgeDomains; +use App\Jobs\ReconcileDnsZone; use App\Jobs\ReconcileEdgeDomain; use App\Models\Domain; use App\Models\Edge; use App\Models\EdgeArtifact; use App\Models\EdgeCell; use App\Models\EdgePool; +use App\Models\Operation; use App\Models\User; use App\Support\DnsRecordData; use Filament\Facades\Filament; @@ -233,4 +241,68 @@ public function test_disabling_from_the_domain_panel_automatically_queues_edge_r $this->assertSame(5, $domain->revision); Queue::assertPushed(ReconcileEdgeDomain::class, fn (ReconcileEdgeDomain $job): bool => $job->domainId === $domain->id); } + + public function test_administrator_can_queue_global_reconciliation_and_bounded_usage_rebuilds(): void + { + Queue::fake(); + $admin = User::factory()->admin()->create(); + $domain = Domain::query()->create(['name' => 'usage-ui.example.test', 'display_name' => 'Usage UI']); + Filament::setCurrentPanel(Filament::getPanel('admin')); + $this->actingAs($admin); + + Livewire::test(ListDnsClusters::class)->callAction('reconcileAllZones')->assertHasNoActionErrors(); + Livewire::test(ListEdges::class)->callAction('reconcileAllDomains')->assertHasNoActionErrors(); + Livewire::test(Telemetry::class)->callAction('rebuildUsage', data: [ + 'domain_id' => $domain->id, + 'from' => '2026-07-20 08:00:00', + 'to' => '2026-07-20 10:00:00', + ])->assertHasNoActionErrors(); + + $this->assertSame(1, Operation::query()->where('type', 'dns.global_reconcile')->count()); + $this->assertSame(1, Operation::query()->where('type', 'edge.global_reconcile')->count()); + $this->assertDatabaseHas('operations', [ + 'type' => 'usage.rebuild', + 'actor_id' => $admin->id, + ]); + Queue::assertPushed(ReconcileAllDnsZones::class); + Queue::assertPushed(ReconcileAllEdgeDomains::class); + Queue::assertPushed(BuildUsageRollups::class); + } + + public function test_domain_reconcile_actions_reuse_the_policy_aware_deployment_endpoints(): void + { + Queue::fake(); + $user = User::factory()->create(); + $domain = Domain::query()->create([ + 'name' => 'reconcile-ui.example.test', + 'display_name' => 'Reconcile UI', + 'lifecycle_state' => DomainLifecycleState::Active, + 'revision' => 3, + ]); + $domain->users()->attach($user); + $domain->dnsRecords()->create(DnsRecordData::validate([ + 'type' => 'A', + 'name' => 'www', + 'content' => '192.0.2.20', + 'ttl' => 300, + 'mode' => 'proxied', + 'origin' => [ + 'host' => '8.8.8.8', 'port' => 443, 'scheme' => 'https', + 'host_header' => 'www.reconcile-ui.example.test', 'sni' => 'www.reconcile-ui.example.test', + 'verify_tls' => true, 'connect_timeout_ms' => 2000, 'response_timeout_ms' => 30000, + 'retry_count' => 0, 'websocket' => false, 'health_check' => null, + ], + ], $domain->name)); + Filament::setCurrentPanel(Filament::getPanel('app')); + $this->actingAs($user); + + Livewire::test(ViewDomain::class, ['record' => $domain->id]) + ->callAction('reconcileDns')->assertHasNoActionErrors() + ->callAction('deployEdge')->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/frontend-route-coverage.md b/docs/frontend-route-coverage.md new file mode 100644 index 0000000..4c10444 --- /dev/null +++ b/docs/frontend-route-coverage.md @@ -0,0 +1,74 @@ +# Frontend route coverage + +This audit covers the application-owned routes in `core/routes/web.php` and +`core/routes/api.php`. Framework routes for Livewire, Filament, Sanctum, +Horizon internals, local storage, and Laravel's fallback health route are not +product actions and are excluded. + +The browser UI is intentionally a policy-aware Filament surface, not an HTTP +client pasted over the API. Browser actions call the same controllers, +validators, policies, models, and queued jobs as their API equivalents. An API +route remains API-only when it exists for automation, pagination, detailed +machine diagnostics, or an edge agent rather than for a person to manage. + +## Public and account routes + +| Route family | Browser surface | Coverage | +|---|---|---| +| `GET /`, `/api/health`, `/api/ready`, `/api/nameservers` | Product landing page links to both panels and service health; nameservers appear in domain delegation | Complete | +| `/api/auth/login`, `/api/auth/logout`, `/api/me` | Filament login/logout and shared Profile page | Complete | +| `/api/me/tokens` | Shared API tokens page with one-time secret display and revoke confirmation | Complete | +| `/api/operations/{operation}` | Domain actions show operation IDs; administrator Operations lists global detail and retry state | Complete | + +## Domain-user routes + +| API route family | Browser surface | Coverage | +|---|---|---| +| `/api/domains` and `/api/domains/{domain}` | Domains resource: assigned-domain list, create, status, display label, disable, and lifecycle state | Complete | +| `/verify-nameservers`, `/activate`, `/status` | Domain actions and Domain status card | Complete | +| `/dns/records`, `/dns/import`, `/dns/export` | DNS records relation with create/edit/delete/bulk delete, BIND import/export, type-aware validation, and permission-gated NS rows | Complete | +| `/dns/records/{record}/geo` and `/geo/preview` | Geo-DNS fields and Preview record action | Complete | +| `/dns/deployment` and `/dns/reconcile` | Authoritative DNS deployment card and **Reconcile authoritative DNS** action | Complete | +| `/proxy`, `/origin`, `/origin/test`, `/origin/health` | Delivery settings, proxied-record origin form, test action, and visible health state | Complete | +| `/deployment`, `/deploy`, `/rollback`, `/revisions` | Delivery status, validated revisions, and rollback action. Edge deploy remains deliberately automatic in the UI per the roadmap; the API trigger is for automation | Complete by contract | +| `/cache`, `/cache/development-mode`, `/cache/purge`, `/cache/purges` | Cache menu: settings, bounded development mode, epoch/URL purge, and latest delivery state | Complete | +| `/tls`, `/tls/status`, `/tls/reissue`, `/tls/renew`, `/tls/upload`, `/tls/custom-certificate` | TLS menu and TLS status card | Complete | +| `/security`, `/security/rules`, `/security/rules/import` | Security profile action and ordered Security rules relation | Complete | +| `/security/ddos`, `/security/ddos/status`, `/security/ddos/events`, `/security/events` | Security readiness/status card, bounded settings, state actions for admins, and recent reason codes | Complete | +| `/analytics/*`, `/logs/*` | Assigned-domain Analytics and logs page with all aggregate views and bounded masked previews | Complete | +| `/usage`, `/usage/export` | Finalized usage table and session-authenticated CSV export | Complete | + +Advanced custom origin ports remain API-only as required by the roadmap. The +browser form exposes the standard HTTP/80 and HTTPS/443 pairs so the common +path stays safe and understandable. + +## Administrator routes + +| API route family | Browser surface | Coverage | +|---|---|---| +| `/api/admin/users` and user domain assignment | Users resource plus the domain Users relation | Complete | +| `/api/admin/audit-logs` | Read-only Audit logs resource | Complete | +| `/api/admin/domains/{domain}/force-verify` | Permission-gated Force verify domain action | Complete | +| `/api/admin/dns/clusters` | DNS clusters resource with secret-safe create/edit, test, enable, and disable | Complete | +| `/api/admin/dns/deployments`, `/failed-deployments`, `/reconcile` | Domain deployment cards and cluster-level **Reconcile all zones** action; failures are searchable in Operations | Complete | +| `/api/admin/system/status`, `/api/admin/operations` | Dashboard queue/health cards and Operations resource with guarded retry | Complete | +| `/api/admin/system/settings*` | Platform settings and System DNS identity pages with preview/confirmation for high-risk identity changes | Complete | +| `/api/admin/edges` | Edges resource with enrollment secret boundary, edit, rotate, enable/disable, drain/undrain, and emergency state | Complete | +| `/api/admin/edge-pools` | Service pools resource with provisioning, enable/disable, withdrawal, and emergency state | Complete | +| `/api/admin/edge-cells*` | Per-edge Cells relation with address edit, drain/undrain, restart, emergency state, and runtime diagnostics | Complete | +| `/api/admin/edge-deployments`, `/edge-routing`, `/edge-deployments/reconcile` | Domain delivery cards, pool targets, edge diagnostics, and **Reconcile all domains** maintenance action | Complete | +| `/api/admin/domains/{domain}/isolation`, `/move`, `/restrict`, `/quarantine`, `/release` | Permission-gated Delivery and Security domain actions | Complete | +| `/api/admin/analytics/*`, `/logs/*`, `/usage`, `/usage/export`, `/usage/rebuild` | Telemetry and usage page, masked previews, global CSV, and bounded **Rebuild usage** action | Complete | + +## Machine-only routes + +`/edge/v1/register`, heartbeat, manifest/artifact/full configuration, +apply/reject acknowledgements, task polling, and task results are an authenticated +edge-agent protocol. Exposing them as human buttons would violate the runtime +architecture and one-time enrollment boundary. Their human-observable state is +available through Edges, Cells, Operations, Audit logs, and deployment cards. + +Cursor pagination and machine export variants stay API-only; their human +equivalents use Filament pagination, bounded tables, and session-authenticated +CSV downloads. Horizon remains the authenticated administrator UI for its own +framework routes.