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 || '';
+}