mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
refactor(frontend): centralize route manifest
This commit is contained in:
1 parent
b5c6efb3c3
commit
ec3b9bb6f9
4 files changed
+416
-372
No files matched your search
+83
-372
@@ -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());
|
||||
});
|
||||
@@ -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')),
|
||||
});
|
||||
@@ -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 || '';
|
||||
}
|
||||
Reference in new issue
Block a user