diff --git a/docs/external-redirects.md b/docs/external-redirects.md
new file mode 100644
index 0000000000..c84f8b1ae4
--- /dev/null
+++ b/docs/external-redirects.md
@@ -0,0 +1,23 @@
+# External Redirects
+
+The supported external redirect paths in the application.
+
+## Supported Redirects
+
+### Pipeline SDK Redirects
+
+Redirecting from Pipeline SDK output URLs to internal dashboard routes.
+
+#### Supported URL Patterns
+
+1. **Experiment Details**
+ ```
+ /external/pipelinesSdk/{namespace}/#/experiments/details/{experimentId}
+ ```
+ Redirects to the internal experiment runs route for the specified experiment.
+
+2. **Run Details**
+ ```
+ /external/pipelinesSdk/{namespace}/#/runs/details/{runId}
+ ```
+ Redirects to the internal pipeline run details route for the specified run.
diff --git a/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts b/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts
new file mode 100644
index 0000000000..2c8b8c9a54
--- /dev/null
+++ b/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts
@@ -0,0 +1,32 @@
+class ExternalRedirect {
+ visit(path: string) {
+ cy.visitWithLogin(path);
+ this.wait();
+ }
+
+ private wait() {
+ cy.findByTestId('redirect-error').should('not.exist');
+ cy.testA11y();
+ }
+
+ findErrorState() {
+ return cy.findByTestId('redirect-error');
+ }
+
+ findHomeButton() {
+ return cy.findByRole('button', { name: 'Go to Home' });
+ }
+}
+
+class PipelinesSdkRedirect {
+ findPipelinesButton() {
+ return cy.findByRole('button', { name: 'Go to Pipelines' });
+ }
+
+ findExperimentsButton() {
+ return cy.findByRole('button', { name: 'Go to Experiments' });
+ }
+}
+
+export const externalRedirect = new ExternalRedirect();
+export const pipelinesSdkRedirect = new PipelinesSdkRedirect();
diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts
new file mode 100644
index 0000000000..3bba564f64
--- /dev/null
+++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts
@@ -0,0 +1,41 @@
+import {
+ externalRedirect,
+ pipelinesSdkRedirect,
+} from '~/__tests__/cypress/cypress/pages/externalRedirect';
+
+describe('External Redirects', () => {
+ describe('Pipeline SDK Redirects', () => {
+ it('should redirect experiment URLs correctly', () => {
+ // Test experiment URL redirect
+ externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/experiments/details/123');
+ cy.url().should('include', '/experiments/test-namespace/123/runs');
+ });
+
+ it('should redirect run URLs correctly', () => {
+ // Test run URL redirect
+ externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/runs/details/456');
+ cy.url().should('include', '/pipelineRuns/test-namespace/runs/456');
+ });
+
+ it('should handle invalid URL format', () => {
+ externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/invalid');
+ externalRedirect.findErrorState().should('exist');
+ pipelinesSdkRedirect.findPipelinesButton().should('exist');
+ pipelinesSdkRedirect.findExperimentsButton().should('exist');
+ });
+ });
+
+ describe('External Redirect Not Found', () => {
+ it('should show not found page for invalid external routes', () => {
+ externalRedirect.visit('/external/invalid-path');
+ externalRedirect.findErrorState().should('exist');
+ externalRedirect.findHomeButton().should('exist');
+ });
+
+ it('should allow navigation back to home', () => {
+ externalRedirect.visit('/external/invalid-path');
+ externalRedirect.findHomeButton().click();
+ cy.url().should('include', '/');
+ });
+ });
+});
diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx
index 1749385da5..a20b057f1d 100644
--- a/frontend/src/app/AppRoutes.tsx
+++ b/frontend/src/app/AppRoutes.tsx
@@ -76,6 +76,8 @@ const StorageClassesPage = React.lazy(() => import('../pages/storageClasses/Stor
const ModelRegistryRoutes = React.lazy(() => import('../pages/modelRegistry/ModelRegistryRoutes'));
+const ExternalRoutes = React.lazy(() => import('../pages/external/ExternalRoutes'));
+
const AppRoutes: React.FC = () => {
const { isAdmin, isAllowed } = useUser();
const isJupyterEnabled = useCheckJupyterEnabled();
@@ -94,6 +96,7 @@ const AppRoutes: React.FC = () => {
}>
+ } />
{isHomeAvailable ? (
<>
} />
diff --git a/frontend/src/pages/ApplicationsPage.tsx b/frontend/src/pages/ApplicationsPage.tsx
index 64db623390..c3ff3de4c1 100644
--- a/frontend/src/pages/ApplicationsPage.tsx
+++ b/frontend/src/pages/ApplicationsPage.tsx
@@ -21,6 +21,7 @@ type ApplicationsPageProps = {
loaded: boolean;
empty: boolean;
loadError?: Error;
+ loadErrorPage?: React.ReactNode;
children?: React.ReactNode;
errorMessage?: string;
emptyMessage?: string;
@@ -41,6 +42,7 @@ const ApplicationsPage: React.FC = ({
loaded,
empty,
loadError,
+ loadErrorPage,
children,
errorMessage,
emptyMessage,
@@ -77,7 +79,7 @@ const ApplicationsPage: React.FC = ({
const renderContents = () => {
if (loadError) {
- return (
+ return !loadErrorPage ? (
= ({
{loadError.message}
+ ) : (
+ loadErrorPage
);
}
diff --git a/frontend/src/pages/external/ExternalRoutes.tsx b/frontend/src/pages/external/ExternalRoutes.tsx
new file mode 100644
index 0000000000..15fea35151
--- /dev/null
+++ b/frontend/src/pages/external/ExternalRoutes.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Route, Routes } from 'react-router-dom';
+import PipelinesSdkRedirect from './redirectComponents/PipelinesSdkRedirects';
+import ExternalRedirectNotFound from './redirectComponents/ExternalRedirectNotFound';
+
+/**
+ * Be sure to keep this file in sync with the documentation in `docs/external-redirects.md`.
+ */
+const ExternalRoutes: React.FC = () => (
+
+ } />
+ } />
+
+);
+
+export default ExternalRoutes;
diff --git a/frontend/src/pages/external/RedirectErrorState.tsx b/frontend/src/pages/external/RedirectErrorState.tsx
new file mode 100644
index 0000000000..456f6e2e69
--- /dev/null
+++ b/frontend/src/pages/external/RedirectErrorState.tsx
@@ -0,0 +1,71 @@
+import {
+ PageSection,
+ EmptyState,
+ EmptyStateVariant,
+ EmptyStateBody,
+ EmptyStateFooter,
+ EmptyStateActions,
+} from '@patternfly/react-core';
+import { ExclamationCircleIcon } from '@patternfly/react-icons';
+import React from 'react';
+
+type RedirectErrorStateProps = {
+ title?: string;
+ errorMessage?: string;
+ actions?: React.ReactNode | React.ReactNode[];
+};
+
+/**
+ * A component that displays an error state with optional title, message and actions
+ * Used for showing redirect/navigation errors with fallback options
+ *
+ * Props for the RedirectErrorState component
+ * @property {string} [title] - Optional title text to display in the error state
+ * @property {string} [errorMessage] - Optional error message to display
+ * @property {React.ReactNode | React.ReactNode[]} [actions] - Custom action buttons/elements to display
+ *
+ *
+ * @example
+ * ```tsx
+ * // With custom actions
+ *
+ *
+ *
+ * >
+ * }
+ * />
+ * ```
+ */
+
+const RedirectErrorState: React.FC = ({
+ title,
+ errorMessage,
+ actions,
+}) => (
+
+
+ {errorMessage && {errorMessage}}
+ {actions && (
+
+ {actions}
+
+ )}
+
+
+);
+
+export default RedirectErrorState;
diff --git a/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx b/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx
new file mode 100644
index 0000000000..b7e5c86325
--- /dev/null
+++ b/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx
@@ -0,0 +1,32 @@
+import { Button } from '@patternfly/react-core';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import ApplicationsPage from '~/pages/ApplicationsPage';
+import RedirectErrorState from '~/pages/external/RedirectErrorState';
+
+const ExternalRedirectNotFound: React.FC = () => {
+ const navigate = useNavigate();
+
+ return (
+
+
+ >
+ }
+ />
+ }
+ />
+ );
+};
+
+export default ExternalRedirectNotFound;
diff --git a/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx b/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx
new file mode 100644
index 0000000000..ce49fc8393
--- /dev/null
+++ b/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { useParams, useLocation, useNavigate } from 'react-router-dom';
+import { Button } from '@patternfly/react-core';
+import { experimentRunsRoute, globalPipelineRunDetailsRoute } from '~/routes';
+import ApplicationsPage from '~/pages/ApplicationsPage';
+import { useRedirect } from '~/utilities/useRedirect';
+import RedirectErrorState from '~/pages/external/RedirectErrorState';
+
+/**
+ * Handles redirects from Pipeline SDK URLs to internal routes.
+ *
+ * Matches and redirects:
+ * - Experiment URL: /external/pipelinesSdk/{namespace}/#/experiments/details/{experimentId}
+ * - Run URL: /external/pipelinesSdk/{namespace}/#/runs/details/{runId}
+ */
+const PipelinesSdkRedirects: React.FC = () => {
+ const { namespace } = useParams<{ namespace: string }>();
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const createRedirectPath = React.useCallback(() => {
+ if (!namespace) {
+ throw new Error('Missing namespace parameter');
+ }
+
+ // Extract experimentId from hash
+ const experimentMatch = location.hash.match(/\/experiments\/details\/([^/]+)$/);
+ if (experimentMatch) {
+ const experimentId = experimentMatch[1];
+ return experimentRunsRoute(namespace, experimentId);
+ }
+
+ // Extract runId from hash
+ const runMatch = location.hash.match(/\/runs\/details\/([^/]+)$/);
+ if (runMatch) {
+ const runId = runMatch[1];
+ return globalPipelineRunDetailsRoute(namespace, runId);
+ }
+
+ throw new Error('The URL format is invalid.');
+ }, [namespace, location.hash]);
+
+ const { error } = useRedirect(createRedirectPath);
+
+ return (
+
+
+
+ >
+ }
+ />
+ }
+ />
+ );
+};
+
+export default PipelinesSdkRedirects;
diff --git a/frontend/src/utilities/__tests__/useRedirect.spec.ts b/frontend/src/utilities/__tests__/useRedirect.spec.ts
new file mode 100644
index 0000000000..c2063abcaa
--- /dev/null
+++ b/frontend/src/utilities/__tests__/useRedirect.spec.ts
@@ -0,0 +1,123 @@
+import { testHook } from '~/__tests__/unit/testUtils/hooks';
+import { useRedirect } from '~/utilities/useRedirect';
+
+const mockNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+describe('useRedirect', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should handle successful redirect', async () => {
+ const createRedirectPath = jest.fn().mockReturnValue('/success-path');
+ const onComplete = jest.fn();
+ const renderResult = testHook(useRedirect)(createRedirectPath, { onComplete });
+
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(createRedirectPath).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith('/success-path', undefined);
+ expect(onComplete).toHaveBeenCalled();
+ expect(state.loaded).toBe(true);
+ expect(state.error).toBeUndefined();
+ });
+
+ it('should handle async redirect path creation', async () => {
+ const createRedirectPath = jest.fn().mockResolvedValue('/async-path');
+ const renderResult = testHook(useRedirect)(createRedirectPath);
+
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(createRedirectPath).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith('/async-path', undefined);
+ expect(state.loaded).toBe(true);
+ expect(state.error).toBeUndefined();
+ });
+
+ it('should handle redirect with navigation options', async () => {
+ const createRedirectPath = jest.fn().mockReturnValue('/path');
+ const navigateOptions = { replace: true };
+ const renderResult = testHook(useRedirect)(createRedirectPath, { navigateOptions });
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(mockNavigate).toHaveBeenCalledWith('/path', navigateOptions);
+ });
+
+ it('should handle error when path is undefined', async () => {
+ const createRedirectPath = jest.fn().mockRejectedValue(new Error('No path available'));
+ const onError = jest.fn();
+ const renderResult = testHook(useRedirect)(createRedirectPath, { onError });
+
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
+ expect(state.loaded).toBe(true);
+ expect(state.error).toBeInstanceOf(Error);
+ });
+
+ it('should handle error in path creation', async () => {
+ const error = new Error('Failed to create path');
+ const createRedirectPath = jest.fn().mockRejectedValue(error);
+ const onError = jest.fn();
+ const renderResult = testHook(useRedirect)(createRedirectPath, { onError });
+
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(onError).toHaveBeenCalledWith(error);
+ expect(state.loaded).toBe(true);
+ expect(state.error).toBe(error);
+ });
+
+ it('should not redirect to not-found when notFoundOnError is false', async () => {
+ const createRedirectPath = jest.fn().mockRejectedValue(new Error());
+ const renderResult = testHook(useRedirect)(createRedirectPath);
+
+ let state = renderResult.result.current;
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBeUndefined();
+
+ await renderResult.waitForNextUpdate();
+ state = renderResult.result.current;
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+
+ expect(state.loaded).toBe(true);
+ expect(state.error).toBeInstanceOf(Error);
+ });
+
+ it('should be stable', () => {
+ const createRedirectPath = jest.fn().mockReturnValue('/path');
+ const renderResult = testHook(useRedirect)(createRedirectPath);
+ renderResult.rerender(createRedirectPath);
+ expect(renderResult).hookToBeStable({ loaded: true, error: undefined });
+ expect(renderResult).hookToHaveUpdateCount(3);
+ });
+});
diff --git a/frontend/src/utilities/useRedirect.ts b/frontend/src/utilities/useRedirect.ts
new file mode 100644
index 0000000000..921ab748c5
--- /dev/null
+++ b/frontend/src/utilities/useRedirect.ts
@@ -0,0 +1,91 @@
+import { NavigateOptions, useNavigate } from 'react-router-dom';
+import React from 'react';
+
+export type RedirectState = {
+ loaded: boolean;
+ error?: Error;
+};
+
+export type RedirectOptions = {
+ /** Additional options for the navigate function */
+ navigateOptions?: NavigateOptions;
+ /** Callback when redirect is complete */
+ onComplete?: () => void;
+ /** Callback when redirect fails */
+ onError?: (error: Error) => void;
+};
+
+/**
+ * Hook for managing redirects with loading states. Automatically redirects on mount.
+ * @param createRedirectPath Function that creates the redirect path, can be async for data fetching
+ * @param options Redirect options
+ * @returns Redirect state object containing loading and error states
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ * const { loaded, error } = useRedirect(() => '/foo');
+ *
+ * // With async path creation
+ * const { loaded, error } = useRedirect(async () => {
+ * const data = await fetchData();
+ * return `/bar/${data.id}`;
+ * });
+ *
+ * // With options
+ * const { loaded, error } = useRedirect(() => '/foobar', {
+ * navigateOptions: { replace: true },
+ * onComplete: () => console.log('Redirected'),
+ * onError: (error) => console.error(error)
+ * });
+ *
+ * // Usage in a component
+ * const createRedirectPath = React.useCallback(() => '/some/path', []);
+ *
+ * const { loaded, error } = useRedirect(createRedirectPath);
+ *
+ * return (
+ * navigate('/foo/bar')}>Go to Home}
+ * />}
+ * />
+ * );
+ * ```
+ */
+export const useRedirect = (
+ createRedirectPath: () => string | Promise,
+ options: RedirectOptions = {},
+): RedirectState => {
+ const { navigateOptions, onComplete, onError } = options;
+ const navigate = useNavigate();
+ const [state, setState] = React.useState({
+ loaded: false,
+ error: undefined,
+ });
+
+ React.useEffect(() => {
+ const performRedirect = async () => {
+ try {
+ setState({ loaded: false, error: undefined });
+ const path = await createRedirectPath();
+ navigate(path, navigateOptions);
+ setState({ loaded: true, error: undefined });
+ onComplete?.();
+ } catch (e) {
+ const error = e instanceof Error ? e : new Error('Failed to redirect');
+ setState({ loaded: true, error });
+ onError?.(error);
+ }
+ };
+
+ performRedirect();
+ }, [createRedirectPath, navigate, navigateOptions, onComplete, onError]);
+
+ return state;
+};