refactor(frontend): centralize route manifest

This commit is contained in:
Juan Denis committed 2026-08-18 03:10:29 -04:00
1 parent b5c6efb3c3
commit ec3b9bb6f9
4 files changed
+416 -372

No files matched your search

+83 -372
View File
@@ -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 <Navigate to={tab ? `/git/${tab}` : '/git'} replace />;
}
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 = <Navigate to={route.redirect} replace />;
} else if (route.legacyRedirect === 'apps') {
element = <LegacyAppRedirect />;
} else if (route.legacyRedirect === 'git-extension') {
element = <LegacyGitExtRedirect />;
} else {
const Component = ROUTE_COMPONENTS[route.component];
element = (
<Suspense fallback={<AppLoader />}>
<Component />
</Suspense>
);
}
if (route.devOnly) element = <DevOnlyRoute>{element}</DevOnlyRoute>;
if (route.guard === 'setup') element = <SetupRoute>{element}</SetupRoute>;
if (route.guard === 'public') element = <PublicRoute>{element}</PublicRoute>;
if (route.guard === 'private') element = <PrivateRoute>{element}</PrivateRoute>;
return element;
}
function renderManifestRoute(route) {
return (
<Route
key={route.id}
index={route.index || undefined}
path={route.index ? undefined : route.path}
element={<ManifestRouteElement route={route} />}
/>
);
}
function AppRoutes() {
const { dashboardRoutes, groupRoutes, standaloneGroups } = useExtensionRoutes();
return (
<Routes>
<Route path="/migrate" element={<DatabaseMigration />} />
<Route path="/setup" element={
<SetupRoute>
<Setup />
</SetupRoute>
} />
<Route path="/login" element={
<PublicRoute>
<Login />
</PublicRoute>
} />
<Route path="/login/callback/:provider" element={
<PublicRoute>
<SSOCallback />
</PublicRoute>
} />
<Route path="/register" element={
<PublicRoute>
<Register />
</PublicRoute>
} />
<Route path="/connections/callback/:provider" element={
<PrivateRoute>
<SourceConnectionCallback />
</PrivateRoute>
} />
<Route path="/connections/github-app/callback" element={
<PrivateRoute>
<GithubAppCallback />
</PrivateRoute>
} />
<Route path="/status/:slug" element={<PublicStatusPage />} />
{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() {
<DashboardLayout />
</PrivateRoute>
}>
<Route index element={<Dashboard />} />
{/* 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. */}
<Route element={<TabGroupLayout tabs={SERVICE_TABS} />}>
<Route path="services" element={<Services />} />
<Route path="services/new" element={<NewService />} />
<Route path="templates" element={<Templates />} />
<Route path="deployments" element={<Deployments />} />
<Route path="deployments/:jobId" element={<DeployConsole />} />
</Route>
<Route path="services/:id" element={<ServiceDetail />} />
<Route path="services/:id/:tab" element={<ServiceDetail />} />
{/* Settings sub-section in the URL (e.g. .../settings/git)
so the Settings left-nav is shareable and survives a refresh. */}
<Route path="services/:id/:tab/:section" element={<ServiceDetail />} />
<Route path="imports" element={<ImportWizard />} />
<Route path="imports/coexistence" element={<Coexistence />} />
<Route path="apps" element={<Navigate to="/services" replace />} />
<Route path="apps/:id" element={<LegacyAppRedirect />} />
<Route path="apps/:id/:tab" element={<LegacyAppRedirect />} />
{/* 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. */}
<Route path="workflow" element={<Navigate to="/automations" replace />} />
<Route element={<TabGroupLayout tabs={DOMAIN_TABS} />}>
<Route path="domains" element={<Domains />} />
<Route path="ssl" element={<SSLCertificates />} />
</Route>
{/* 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. */}
<Route path="dns" element={<Navigate to="/domains" replace />} />
<Route path="dynamic-dns" element={<Navigate to="/domains" replace />} />
{/* 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. */}
<Route path="databases" element={<Databases />} />
<Route path="databases/:tab" element={<Databases />} />
<Route path="docker" element={<Docker />} />
<Route path="docker/:tab" element={<Docker />} />
{/* /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). */}
<Route element={<TabGroupLayout tabs={SERVER_TABS} groupId="servers" />}>
<Route path="servers" element={<Servers />} />
<Route path="fleet" element={<AgentFleet />} />
<Route path="fleet-proxy" element={<FleetProxy />} />
<Route path="server-templates" element={<ServerTemplates />} />
{groupRoutes.servers}
</Route>
<Route path="servers/:id" element={<ServerDetail />} />
<Route path="servers/:id/:tab" element={<ServerDetail />} />
<Route path="agent-plugins" element={<Navigate to="/extensions" replace />} />
{/* Organization tab group — Projects / Shared Variables /
Workspaces share one PageTopbar + tabs (ORG_TABS) instead of
a collapsible sidebar sub-menu. Detail routes stay outside. */}
<Route element={<TabGroupLayout tabs={ORG_TABS} />}>
<Route path="projects" element={<Projects />} />
<Route path="shared-variables" element={<SharedVariables />} />
<Route path="vaults" element={<Vaults />} />
<Route path="workspaces" element={<Workspaces />} />
</Route>
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="projects/:id/:tab" element={<ProjectDetail />} />
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
<Route path="workspaces/:id/:tab" element={<WorkspaceDetail />} />
<Route path="workspaces/:id/:tab/:section" element={<WorkspaceDetail />} />
<Route element={<TabGroupLayout tabs={MARKET_TABS} />}>
<Route path="extensions" element={<Marketplace />} />
<Route path="extensions/installed" element={<Marketplace />} />
<Route path="downloads" element={<Downloads />} />
</Route>
{/* Old /marketplace paths redirect to /extensions (renamed for clarity). */}
<Route path="marketplace" element={<Navigate to="/extensions" replace />} />
<Route path="marketplace/installed" element={<Navigate to="/extensions/installed" replace />} />
<Route path="style-guide" element={<StyleGuide />} />
<Route path="style-guide/:tab" element={<StyleGuide />} />
<Route path="app-map" element={<AppMap />} />
<Route path="app-map/:tab" element={<AppMap />} />
<Route path="documentation" element={<Documentation />} />
<Route path="firewall" element={<Navigate to="/security/firewall" replace />} />
<Route path="git-ext" element={<LegacyGitExtRedirect />} />
<Route path="git-ext/:tab" element={<LegacyGitExtRedirect />} />
{/* /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. */}
<Route element={<TabGroupLayout tabs={FILE_TABS} groupId="files" />}>
<Route path="files" element={<FileManager />} />
{groupRoutes.files}
</Route>
{/* 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. */}
<Route element={<TabGroupLayout tabs={MONITOR_TABS} groupId="monitoring" />}>
<Route path="monitoring" element={<Monitoring />} />
{/* Static segments outrank the :tab catch-all below, so these
keep their own pages while /monitoring/rules etc. still
land on <Monitoring/>. */}
<Route path="monitoring/monitors" element={<Monitors />} />
<Route path="monitoring/incidents" element={<Incidents />} />
{/* Alerts merged into Incidents — a host threshold crossing
and a monitor going down are the same question. */}
<Route path="monitoring/alerts" element={<Navigate to="/monitoring/incidents" replace />} />
<Route path="monitoring/fleet-alerts" element={<Navigate to="/monitoring/incidents" replace />} />
{/* 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). */}
<Route path="monitoring/jobs" element={<Jobs />} />
<Route path="monitoring/jobs/scheduled" element={<Jobs />} />
{/* Error tracker: same class of thing as Events and Jobs —
deduplicated backend/frontend exceptions. */}
<Route path="monitoring/errors" element={<Errors />} />
<Route path="monitoring/:tab" element={<Monitoring />} />
<Route path="telemetry" element={<Telemetry />} />
{groupRoutes.monitoring}
</Route>
{/* Outside the tab group: a single monitor is a drill-down with
its own breadcrumb top bar, like /queue/:group/:queue. */}
<Route path="monitoring/monitors/:monitorId" element={<MonitorDetail />} />
<Route path="observability" element={<Navigate to="/monitoring" replace />} />
{/* 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. */}
<Route path="fleet-monitor" element={<Navigate to="/monitoring" replace />} />
{/* /gpu is contributed by the standalone serverkit-gpu extension. */}
<Route element={<TabGroupLayout tabs={BACKUP_TABS} />}>
<Route path="backups" element={<Backups />} />
<Route path="backups/:tab" element={<Backups />} />
</Route>
<Route path="cron" element={<CronJobs />} />
{/* 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. */}
<Route element={<TabGroupLayout tabs={SECURITY_TABS} groupId="security" />}>
<Route path="security" element={<Security />} />
<Route path="security/:tab" element={<Security />} />
{groupRoutes.security}
</Route>
{/* 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). */}
<Route path="terminal" element={<Terminal />} />
<Route path="terminal/terminal" element={<Navigate to="/terminal/shell" replace />} />
<Route path="terminal/:tab" element={<Terminal />} />
{/* 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. */}
<Route path="webhooks" element={<Navigate to="/settings/webhooks" replace />} />
<Route path="secrets/webhooks" element={<Navigate to="/settings/webhooks" replace />} />
<Route path="secrets" element={<Navigate to="/vaults" replace />} />
<Route path="secrets/:tab" element={<Navigate to="/vaults" replace />} />
<Route path="queue" element={<QueueOperations />} />
{/* Developer tooling, not an operator surface — see the
matching requiresCondition on the sidebar item. */}
<Route path="test-sandbox" element={<DevOnlyRoute><TestSandbox /></DevOnlyRoute>} />
<Route path="queue/:groupSlug/:queueSlug" element={<QueueDetail />} />
<Route path="notifications" element={<Notifications />} />
<Route path="admin/notifications" element={<DeliveryLog />} />
{/* Jobs is a Monitoring tab now; keep old links working. */}
<Route path="jobs" element={<Navigate to="/monitoring/jobs" replace />} />
<Route path="jobs/scheduled" element={<Navigate to="/monitoring/jobs/scheduled" replace />} />
<Route path="settings" element={<Settings />} />
<Route path="settings/:tab" element={<Settings />} />
{ROUTE_GROUP_IDS.map((group) => {
const layout = ROUTE_GROUP_LAYOUTS[group];
return (
<Route
key={`group:${group}`}
element={<TabGroupLayout tabs={layout.tabs} groupId={layout.groupId} />}
>
{routesForGroup(group).map(renderManifestRoute)}
{EXTENSION_ROUTE_GROUPS.has(group) && groupRoutes[group]}
</Route>
);
})}
{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. */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
@@ -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());
});
+62
View File
@@ -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')),
});
+209
View File
@@ -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<string, Record<string, string>>=} titleByParam Title overrides keyed by parameter.
*/
/** @type {ReadonlyArray<Readonly<CoreRoute>>} */
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 || '';
}