diff --git a/.claude/skills/inertia-table/SKILL.md b/.claude/skills/inertia-table/SKILL.md index 52c9bd6a7..b86409fcb 100644 --- a/.claude/skills/inertia-table/SKILL.md +++ b/.claude/skills/inertia-table/SKILL.md @@ -99,7 +99,7 @@ export default function Index({ servers }: { servers: InertiaTableData }) { tableData={servers} actions={(row) => ( <> - + )} @@ -213,7 +213,7 @@ Global hooks (`Table::globalBeforeQuery`, `Table::globalAfterData`) already exis **Settings → frontend hooks:** `->withSettings(['polling' => 5])` ships arbitrary data; on the React side register a hook with `registerTableHook('polling', ({ value, refresh }) => { const id = setInterval(refresh, value*1000); return () => clearInterval(id); })`. -**Row click to detail page:** ` router.visit(route('servers.show', row.id as number))} />`. The component already skips clicks on `a, button, input, select, textarea, [role="button"]`. +**Row click to detail page:** `` router.visit(`/servers/${row.id}`)} />``. The component already skips clicks on `a, button, input, select, textarea, [role="button"]`. **External search input** (e.g. in a page header instead of the toolbar): pass `searchRef={inputRef}` — the built-in search bar hides and the table watches that input. @@ -222,7 +222,7 @@ Global hooks (`Table::globalBeforeQuery`, `Table::globalAfterData`) already exis 1. **Every row needs an `id`.** Add `Column::data('id')` if `id` isn't otherwise included. 2. **Dot-notation columns get a `_`-prefixed name** in the row payload (e.g. `provider.name` → row key `_provider_name`). Use the column's display value; don't reach into the row by the dotted path from the frontend. 3. **Sort key vs display name:** for computed columns, set `->accessor('real_db_column')` or sort will target the wrong field. -4. **Ziggy** is a peer dep — Vito4 uses it, so leave `use_ziggy => true`. If you ever turn it off, link columns send pre-resolved URLs. +4. **Link URLs** are resolved server-side. Keep `use_ziggy => false` in `config/inertia-table.php`; frontend link cells read the pre-resolved URL from `href_key`. 5. **TypeScript prop type** for an Inertia page is `InertiaTableData`, not the row type. Cast `row.id` (it's `string | number`). 6. **`ActionsColumn`** does nothing on its own — you must pass `actions={(row) => ...}` to ``. 7. **`registerCellComponent` / `registerIcon` must run before render.** Put them in `app.tsx` (or a module imported there), not inside the page component. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 2156a598f..d4c81174c 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -7,6 +7,7 @@ description: "React, Inertia, Tailwind v4, and TypeScript frontend standards" ## Inertia & React +- Use root-relative URL paths (`/settings/profile`, `/servers/${server.id}`) for frontend navigation and requests. Encode string path segments and pass query parameters via Axios `params` or `URLSearchParams`. - Inertia pages live in `resources/js/pages/`. Use `Inertia::render()` from Laravel controllers. - React components in `resources/js/components/`. Use functional components and hooks. - Use the `useForm` helper for forms — follow existing patterns in the codebase. @@ -37,7 +38,7 @@ dialog.confirm.open({ variant: 'destructive', confirmLabel: 'Delete', method: 'delete', - url: route('firewall.destroy', { server: rule.server_id, firewallRule: rule }), + url: `/servers/${rule.server_id}/firewall/${rule.id}`, }); ``` @@ -67,7 +68,7 @@ export default function FirewallRuleForm({ const form = useForm({ /* seed from props */ }); const submit = (e: FormEvent) => { e.preventDefault(); - form.post(route('firewall.store', { server: serverId }), { onSuccess: () => onOpenChange(false) }); + form.post(`/servers/${serverId}/firewall`, { onSuccess: () => onOpenChange(false) }); }; return ( diff --git a/.prettierignore b/.prettierignore index db8957b31..3409b01ac 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,5 +15,4 @@ sail resources/views/ssh/ resources/views/wireguard/ resources/views/scribe/ -resources/js/ziggy.js resources/views/mail/* diff --git a/app/Actions/Bootstrap/GetBootstrap.php b/app/Actions/Bootstrap/GetBootstrap.php index a5ddbf596..90f4ddee5 100644 --- a/app/Actions/Bootstrap/GetBootstrap.php +++ b/app/Actions/Bootstrap/GetBootstrap.php @@ -95,9 +95,18 @@ private function configs(): array 'installed' => GithubApp::query()->exists(), ], 'tooling' => $this->tooling(), + 'dashboard_urls' => [ + 'horizon' => $this->dashboardUrl(config('horizon.domain'), config('horizon.path')), + 'logs' => $this->dashboardUrl(config('log-viewer.route_domain'), config('log-viewer.route_path')), + ], ]; } + private function dashboardUrl(?string $domain, string $path): string + { + return ($domain ? '//'.$domain : '').'/'.trim($path, '/'); + } + /** * @return array, commands: array}> */ diff --git a/app/Actions/Ziggy/GetZiggyRoutes.php b/app/Actions/Ziggy/GetZiggyRoutes.php deleted file mode 100644 index d6422f883..000000000 --- a/app/Actions/Ziggy/GetZiggyRoutes.php +++ /dev/null @@ -1,74 +0,0 @@ -cachedScript !== null) { - return $this->cachedScript; - } - - if (! app()->isProduction()) { - return $this->cachedScript = $this->buildScript(); - } - - return $this->cachedScript = Cache::rememberForever( - self::SCRIPT_CACHE_KEY, - fn (): string => $this->buildScript(), - ); - } - - public function version(): string - { - if ($this->cachedVersion !== null) { - return $this->cachedVersion; - } - - if (! app()->isProduction()) { - return $this->cachedVersion = substr(md5($this->script()), 0, 16); - } - - return $this->cachedVersion = Cache::rememberForever( - self::VERSION_CACHE_KEY, - fn (): string => substr(md5($this->script()), 0, 16), - ); - } - - public function url(): string - { - return route('ziggy.routes', ['version' => $this->version()]); - } - - public static function forgetCache(): void - { - Cache::forget(self::SCRIPT_CACHE_KEY); - Cache::forget(self::VERSION_CACHE_KEY); - } - - private function buildScript(): string - { - $payload = (new Ziggy)->toJson(); - $routeFunctionPath = base_path('vendor/tightenco/ziggy/dist/route.umd.js'); - $routeFunction = file_get_contents($routeFunctionPath); - - if ($routeFunction === false) { - throw new RuntimeException("Failed to read Ziggy route function from {$routeFunctionPath}"); - } - - return "window.Ziggy={$payload};{$routeFunction}"; - } -} diff --git a/app/Http/Controllers/ZiggyRoutesController.php b/app/Http/Controllers/ZiggyRoutesController.php deleted file mode 100644 index f5d608adf..000000000 --- a/app/Http/Controllers/ZiggyRoutesController.php +++ /dev/null @@ -1,31 +0,0 @@ -script(), 200, [ - 'Content-Type' => 'application/javascript; charset=utf-8', - 'Cache-Control' => 'public, max-age=31536000, immutable', - ]); - } -} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 795233003..8f67af10e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -15,7 +15,6 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; use Inertia\Middleware; -use Tighten\Ziggy\Ziggy; class HandleInertiaRequests extends Middleware { @@ -49,8 +48,6 @@ public function share(Request $request): array { [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); - $ssrEnabled = (bool) config('inertia.ssr.enabled'); - /** @var ?User $user */ $user = $request->user(); $currentProject = $user?->currentProject; @@ -111,12 +108,6 @@ public function share(Request $request): array ] : null, 'csrf_token' => csrf_token(), 'bootstrap_version' => app(GetBootstrap::class)->version(), - ...($ssrEnabled ? [ - 'ziggy' => fn (): array => [ - ...(new Ziggy)->toArray(), - 'location' => $request->url(), - ], - ] : []), 'flash' => [ 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), diff --git a/composer.json b/composer.json index 5345477dc..95eabdf58 100644 --- a/composer.json +++ b/composer.json @@ -28,8 +28,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.17", "spatie/laravel-route-attributes": "^1.24", - "symfony/yaml": "^8.0", - "tightenco/ziggy": "^2.5" + "symfony/yaml": "^8.0" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/composer.lock b/composer.lock index e348fa36f..f4d876edc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ffa2c365bbb1da4e8d29787360bf8d98", + "content-hash": "c0403521d2ec32c379f59ccfd95e7898", "packages": [ { "name": "aws/aws-crt-php", @@ -8877,76 +8877,6 @@ ], "time": "2026-08-21T12:16:08+00:00" }, - { - "name": "tightenco/ziggy", - "version": "v2.6.4", - "source": { - "type": "git", - "url": "https://github.com/tighten/ziggy.git", - "reference": "c32a3eaad1574df8b953281680bba89bd3217dc2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tighten/ziggy/zipball/c32a3eaad1574df8b953281680bba89bd3217dc2", - "reference": "c32a3eaad1574df8b953281680bba89bd3217dc2", - "shasum": "" - }, - "require": { - "ext-json": "*", - "laravel/framework": ">=9.0", - "php": ">=8.1" - }, - "require-dev": { - "laravel/folio": "^1.1", - "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", - "pestphp/pest": "^2.0 || ^3.0 || ^4.0", - "pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Tighten\\Ziggy\\ZiggyServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Tighten\\Ziggy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Coulbourne", - "email": "daniel@tighten.co" - }, - { - "name": "Jake Bathman", - "email": "jake@tighten.co" - }, - { - "name": "Jacob Baker-Kretzmar", - "email": "jacob@tighten.co" - } - ], - "description": "Use your Laravel named routes in JavaScript.", - "homepage": "https://github.com/tighten/ziggy", - "keywords": [ - "Ziggy", - "javascript", - "laravel", - "routes" - ], - "support": { - "issues": "https://github.com/tighten/ziggy/issues", - "source": "https://github.com/tighten/ziggy/tree/v2.6.4" - }, - "time": "2026-08-18T17:28:45+00:00" - }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/config/inertia-table.php b/config/inertia-table.php new file mode 100644 index 000000000..f1d19d3b5 --- /dev/null +++ b/config/inertia-table.php @@ -0,0 +1,5 @@ + false, +]; diff --git a/docs/4.x/prologue/release-notes.md b/docs/4.x/prologue/release-notes.md index 5d9417607..1e091cbd7 100644 --- a/docs/4.x/prologue/release-notes.md +++ b/docs/4.x/prologue/release-notes.md @@ -8,9 +8,10 @@ releases. ### Inertia context shrinking -Inertia responses previously shipped ~120 KB+ of mostly-static shared data on every request (the -Ziggy route table, the provider/service catalogue, and more). A new cached bootstrap endpoint now -serves this once and hydrates it on the frontend, cutting typical responses down to a few KB. +Inertia responses previously shipped ~120 KB+ of mostly-static shared data on every request. The +frontend now uses URL paths directly, without a client-side route catalogue or route-script cache. +A cached bootstrap endpoint serves the provider/service catalogue once and hydrates it on the +frontend, cutting typical responses down to a few KB. ## Realtime websocket Updates diff --git a/resources/js/components/app-command.tsx b/resources/js/components/app-command.tsx index 3b02f5cf2..389631b25 100644 --- a/resources/js/components/app-command.tsx +++ b/resources/js/components/app-command.tsx @@ -46,7 +46,7 @@ export default function AppCommand() { const query = useQuery({ queryKey: ['search'], queryFn: async () => { - const response = await axios.get(route('search', { query: queryText })); + const response = await axios.get('/search', { params: { query: queryText } }); return response.data.data; }, retry: false, @@ -100,16 +100,13 @@ export default function AppCommand() { value={`result-${index}`} onSelect={() => { if (result.type === 'server') { - router.post(route('servers.switch', { server: result.id })); + router.post(`/servers/${result.id}/switch`); } else if (result.type === 'project') { router.patch( - route('projects.switch', { - project: result.id, - currentPath: window.location.pathname, - }), + `/settings/projects/switch/${result.id}?${new URLSearchParams({ currentPath: window.location.pathname })}`, ); } else if (result.type === 'site') { - router.post(route('sites.switch', { server: result.parent_id, site: result.id })); + router.post(`/servers/${result.parent_id}/sites/${result.id}/switch`); } setOpen(false); }} diff --git a/resources/js/components/app-sidebar-nested.tsx b/resources/js/components/app-sidebar-nested.tsx index 659598a35..313900f78 100644 --- a/resources/js/components/app-sidebar-nested.tsx +++ b/resources/js/components/app-sidebar-nested.tsx @@ -55,56 +55,56 @@ export function AppSidebar() { const mainNavItems: NavItem[] = [ { title: 'Servers', - href: route('servers'), + href: '/servers', icon: ServerIcon, children: [ { title: 'Overview', - href: route('servers.show', { server: page.props.server?.id || 0 }), - onlyActivePath: route('servers.show', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}`, + onlyActivePath: `/servers/${page.props.server?.id || 0}`, icon: HomeIcon, isDisabled: isServerMenuDisabled, }, { title: 'Database', - href: route('databases', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/database`, icon: DatabaseIcon, isDisabled: isServerMenuDisabled, children: [ { title: 'Databases', - href: route('databases', { server: page.props.server?.id || 0 }), - onlyActivePath: route('databases', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/database`, + onlyActivePath: `/servers/${page.props.server?.id || 0}/database`, icon: DatabaseIcon, }, { title: 'Users', - href: route('database-users', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/database/users`, icon: UsersIcon, }, { title: 'Backups', - href: route('backups', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/backups`, icon: CloudUploadIcon, }, ], }, { title: 'Sites', - href: route('sites', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/sites`, icon: MousePointerClickIcon, isDisabled: isServerMenuDisabled, children: page.props.site ? [ { title: 'All sites', - href: route('sites', { server: page.props.server?.id || 0 }), - onlyActivePath: route('sites', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/sites`, + onlyActivePath: `/servers/${page.props.server?.id || 0}/sites`, icon: ArrowLeftIcon, }, { title: 'Application', - href: route('application', { server: page.props.server?.id || 0, site: page.props.site?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/sites/${page.props.site?.id || 0}`, icon: RocketIcon, }, ] @@ -112,13 +112,13 @@ export function AppSidebar() { }, { title: 'Firewall', - href: route('firewall', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/firewall`, icon: FlameIcon, isDisabled: isServerMenuDisabled, }, { title: 'CronJobs', - href: route('cronjobs', { server: page.props.server?.id || 0 }), + href: `/servers/${page.props.server?.id || 0}/cronjobs`, icon: ClockIcon, isDisabled: isServerMenuDisabled, }, @@ -161,62 +161,62 @@ export function AppSidebar() { }, { title: 'Sites', - href: route('sites.all'), + href: '/sites', icon: MousePointerClickIcon, }, { title: 'Settings', - href: route('settings'), + href: '/settings', icon: CogIcon, children: [ { title: 'Profile', - href: route('profile'), + href: '/settings/profile', icon: UserIcon, }, { title: 'Users', - href: route('users'), + href: '/admin/users', icon: UsersIcon, }, { title: 'Projects', - href: route('projects'), + href: '/settings/projects', icon: ListIcon, }, { title: 'Server Providers', - href: route('server-providers'), + href: '/settings/server-providers', icon: CloudIcon, }, { title: 'Source Controls', - href: route('source-controls'), + href: '/settings/source-controls', icon: CodeIcon, }, { title: 'Storage Providers', - href: route('storage-providers'), + href: '/settings/storage-providers', icon: DatabaseIcon, }, { title: 'Notification Channels', - href: route('notification-channels'), + href: '/settings/notification-channels', icon: BellIcon, }, { title: 'SSH Keys', - href: route('ssh-keys'), + href: '/settings/ssh-keys', icon: KeyIcon, }, { title: 'Tags', - href: route('tags'), + href: '/tags', icon: TagIcon, }, { title: 'API Keys', - href: route('api-keys'), + href: '/settings/api-keys', icon: PlugIcon, }, ], @@ -238,7 +238,7 @@ export function AppSidebar() { const getMenuItems = (items: NavItem[]) => { return items.map((item) => { - const isActive = item.onlyActivePath ? currentPath() === item.href : window.location.href.startsWith(item.href); + const isActive = item.onlyActivePath ? currentPath() === item.href : currentPath().startsWith(item.href); if (item.children && item.children.length > 0) { return ( @@ -276,7 +276,7 @@ export function AppSidebar() { - + diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d53cc84da..05e5b44ca 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -35,54 +35,56 @@ import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useConfigs } from '@/stores/bootstrap-store'; export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?: NavItem[]; secondNavTitle?: string }) { const page = usePage(); + const dashboardUrls = useConfigs()?.dashboard_urls; const mainNavItems: NavItem[] = [ { title: 'Networks', - href: route('networks'), + href: '/networks', icon: NetworkIcon, }, { title: 'Servers', - href: route('servers'), + href: '/servers', icon: ServerIcon, }, { title: 'Sites', - href: route('sites.all'), + href: '/sites', icon: MousePointerClickIcon, }, { title: 'Backups', - href: route('backups.all'), + href: '/backups', icon: CloudUploadIcon, }, { title: 'Scripts', - href: route('scripts'), + href: '/scripts', icon: ZapIcon, }, { title: 'Workflows', - href: route('workflows'), + href: '/workflows', icon: WorkflowIcon, }, { title: 'Domains', - href: route('domains'), + href: '/domains', icon: Globe, }, { title: 'Settings', - href: route('settings'), + href: '/settings', icon: CogIcon, }, { title: 'Admin', - href: route('admin'), + href: '/admin', icon: Settings2Icon, hidden: !page.props.auth.user?.is_admin, }, @@ -91,15 +93,15 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? const footerNavItems: NavItem[] = [ { title: 'Horizon Dashboard', - href: route('horizon.index'), + href: dashboardUrls?.horizon ?? '#', icon: ListEndIcon, - hidden: !page.props.auth.user?.is_admin, + hidden: !page.props.auth.user?.is_admin || !dashboardUrls, }, { title: 'Vito Logs', - href: route('log-viewer.index'), + href: dashboardUrls?.logs ?? '#', icon: LogsIcon, - hidden: !page.props.auth.user?.is_admin, + hidden: !page.props.auth.user?.is_admin || !dashboardUrls, }, { title: 'Repository', @@ -123,7 +125,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? - + @@ -143,7 +145,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?