Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/web/__tests__/lib/legacy-redirects_test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions apps/web/src/lib/legacy-redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { matchPath } from 'react-router';

type LegacyRedirectParams = Record<string, string | undefined>;

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;
}
17 changes: 17 additions & 0 deletions apps/web/src/routes.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'),
Expand All @@ -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;
17 changes: 17 additions & 0 deletions apps/web/src/routes/legacy-redirects.tsx
Original file line number Diff line number Diff line change
@@ -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 });
}