diff --git a/apps/web/__tests__/lib/legacy-redirects_test.ts b/apps/web/__tests__/lib/legacy-redirects_test.ts new file mode 100644 index 0000000000..a42d1b42ab --- /dev/null +++ b/apps/web/__tests__/lib/legacy-redirects_test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { legacyRedirectTarget } from '../../src/lib/legacy-redirects'; + +describe('legacy dashboard route redirects', () => { + it.each([ + ['/login', '/'], + ['/dashboard/keys', '/dashboard/services/api-keys'], + ['/dashboard/models', '/dashboard/playground'], + ['/dashboard/performance', '/dashboard/monitor/performance'], + ['/dashboard/requests', '/dashboard/monitor/requests'], + ['/dashboard/upstreams', '/dashboard/providers/upstreams'], + ['/dashboard/upstreams/new', '/dashboard/providers/upstreams'], + ['/dashboard/usage', '/dashboard/monitor/usage'], + ['/dashboard/users', '/dashboard/admin/users'], + ])('redirects %s to %s', (from, to) => { + expect(legacyRedirectTarget(from)).toBe(to); + }); + + it('redirects a legacy request key to the requests monitor key search param', () => { + expect(legacyRedirectTarget('/dashboard/requests/key-123')).toBe('/dashboard/monitor/requests?key=key-123'); + }); + + it('preserves a legacy request record hash as the record search param', () => { + expect(legacyRedirectTarget('/dashboard/requests/key-123', '#record-9')).toBe('/dashboard/monitor/requests?key=key-123&record=record-9'); + expect(legacyRedirectTarget('/dashboard/requests/key-123', '#record%209')).toBe('/dashboard/monitor/requests?key=key-123&record=record%209'); + }); + + it('redirects legacy upstream routes to the provider upstream routes', () => { + expect(legacyRedirectTarget('/dashboard/upstreams/up_abc')).toBe('/dashboard/providers/upstreams/up_abc'); + expect(legacyRedirectTarget('/dashboard/upstreams/new/custom')).toBe('/dashboard/providers/upstreams/new/custom'); + }); + + it('encodes path params and record hashes in the redirect target', () => { + expect(legacyRedirectTarget('/dashboard/requests/key%201')).toBe('/dashboard/monitor/requests?key=key%201'); + expect(legacyRedirectTarget('/dashboard/upstreams/new/a%2Fb')).toBe('/dashboard/providers/upstreams/new/a%2Fb'); + }); + + it('treats trailing slashes as the same legacy route', () => { + expect(legacyRedirectTarget('/login/')).toBe('/'); + expect(legacyRedirectTarget('/dashboard/keys/')).toBe('/dashboard/services/api-keys'); + expect(legacyRedirectTarget('/dashboard/requests/key-123/')).toBe('/dashboard/monitor/requests?key=key-123'); + }); + + it('returns null for paths that are not legacy routes', () => { + expect(legacyRedirectTarget('/dashboard/settings')).toBeNull(); + expect(legacyRedirectTarget('/dashboard/unknown')).toBeNull(); + expect(legacyRedirectTarget('/')).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/legacy-redirects.ts b/apps/web/src/lib/legacy-redirects.ts new file mode 100644 index 0000000000..27695ae62f --- /dev/null +++ b/apps/web/src/lib/legacy-redirects.ts @@ -0,0 +1,71 @@ +import { matchPath } from 'react-router'; + +type LegacyRedirectParams = Record; + +interface LegacyRedirectRule { + from: string; + to: (params: LegacyRedirectParams, hash: string) => string; +} + +const decodeParam = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + // Legacy routes were exposed through a browser router; an undecodable + // segment is still passed through so the redirect keeps the original. + return value; + } +}; + +const recordSearch = (hash: string): string => { + if (!hash.startsWith('#')) return ''; + const recordId = hash.slice(1); + let decodedRecordId = recordId; + try { + decodedRecordId = decodeURIComponent(recordId); + } catch { + // Legacy hashes were written URL-encoded; an undecodable hash is still + // passed through so the redirect keeps the original value. + } + return `&record=${encodeURIComponent(decodedRecordId)}`; +}; + +// One rule per legacy route. `from` is the absolute path pattern the old +// Vue router exposed; `to` builds the current SPA address for it. Keep this +// list ordered so static paths are tried before their parameterized +// neighbours (e.g. `/dashboard/upstreams/new` before `/dashboard/upstreams/:id`). +export const LEGACY_REDIRECT_RULES: LegacyRedirectRule[] = [ + { from: '/login', to: () => '/' }, + { from: '/dashboard/keys', to: () => '/dashboard/services/api-keys' }, + { from: '/dashboard/models', to: () => '/dashboard/playground' }, + { from: '/dashboard/performance', to: () => '/dashboard/monitor/performance' }, + { from: '/dashboard/requests', to: () => '/dashboard/monitor/requests' }, + { + from: '/dashboard/requests/:keyId', + to: ({ keyId = '' }, hash) => + `/dashboard/monitor/requests?key=${encodeURIComponent(decodeParam(keyId))}${recordSearch(hash)}`, + }, + { from: '/dashboard/upstreams', to: () => '/dashboard/providers/upstreams' }, + { from: '/dashboard/upstreams/new', to: () => '/dashboard/providers/upstreams' }, + { + from: '/dashboard/upstreams/new/:provider', + to: ({ provider = '' }) => `/dashboard/providers/upstreams/new/${encodeURIComponent(decodeParam(provider))}`, + }, + { + from: '/dashboard/upstreams/:id', + to: ({ id = '' }) => `/dashboard/providers/upstreams/${encodeURIComponent(decodeParam(id))}`, + }, + { from: '/dashboard/usage', to: () => '/dashboard/monitor/usage' }, + { from: '/dashboard/users', to: () => '/dashboard/admin/users' }, +]; + +export function legacyRedirectTarget(pathname: string, hash = ''): string | null { + const normalized = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + + for (const rule of LEGACY_REDIRECT_RULES) { + const match = matchPath({ path: rule.from, end: true }, normalized); + if (match) return rule.to(match.params, hash); + } + + return null; +} diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index dee15a6437..2bc2d562ed 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -1,5 +1,20 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'; +import { LEGACY_REDIRECT_RULES } from './lib/legacy-redirects'; + +const legacyRedirectRoute = (path: string) => + route(path, 'routes/legacy-redirects.tsx', { id: `routes/legacy-redirects-${path.replace(/[^\w]+/g, '-')}` }); + +const rootLegacyRoutes = LEGACY_REDIRECT_RULES + .map(rule => rule.from) + .filter(path => !path.startsWith('/dashboard/')) + .map(path => legacyRedirectRoute(path.replace(/^\//, ''))); + +const dashboardLegacyRoutes = LEGACY_REDIRECT_RULES + .map(rule => rule.from) + .filter(path => path.startsWith('/dashboard/')) + .map(path => legacyRedirectRoute(path.slice('/dashboard/'.length))); + // The gallery renders every Fluent control the dashboard uses so the WinUI // layer can be judged in one place. It is scaffolding, not a product surface: // no navigation entry, no translations, and its copy is English placeholder @@ -22,6 +37,7 @@ const developmentRoutes = export default [ index('routes/home.tsx'), + ...rootLegacyRoutes, route('dashboard', 'routes/dashboard.tsx', [ index('routes/dashboard-index.tsx'), route('playground', 'routes/dashboard-playground.tsx'), @@ -39,6 +55,7 @@ export default [ route('admin/users', 'routes/dashboard-admin-users.tsx'), route('admin/backup-restore', 'routes/dashboard-admin-backup-restore.tsx'), route('settings', 'routes/dashboard-settings.tsx'), + ...dashboardLegacyRoutes, ...developmentRoutes, ]), ] satisfies RouteConfig; diff --git a/apps/web/src/routes/legacy-redirects.tsx b/apps/web/src/routes/legacy-redirects.tsx new file mode 100644 index 0000000000..a352269566 --- /dev/null +++ b/apps/web/src/routes/legacy-redirects.tsx @@ -0,0 +1,17 @@ +import { redirect } from 'react-router'; + +import type { Route } from './+types/legacy-redirects'; +import { legacyRedirectTarget } from '../lib/legacy-redirects'; + +export async function clientLoader({ request }: Route.ClientLoaderArgs) { + const url = new URL(request.url); + // React Router builds the loader Request from the URL with the hash + // stripped, so a legacy record deep link would otherwise lose its record + // id. Read the hash from the browser location, where it still is while the + // redirect is being resolved. + const hash = typeof window !== 'undefined' ? window.location.hash : url.hash; + + const redirectTo = legacyRedirectTarget(url.pathname, hash); + if (redirectTo) throw redirect(redirectTo); + throw new Response('Not Found', { status: 404 }); +}