diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b58821e..7a8bc6fb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { Suspense, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -12,30 +12,6 @@ import { Toaster } from './components/ui/sonner'; import ThemeSync from './components/ThemeSync'; import DashboardLayout from './layouts/DashboardLayout'; import AppLoader from './components/AppLoader'; -import Dashboard from './pages/Dashboard'; -import Login from './pages/Login'; -import Register from './pages/Register'; -import Setup from './pages/Setup'; -import Docker from './pages/Docker'; -import Databases from './pages/Databases'; -import Domains from './pages/Domains'; -import Monitoring from './pages/Monitoring'; -import Backups from './pages/Backups'; -import Terminal from './pages/Terminal'; -import Settings from './pages/Settings'; -import FileManager from './pages/FileManager'; -// Firewall is now part of Security page -import CronJobs from './pages/CronJobs'; -import Security from './pages/Security'; -import Services from './pages/Services'; -import NewService from './pages/new-service/NewService'; -import ImportWizard from './pages/ImportWizard'; -import Coexistence from './pages/Coexistence'; -import ServiceDetail from './pages/ServiceDetail'; -import Templates from './pages/Templates'; -import Servers from './pages/Servers'; -import ServerDetail from './pages/ServerDetail'; -import AgentFleet from './pages/AgentFleet'; import TabGroupLayout from './layouts/TabGroupLayout'; import { SERVER_TABS } from './components/servers/serverTabs'; import { DOMAIN_TABS } from './components/domains/domainTabs'; @@ -46,111 +22,17 @@ import { MARKET_TABS } from './components/marketplace/marketTabs'; import { BACKUP_TABS } from './components/backups/backupTabs'; import { SECURITY_TABS } from './components/security/securityTabs'; import { ORG_TABS } from './components/organization/organizationTabs'; -import Downloads from './pages/Downloads'; -import SSLCertificates from './pages/SSLCertificates'; -import SSOCallback from './pages/SSOCallback'; -import SourceConnectionCallback from './pages/SourceConnectionCallback'; -import GithubAppCallback from './pages/GithubAppCallback'; -import DatabaseMigration from './pages/DatabaseMigration'; -import ServerTemplates from './pages/ServerTemplates'; -import Workspaces from './pages/Workspaces'; -import WorkspaceDetail from './pages/WorkspaceDetail'; -import Projects from './pages/Projects'; -import ProjectDetail from './pages/ProjectDetail'; -import SharedVariables from './pages/SharedVariables'; -import FleetProxy from './pages/FleetProxy'; -import PublicStatusPage from './pages/PublicStatusPage'; -import Marketplace from './pages/Marketplace'; -import Vaults from './pages/Vaults'; -import StyleGuide from './pages/StyleGuide'; -import NotFound from './pages/NotFound'; -import AppMap from './pages/AppMap'; -import Documentation from './pages/Documentation'; -import Deployments from './pages/Deployments'; -import DeployConsole from './pages/DeployConsole'; -import QueueOperations from './pages/QueueOperations'; -import QueueDetail from './pages/QueueDetail'; -import Notifications from './pages/Notifications'; -import DeliveryLog from './pages/DeliveryLog'; -import Telemetry from './pages/Telemetry'; -import Jobs from './pages/Jobs'; -import Errors from './pages/Errors'; -import Monitors from './pages/Monitors'; -import MonitorDetail from './pages/MonitorDetail'; -import Incidents from './pages/Incidents'; -import TestSandbox from './pages/TestSandbox'; import useDevMode from './hooks/useDevMode'; import useExtensionRoutes from './plugins/ExtensionRoutes'; import { useContributions } from './plugins/contributions'; - -// Page title mapping -const PAGE_TITLES = { - '/': 'Dashboard', - '/login': 'Login', - '/register': 'Register', - '/setup': 'Setup', - '/services': 'Services', - '/services/new': 'New Service', - '/imports': 'Import a Site', - '/imports/coexistence': 'Running Alongside Another Panel', - '/projects': 'Projects', - '/shared-variables': 'Shared Variables', - '/fleet-proxy': 'Fleet Proxy', - // WordPress titles come from the serverkit-wordpress manifest - // (page_titles) — list pages by exact path, detail pages via the - // base-path lookup in PageTitleUpdater below. - '/templates': 'Templates', - '/deployments': 'Deployment Activity', - '/domains': 'Domains', - '/databases': 'Databases', - '/ssl': 'SSL Certificates', - '/docker': 'Docker', - '/servers': 'Servers', - '/downloads': 'Downloads', - '/files': 'File Manager', - '/observability': 'Monitoring', - '/monitoring': 'Monitoring', - '/backups': 'Backups', - '/cron': 'Cron Jobs', - '/security': 'Security', - '/terminal': 'Terminal', - '/settings': 'Settings', - '/connections/callback/github': 'GitHub Connection', - '/connections/github-app/callback': 'GitHub Setup', - '/migrate': 'Database Migration', - '/fleet': 'Agent Fleet', - '/agent-plugins': 'Extensions', - '/server-templates': 'Server Templates', - '/workspaces': 'Workspaces', - '/workspaces/:id': 'Workspace', - '/workspaces/:id/overview': 'Workspace Overview', - '/workspaces/:id/servers': 'Workspace Servers', - '/workspaces/:id/services': 'Workspace Services', - '/workspaces/:id/sites': 'Workspace Sites', - '/workspaces/:id/members': 'Workspace Members', - '/workspaces/:id/settings': 'Workspace Settings', - '/workspaces/:id/settings/general': 'Workspace Settings', - '/workspaces/:id/settings/navigation': 'Workspace Navigation Permissions', - '/dns': 'DNS Zones', - '/extensions': 'Extensions', - '/extensions/installed': 'Installed Extensions', - '/vaults': 'Vaults', - '/style-guide': 'Style Guide', - '/app-map': 'App Map', - '/documentation': 'Documentation', - '/dynamic-dns': 'Dynamic DNS', - '/queue': 'Queue Bus', - '/notifications': 'Notifications', - '/admin/notifications': 'Notification Delivery Log', - '/telemetry': 'Telemetry', - '/monitoring/monitors': 'Monitors', - '/monitoring/monitors/:monitorId': 'Monitor', - '/monitoring/incidents': 'Incidents', - '/monitoring/jobs': 'Jobs', - '/monitoring/errors': 'Errors', - '/jobs': 'Jobs', - '/test-sandbox': 'Test Sandbox', -}; +import { ROUTE_COMPONENTS } from './routes/routeComponents'; +import { + ROUTE_GROUP_IDS, + resolveCoreRouteTitle, + resolveExactCoreRouteTitle, + routesForGroup, + routesForPlacement, +} from './routes/routeManifest'; // /apps/* is the legacy URL space for what is now "Services" (§1 unification). // The list route already redirects; this preserves deep links to a single app @@ -168,34 +50,12 @@ function PageTitleUpdater() { useEffect(() => { const path = location.pathname; - let title = PAGE_TITLES[path] || (pluginTitles && pluginTitles[path]); - - // Handle dynamic routes and tab sub-routes - if (!title) { - if (path.startsWith('/workspaces/')) { - const parts = path.split('/'); - const tab = parts[3]; - const section = parts[4]; - if (tab === 'settings' && section) { - title = PAGE_TITLES[`/workspaces/:id/settings/${section}`] || 'Workspace Settings'; - } else if (tab) { - title = PAGE_TITLES[`/workspaces/:id/${tab}`] || 'Workspace'; - } else { - title = 'Workspace'; - } - } - // Check if it's a base page with a tab suffix (e.g., /security/firewall) - else { - const basePath = '/' + path.split('/')[1]; - if (PAGE_TITLES[basePath]) { - title = PAGE_TITLES[basePath]; - } else if (pluginTitles && pluginTitles[basePath]) { - title = pluginTitles[basePath]; - } else if (path.startsWith('/services/')) title = 'Service Details'; - else if (path.startsWith('/servers/')) title = 'Server Details'; - else title = ''; - } - } + const basePath = '/' + path.split('/')[1]; + const title = resolveExactCoreRouteTitle(path) + || (pluginTitles && pluginTitles[path]) + || resolveCoreRouteTitle(path) + || (pluginTitles && pluginTitles[basePath]) + || ''; // Public (pre-auth) routes stay brand-neutral; the app interior uses the panel brand. const isPublicRoute = ['/login', '/register', '/setup', '/forgot'].some(p => path.startsWith(p)); @@ -282,42 +142,63 @@ function LegacyGitExtRedirect() { return ; } +const ROUTE_GROUP_LAYOUTS = Object.freeze({ + services: { tabs: SERVICE_TABS }, + domains: { tabs: DOMAIN_TABS }, + servers: { tabs: SERVER_TABS, groupId: 'servers' }, + organization: { tabs: ORG_TABS }, + marketplace: { tabs: MARKET_TABS }, + files: { tabs: FILE_TABS, groupId: 'files' }, + monitoring: { tabs: MONITOR_TABS, groupId: 'monitoring' }, + backups: { tabs: BACKUP_TABS }, + security: { tabs: SECURITY_TABS, groupId: 'security' }, +}); + +// These are the four extension nesting slots supported before the manifest +// refactor. Keeping the list explicit prevents a newly named contribution +// group from becoming routable without an intentional host change. +const EXTENSION_ROUTE_GROUPS = new Set(['servers', 'files', 'monitoring', 'security']); + +function ManifestRouteElement({ route }) { + let element; + if (route.redirect) { + element = ; + } else if (route.legacyRedirect === 'apps') { + element = ; + } else if (route.legacyRedirect === 'git-extension') { + element = ; + } else { + const Component = ROUTE_COMPONENTS[route.component]; + element = ( + }> + + + ); + } + + if (route.devOnly) element = {element}; + if (route.guard === 'setup') element = {element}; + if (route.guard === 'public') element = {element}; + if (route.guard === 'private') element = {element}; + return element; +} + +function renderManifestRoute(route) { + return ( + } + /> + ); +} + function AppRoutes() { const { dashboardRoutes, groupRoutes, standaloneGroups } = useExtensionRoutes(); return ( - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - } /> + {routesForPlacement('root').map(renderManifestRoute)} {/* Standalone plugin layouts — bare or custom. Each group is a sibling top-level Route under PrivateRoute, so the plugin owns the chrome (no DashboardLayout sidebar). */} @@ -337,192 +218,22 @@ function AppRoutes() { }> - } /> - {/* Tab groups — each parent TabGroupLayout renders the shared - PageTopbar + sub-nav once and swaps only the routed content - below, so the tabs act like real tabs (no full-page remount) - and keep the group's sidebar item lit. Detail / full-bleed - routes (services/:id, wordpress/:id, …) stay outside. */} - }> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - {/* Settings sub-section in the URL (e.g. .../settings/git) - so the Settings left-nav is shareable and survives a refresh. */} - } /> - } /> - } /> - } /> - } /> - } /> - {/* WordPress moved into the serverkit-wordpress builtin extension - (Phase 5 #38). It contributes a single splat route wordpress/* - via its manifest and self-renders the whole sub-router (tab - group + full-bleed detail + legacy /projects redirects), so all - the WordPress routing now lives in the extension. */} - {/* The React-Flow Workflow Builder (serverkit-workflows) was - retired in plan 45; the Automations extension (tramo) replaces - it. Keep the old /workflow path working by redirecting. */} - } /> - }> - } /> - } /> - - {/* DNS Zones + Dynamic DNS were merged into the Domains surface - (records + per-record Dynamic DNS live in the domain drawer) — - keep the old paths working by redirecting. */} - } /> - } /> - {/* Cloudflare zone settings moved into the serverkit-cloudflare-ops - builtin extension (#36); it contributes the - cloudflare/zones/:zoneId route via its manifest. Reached from the - "Open in Cloudflare" button on a Cloudflare-managed domain. */} - } /> - } /> - } /> - } /> - {/* /cloud and /remote-access are now the serverkit-cloud-provision - and serverkit-remote-access builtin extensions: they join this - group via tabs contributions + group-nested routes - (groupRoutes.servers). */} - }> - } /> - } /> - } /> - } /> - {groupRoutes.servers} - - } /> - } /> - } /> - {/* Organization tab group — Projects / Shared Variables / - Workspaces share one PageTopbar + tabs (ORG_TABS) instead of - a collapsible sidebar sub-menu. Detail routes stay outside. */} - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - - {/* Old /marketplace paths redirect to /extensions (renamed for clarity). */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* /ftp is now the serverkit-ftp builtin extension: it joins - this group via a tabs contribution + group-nested routes - (groupRoutes.files), so tab + page disappear together when - it's uninstalled. */} - }> - } /> - {groupRoutes.files} - - {/* Observability tab group (§4): Monitoring / Events / Status - Pages share one PageTopbar. /observability lands on Monitoring. */} - {/* /status-pages is now the serverkit-status builtin extension - (tabs contribution + group-nested route, groupRoutes.monitoring); - the public /status/:slug route stays core above. */} - }> - } /> - {/* Static segments outrank the :tab catch-all below, so these - keep their own pages while /monitoring/rules etc. still - land on . */} - } /> - } /> - {/* Alerts merged into Incidents — a host threshold crossing - and a monitor going down are the same question. */} - } /> - } /> - {/* Jobs moved in from its own top-level page: it is the same - class of thing as Events and read as a rival page next to - it. Activity/Scheduled is now an in-page SegControl - (the group's bar already owns the tab row). */} - } /> - } /> - {/* Error tracker: same class of thing as Events and Jobs — - deduplicated backend/frontend exceptions. */} - } /> - } /> - } /> - {groupRoutes.monitoring} - - {/* Outside the tab group: a single monitor is a drill-down with - its own breadcrumb top bar, like /queue/:group/:queue. */} - } /> - } /> - {/* Fleet Monitor folded into /monitoring: it asked the same - questions at fleet scope, so its heatmap is now the Host - health grid on the overview (click a card to re-scope the - page) and its analytics are the Capacity tab. */} - } /> - {/* /gpu is contributed by the standalone serverkit-gpu extension. */} - }> - } /> - } /> - - } /> - {/* Security tab group. groupId="security" (plan 47 Phase 3a) lets - extracted security features (fail2ban, clamav, …) contribute - tabs + group-nested routes via their manifests, so they return - as tabs in the place users know. */} - }> - } /> - } /> - {groupRoutes.security} - - {/* Email routes are gated by the Email module toggle - (Settings → Modules); disabled ⇒ redirect to the dashboard. */} - {/* /email is now the serverkit-email builtin extension (contributes - the route via its manifest). */} - } /> - } /> - } /> - {/* Retired "Secrets & Webhooks" page: Vaults moved into the - Organization tab group (/vaults); the inbound-webhook console - is now a Settings → Admin tab (it's server configuration, not - a daily ops surface). Redirect old links/bookmarks. */} - } /> - } /> - } /> - } /> - } /> - {/* Developer tooling, not an operator surface — see the - matching requiresCondition on the sidebar item. */} - } /> - } /> - } /> - } /> - {/* Jobs is a Monitoring tab now; keep old links working. */} - } /> - } /> - } /> - } /> + {ROUTE_GROUP_IDS.map((group) => { + const layout = ROUTE_GROUP_LAYOUTS[group]; + return ( + } + > + {routesForGroup(group).map(renderManifestRoute)} + {EXTENSION_ROUTE_GROUPS.has(group) && groupRoutes[group]} + + ); + })} + {routesForPlacement('dashboard') + .filter((route) => !route.group) + .map(renderManifestRoute)} {dashboardRoutes} - {/* Catch-all: an unknown URL, or an extension route whose - extension isn't installed (never registered above). Renders - inside the dashboard chrome instead of a blank page. */} - } /> ); diff --git a/frontend/src/routes/__tests__/routeManifest.test.mjs b/frontend/src/routes/__tests__/routeManifest.test.mjs new file mode 100644 index 00000000..3628a989 --- /dev/null +++ b/frontend/src/routes/__tests__/routeManifest.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + CORE_ROUTES, + ROUTE_GROUP_IDS, + resolveCoreRouteTitle, + resolveExactCoreRouteTitle, + routesForGroup, + routesForPlacement, +} from '../routeManifest.js'; + +test('core route paths and ids are unique', () => { + assert.equal(CORE_ROUTES.length, 96, 'route changes must update the parity baseline intentionally'); + assert.equal(new Set(CORE_ROUTES.map(({ id }) => id)).size, CORE_ROUTES.length); + assert.equal(new Set(CORE_ROUTES.map(({ path }) => path)).size, CORE_ROUTES.length); +}); + +test('each route has one render strategy and valid placement metadata', () => { + for (const route of CORE_ROUTES) { + const strategies = [route.component, route.redirect, route.legacyRedirect].filter(Boolean); + assert.equal(strategies.length, 1, `${route.id} must define exactly one render strategy`); + assert.ok(['root', 'dashboard'].includes(route.placement), `${route.id} has an invalid placement`); + if (route.group) assert.ok(ROUTE_GROUP_IDS.includes(route.group), `${route.id} has an unknown group`); + if (route.guard) assert.equal(route.placement, 'root', `${route.id} cannot guard a nested dashboard route`); + } +}); + +test('route selectors partition the manifest without duplication', () => { + const selected = [...routesForPlacement('root'), ...routesForPlacement('dashboard')]; + assert.equal(selected.length, CORE_ROUTES.length); + + for (const group of ROUTE_GROUP_IDS) { + assert.ok(routesForGroup(group).length > 0, `${group} must own at least one route`); + } +}); + +test('title resolution prefers static routes and handles dynamic workspace titles', () => { + assert.equal(resolveExactCoreRouteTitle('/services/new'), 'New Service'); + assert.equal(resolveExactCoreRouteTitle('/services/123'), ''); + assert.equal(resolveCoreRouteTitle('/services/new'), 'New Service'); + assert.equal(resolveCoreRouteTitle('/services/123/settings'), 'Services'); + assert.equal(resolveCoreRouteTitle('/workspaces/42/overview'), 'Workspace Overview'); + assert.equal(resolveCoreRouteTitle('/workspaces/42/settings/navigation'), 'Workspace Navigation Permissions'); + assert.equal(resolveCoreRouteTitle('/connections/callback/github'), 'GitHub Connection'); + assert.equal(resolveCoreRouteTitle('/status/public'), ''); + assert.equal(resolveCoreRouteTitle('/not-a-core-route'), ''); +}); + +test('every component key has exactly one lazy page registration', async () => { + const registryPath = fileURLToPath(new URL('../routeComponents.jsx', import.meta.url)); + const registrySource = await readFile(registryPath, 'utf8'); + const registered = new Set( + [...registrySource.matchAll(/^\s{4}([A-Za-z][A-Za-z0-9]+): lazy\(/gm)] + .map((match) => match[1]), + ); + const referenced = new Set(CORE_ROUTES.map(({ component }) => component).filter(Boolean)); + + assert.deepEqual([...registered].sort(), [...referenced].sort()); +}); diff --git a/frontend/src/routes/routeComponents.jsx b/frontend/src/routes/routeComponents.jsx new file mode 100644 index 00000000..66490f54 --- /dev/null +++ b/frontend/src/routes/routeComponents.jsx @@ -0,0 +1,62 @@ +import { lazy } from 'react'; + +// Keep import functions explicit: Vite emits one chunk per page while the +// manifest remains plain data that Node can validate without a JSX loader. +export const ROUTE_COMPONENTS = Object.freeze({ + AgentFleet: lazy(() => import('../pages/AgentFleet')), + AppMap: lazy(() => import('../pages/AppMap')), + Backups: lazy(() => import('../pages/Backups')), + Coexistence: lazy(() => import('../pages/Coexistence')), + CronJobs: lazy(() => import('../pages/CronJobs')), + Dashboard: lazy(() => import('../pages/Dashboard')), + DatabaseMigration: lazy(() => import('../pages/DatabaseMigration')), + Databases: lazy(() => import('../pages/Databases')), + DeliveryLog: lazy(() => import('../pages/DeliveryLog')), + DeployConsole: lazy(() => import('../pages/DeployConsole')), + Deployments: lazy(() => import('../pages/Deployments')), + Docker: lazy(() => import('../pages/Docker')), + Documentation: lazy(() => import('../pages/Documentation')), + Domains: lazy(() => import('../pages/Domains')), + Downloads: lazy(() => import('../pages/Downloads')), + Errors: lazy(() => import('../pages/Errors')), + FileManager: lazy(() => import('../pages/FileManager')), + FleetProxy: lazy(() => import('../pages/FleetProxy')), + GithubAppCallback: lazy(() => import('../pages/GithubAppCallback')), + ImportWizard: lazy(() => import('../pages/ImportWizard')), + Incidents: lazy(() => import('../pages/Incidents')), + Jobs: lazy(() => import('../pages/Jobs')), + Login: lazy(() => import('../pages/Login')), + Marketplace: lazy(() => import('../pages/Marketplace')), + MonitorDetail: lazy(() => import('../pages/MonitorDetail')), + Monitoring: lazy(() => import('../pages/Monitoring')), + Monitors: lazy(() => import('../pages/Monitors')), + NewService: lazy(() => import('../pages/new-service/NewService')), + NotFound: lazy(() => import('../pages/NotFound')), + Notifications: lazy(() => import('../pages/Notifications')), + ProjectDetail: lazy(() => import('../pages/ProjectDetail')), + Projects: lazy(() => import('../pages/Projects')), + PublicStatusPage: lazy(() => import('../pages/PublicStatusPage')), + QueueDetail: lazy(() => import('../pages/QueueDetail')), + QueueOperations: lazy(() => import('../pages/QueueOperations')), + Register: lazy(() => import('../pages/Register')), + Security: lazy(() => import('../pages/Security')), + ServerDetail: lazy(() => import('../pages/ServerDetail')), + Servers: lazy(() => import('../pages/Servers')), + ServerTemplates: lazy(() => import('../pages/ServerTemplates')), + ServiceDetail: lazy(() => import('../pages/ServiceDetail')), + Services: lazy(() => import('../pages/Services')), + Settings: lazy(() => import('../pages/Settings')), + Setup: lazy(() => import('../pages/Setup')), + SharedVariables: lazy(() => import('../pages/SharedVariables')), + SourceConnectionCallback: lazy(() => import('../pages/SourceConnectionCallback')), + SSLCertificates: lazy(() => import('../pages/SSLCertificates')), + SSOCallback: lazy(() => import('../pages/SSOCallback')), + StyleGuide: lazy(() => import('../pages/StyleGuide')), + Telemetry: lazy(() => import('../pages/Telemetry')), + Templates: lazy(() => import('../pages/Templates')), + Terminal: lazy(() => import('../pages/Terminal')), + TestSandbox: lazy(() => import('../pages/TestSandbox')), + Vaults: lazy(() => import('../pages/Vaults')), + WorkspaceDetail: lazy(() => import('../pages/WorkspaceDetail')), + Workspaces: lazy(() => import('../pages/Workspaces')), +}); diff --git a/frontend/src/routes/routeManifest.js b/frontend/src/routes/routeManifest.js new file mode 100644 index 00000000..28bca107 --- /dev/null +++ b/frontend/src/routes/routeManifest.js @@ -0,0 +1,209 @@ +/** + * Core route descriptor contract. + * + * @typedef {'root' | 'dashboard'} RoutePlacement + * @typedef {'public' | 'private' | 'setup'} RouteGuard + * @typedef {Object} CoreRoute + * @property {string} id Stable identity used by tests and diagnostics. + * @property {string} path Absolute React Router path (or `*` for the dashboard fallback). + * @property {RoutePlacement} placement Router level where the route is mounted. + * @property {string=} component Key in routeComponents.jsx. + * @property {string=} redirect Absolute redirect destination. + * @property {'apps' | 'git-extension'=} legacyRedirect Parameter-aware legacy redirect. + * @property {RouteGuard=} guard Root-route authentication policy. + * @property {string=} group Dashboard tab-group identity. + * @property {string=} title Browser title before the panel brand. + * @property {string=} titlePath Optional exact title-only path for a parameterized route. + * @property {boolean=} index Whether this is the dashboard index route. + * @property {boolean=} devOnly Whether the page is hidden outside developer mode. + * @property {Record>=} titleByParam Title overrides keyed by parameter. + */ + +/** @type {ReadonlyArray>} */ +export const CORE_ROUTES = Object.freeze([ + { id: 'migration', path: '/migrate', placement: 'root', component: 'DatabaseMigration', title: 'Database Migration' }, + { id: 'setup', path: '/setup', placement: 'root', component: 'Setup', guard: 'setup', title: 'Setup' }, + { id: 'login', path: '/login', placement: 'root', component: 'Login', guard: 'public', title: 'Login' }, + { id: 'sso-callback', path: '/login/callback/:provider', placement: 'root', component: 'SSOCallback', guard: 'public', title: 'Login' }, + { id: 'register', path: '/register', placement: 'root', component: 'Register', guard: 'public', title: 'Register' }, + { id: 'connection-callback', path: '/connections/callback/:provider', placement: 'root', component: 'SourceConnectionCallback', guard: 'private', title: 'GitHub Connection', titlePath: '/connections/callback/github' }, + { id: 'github-app-callback', path: '/connections/github-app/callback', placement: 'root', component: 'GithubAppCallback', guard: 'private', title: 'GitHub Setup' }, + { id: 'public-status', path: '/status/:slug', placement: 'root', component: 'PublicStatusPage' }, + + { id: 'dashboard', path: '/', placement: 'dashboard', component: 'Dashboard', title: 'Dashboard', index: true }, + { id: 'services', path: '/services', placement: 'dashboard', group: 'services', component: 'Services', title: 'Services' }, + { id: 'new-service', path: '/services/new', placement: 'dashboard', group: 'services', component: 'NewService', title: 'New Service' }, + { id: 'templates', path: '/templates', placement: 'dashboard', group: 'services', component: 'Templates', title: 'Templates' }, + { id: 'deployments', path: '/deployments', placement: 'dashboard', group: 'services', component: 'Deployments', title: 'Deployment Activity' }, + { id: 'deploy-console', path: '/deployments/:jobId', placement: 'dashboard', group: 'services', component: 'DeployConsole', title: 'Deployment Activity' }, + { id: 'service-detail', path: '/services/:id', placement: 'dashboard', component: 'ServiceDetail', title: 'Services' }, + { id: 'service-detail-tab', path: '/services/:id/:tab', placement: 'dashboard', component: 'ServiceDetail', title: 'Services' }, + { id: 'service-detail-section', path: '/services/:id/:tab/:section', placement: 'dashboard', component: 'ServiceDetail', title: 'Services' }, + { id: 'imports', path: '/imports', placement: 'dashboard', component: 'ImportWizard', title: 'Import a Site' }, + { id: 'imports-coexistence', path: '/imports/coexistence', placement: 'dashboard', component: 'Coexistence', title: 'Running Alongside Another Panel' }, + { id: 'legacy-apps', path: '/apps', placement: 'dashboard', redirect: '/services' }, + { id: 'legacy-app-detail', path: '/apps/:id', placement: 'dashboard', legacyRedirect: 'apps' }, + { id: 'legacy-app-tab', path: '/apps/:id/:tab', placement: 'dashboard', legacyRedirect: 'apps' }, + { id: 'legacy-workflow', path: '/workflow', placement: 'dashboard', redirect: '/automations' }, + + { id: 'domains', path: '/domains', placement: 'dashboard', group: 'domains', component: 'Domains', title: 'Domains' }, + { id: 'ssl', path: '/ssl', placement: 'dashboard', group: 'domains', component: 'SSLCertificates', title: 'SSL Certificates' }, + { id: 'legacy-dns', path: '/dns', placement: 'dashboard', redirect: '/domains', title: 'DNS Zones' }, + { id: 'legacy-dynamic-dns', path: '/dynamic-dns', placement: 'dashboard', redirect: '/domains', title: 'Dynamic DNS' }, + { id: 'databases', path: '/databases', placement: 'dashboard', component: 'Databases', title: 'Databases' }, + { id: 'databases-tab', path: '/databases/:tab', placement: 'dashboard', component: 'Databases', title: 'Databases' }, + { id: 'docker', path: '/docker', placement: 'dashboard', component: 'Docker', title: 'Docker' }, + { id: 'docker-tab', path: '/docker/:tab', placement: 'dashboard', component: 'Docker', title: 'Docker' }, + + { id: 'servers', path: '/servers', placement: 'dashboard', group: 'servers', component: 'Servers', title: 'Servers' }, + { id: 'fleet', path: '/fleet', placement: 'dashboard', group: 'servers', component: 'AgentFleet', title: 'Agent Fleet' }, + { id: 'fleet-proxy', path: '/fleet-proxy', placement: 'dashboard', group: 'servers', component: 'FleetProxy', title: 'Fleet Proxy' }, + { id: 'server-templates', path: '/server-templates', placement: 'dashboard', group: 'servers', component: 'ServerTemplates', title: 'Server Templates' }, + { id: 'server-detail', path: '/servers/:id', placement: 'dashboard', component: 'ServerDetail', title: 'Servers' }, + { id: 'server-detail-tab', path: '/servers/:id/:tab', placement: 'dashboard', component: 'ServerDetail', title: 'Servers' }, + { id: 'legacy-agent-plugins', path: '/agent-plugins', placement: 'dashboard', redirect: '/extensions', title: 'Extensions' }, + + { id: 'projects', path: '/projects', placement: 'dashboard', group: 'organization', component: 'Projects', title: 'Projects' }, + { id: 'shared-variables', path: '/shared-variables', placement: 'dashboard', group: 'organization', component: 'SharedVariables', title: 'Shared Variables' }, + { id: 'vaults', path: '/vaults', placement: 'dashboard', group: 'organization', component: 'Vaults', title: 'Vaults' }, + { id: 'workspaces', path: '/workspaces', placement: 'dashboard', group: 'organization', component: 'Workspaces', title: 'Workspaces' }, + { id: 'project-detail', path: '/projects/:id', placement: 'dashboard', component: 'ProjectDetail', title: 'Projects' }, + { id: 'project-detail-tab', path: '/projects/:id/:tab', placement: 'dashboard', component: 'ProjectDetail', title: 'Projects' }, + { id: 'workspace-detail', path: '/workspaces/:id', placement: 'dashboard', component: 'WorkspaceDetail', title: 'Workspace' }, + { + id: 'workspace-detail-tab', path: '/workspaces/:id/:tab', placement: 'dashboard', component: 'WorkspaceDetail', title: 'Workspace', + titleByParam: { tab: { overview: 'Workspace Overview', servers: 'Workspace Servers', services: 'Workspace Services', sites: 'Workspace Sites', members: 'Workspace Members', settings: 'Workspace Settings' } }, + }, + { + id: 'workspace-detail-section', path: '/workspaces/:id/:tab/:section', placement: 'dashboard', component: 'WorkspaceDetail', title: 'Workspace', + titleByParam: { + tab: { settings: 'Workspace Settings' }, + section: { general: 'Workspace Settings', navigation: 'Workspace Navigation Permissions' }, + }, + }, + + { id: 'extensions', path: '/extensions', placement: 'dashboard', group: 'marketplace', component: 'Marketplace', title: 'Extensions' }, + { id: 'installed-extensions', path: '/extensions/installed', placement: 'dashboard', group: 'marketplace', component: 'Marketplace', title: 'Installed Extensions' }, + { id: 'downloads', path: '/downloads', placement: 'dashboard', group: 'marketplace', component: 'Downloads', title: 'Downloads' }, + { id: 'legacy-marketplace', path: '/marketplace', placement: 'dashboard', redirect: '/extensions' }, + { id: 'legacy-marketplace-installed', path: '/marketplace/installed', placement: 'dashboard', redirect: '/extensions/installed' }, + { id: 'style-guide', path: '/style-guide', placement: 'dashboard', component: 'StyleGuide', title: 'Style Guide' }, + { id: 'style-guide-tab', path: '/style-guide/:tab', placement: 'dashboard', component: 'StyleGuide', title: 'Style Guide' }, + { id: 'app-map', path: '/app-map', placement: 'dashboard', component: 'AppMap', title: 'App Map' }, + { id: 'app-map-tab', path: '/app-map/:tab', placement: 'dashboard', component: 'AppMap', title: 'App Map' }, + { id: 'documentation', path: '/documentation', placement: 'dashboard', component: 'Documentation', title: 'Documentation' }, + { id: 'legacy-firewall', path: '/firewall', placement: 'dashboard', redirect: '/security/firewall' }, + { id: 'legacy-git-extension', path: '/git-ext', placement: 'dashboard', legacyRedirect: 'git-extension' }, + { id: 'legacy-git-extension-tab', path: '/git-ext/:tab', placement: 'dashboard', legacyRedirect: 'git-extension' }, + + { id: 'files', path: '/files', placement: 'dashboard', group: 'files', component: 'FileManager', title: 'File Manager' }, + { id: 'monitoring', path: '/monitoring', placement: 'dashboard', group: 'monitoring', component: 'Monitoring', title: 'Monitoring' }, + { id: 'monitors', path: '/monitoring/monitors', placement: 'dashboard', group: 'monitoring', component: 'Monitors', title: 'Monitors' }, + { id: 'incidents', path: '/monitoring/incidents', placement: 'dashboard', group: 'monitoring', component: 'Incidents', title: 'Incidents' }, + { id: 'legacy-alerts', path: '/monitoring/alerts', placement: 'dashboard', group: 'monitoring', redirect: '/monitoring/incidents' }, + { id: 'legacy-fleet-alerts', path: '/monitoring/fleet-alerts', placement: 'dashboard', group: 'monitoring', redirect: '/monitoring/incidents' }, + { id: 'jobs', path: '/monitoring/jobs', placement: 'dashboard', group: 'monitoring', component: 'Jobs', title: 'Jobs' }, + { id: 'scheduled-jobs', path: '/monitoring/jobs/scheduled', placement: 'dashboard', group: 'monitoring', component: 'Jobs', title: 'Monitoring' }, + { id: 'errors', path: '/monitoring/errors', placement: 'dashboard', group: 'monitoring', component: 'Errors', title: 'Errors' }, + { id: 'monitoring-tab', path: '/monitoring/:tab', placement: 'dashboard', group: 'monitoring', component: 'Monitoring', title: 'Monitoring' }, + { id: 'telemetry', path: '/telemetry', placement: 'dashboard', group: 'monitoring', component: 'Telemetry', title: 'Telemetry' }, + { id: 'monitor-detail', path: '/monitoring/monitors/:monitorId', placement: 'dashboard', component: 'MonitorDetail', title: 'Monitoring' }, + { id: 'legacy-observability', path: '/observability', placement: 'dashboard', redirect: '/monitoring', title: 'Monitoring' }, + { id: 'legacy-fleet-monitor', path: '/fleet-monitor', placement: 'dashboard', redirect: '/monitoring' }, + + { id: 'backups', path: '/backups', placement: 'dashboard', group: 'backups', component: 'Backups', title: 'Backups' }, + { id: 'backups-tab', path: '/backups/:tab', placement: 'dashboard', group: 'backups', component: 'Backups', title: 'Backups' }, + { id: 'cron', path: '/cron', placement: 'dashboard', component: 'CronJobs', title: 'Cron Jobs' }, + { id: 'security', path: '/security', placement: 'dashboard', group: 'security', component: 'Security', title: 'Security' }, + { id: 'security-tab', path: '/security/:tab', placement: 'dashboard', group: 'security', component: 'Security', title: 'Security' }, + { id: 'terminal', path: '/terminal', placement: 'dashboard', component: 'Terminal', title: 'Terminal' }, + { id: 'legacy-terminal', path: '/terminal/terminal', placement: 'dashboard', redirect: '/terminal/shell' }, + { id: 'terminal-tab', path: '/terminal/:tab', placement: 'dashboard', component: 'Terminal', title: 'Terminal' }, + { id: 'legacy-webhooks', path: '/webhooks', placement: 'dashboard', redirect: '/settings/webhooks' }, + { id: 'legacy-secret-webhooks', path: '/secrets/webhooks', placement: 'dashboard', redirect: '/settings/webhooks' }, + { id: 'legacy-secrets', path: '/secrets', placement: 'dashboard', redirect: '/vaults' }, + { id: 'legacy-secrets-tab', path: '/secrets/:tab', placement: 'dashboard', redirect: '/vaults' }, + { id: 'queue', path: '/queue', placement: 'dashboard', component: 'QueueOperations', title: 'Queue Bus' }, + { id: 'test-sandbox', path: '/test-sandbox', placement: 'dashboard', component: 'TestSandbox', title: 'Test Sandbox', devOnly: true }, + { id: 'queue-detail', path: '/queue/:groupSlug/:queueSlug', placement: 'dashboard', component: 'QueueDetail', title: 'Queue Bus' }, + { id: 'notifications', path: '/notifications', placement: 'dashboard', component: 'Notifications', title: 'Notifications' }, + { id: 'notification-delivery-log', path: '/admin/notifications', placement: 'dashboard', component: 'DeliveryLog', title: 'Notification Delivery Log' }, + { id: 'legacy-jobs', path: '/jobs', placement: 'dashboard', redirect: '/monitoring/jobs', title: 'Jobs' }, + { id: 'legacy-scheduled-jobs', path: '/jobs/scheduled', placement: 'dashboard', redirect: '/monitoring/jobs/scheduled', title: 'Jobs' }, + { id: 'settings', path: '/settings', placement: 'dashboard', component: 'Settings', title: 'Settings' }, + { id: 'settings-tab', path: '/settings/:tab', placement: 'dashboard', component: 'Settings', title: 'Settings' }, + { id: 'not-found', path: '*', placement: 'dashboard', component: 'NotFound' }, +]); + +export const ROUTE_GROUP_IDS = Object.freeze([ + 'services', + 'domains', + 'servers', + 'organization', + 'marketplace', + 'files', + 'monitoring', + 'backups', + 'security', +]); + +export function routesForPlacement(placement) { + return CORE_ROUTES.filter((route) => route.placement === placement); +} + +export function routesForGroup(group) { + return CORE_ROUTES.filter((route) => route.group === group); +} + +function matchPattern(pattern, pathname) { + const patternParts = pattern.split('/').filter(Boolean); + const pathParts = pathname.split('/').filter(Boolean); + + if (pattern === '/' && pathname === '/') return { params: {}, score: 1000 }; + if (pattern === '*' || patternParts.length !== pathParts.length) return null; + + const params = {}; + let staticSegments = 0; + for (let index = 0; index < patternParts.length; index += 1) { + const expected = patternParts[index]; + const actual = pathParts[index]; + if (expected.startsWith(':')) { + params[expected.slice(1)] = actual; + } else if (expected === actual) { + staticSegments += 1; + } else { + return null; + } + } + + return { params, score: staticSegments * 100 - patternParts.length }; +} + +export function resolveCoreRouteTitle(pathname) { + let best = null; + + for (const route of CORE_ROUTES) { + if (!route.title) continue; + const titlePattern = route.titlePath || route.path; + const match = matchPattern(titlePattern, pathname); + if (!match || (best && match.score <= best.score)) continue; + + let title = route.title; + if (route.titleByParam) { + for (const [param, titles] of Object.entries(route.titleByParam)) { + title = titles[match.params[param]] || title; + } + } + best = { score: match.score, title }; + } + + return best?.title || ''; +} + +export function resolveExactCoreRouteTitle(pathname) { + const route = CORE_ROUTES.find((candidate) => { + const titlePath = candidate.titlePath || candidate.path; + return candidate.title && !titlePath.includes(':') && titlePath === pathname; + }); + return route?.title || ''; +}