diff --git a/packages/flyte-api/package.json b/packages/flyte-api/package.json index e236fac74..6ddf514a4 100644 --- a/packages/flyte-api/package.json +++ b/packages/flyte-api/package.json @@ -26,7 +26,7 @@ "tslib": "^2.4.1" }, "dependencies": { - "axios": "^0.27.2", + "axios": "^1.7.1", "camelcase-keys": "^7.0.2", "snakecase-keys": "^5.4.2" } diff --git a/packages/oss-console/package.json b/packages/oss-console/package.json index df8d6a6d7..89b9f247c 100644 --- a/packages/oss-console/package.json +++ b/packages/oss-console/package.json @@ -63,7 +63,7 @@ "@tanstack/react-virtual": "^3.0.0-alpha.0", "@types/d3-shape": "^1.2.6", "@xstate/react": "^1.0.0", - "axios": "^0.27.2", + "axios": "^1.7.1", "copy-to-clipboard": "^3.0.8", "cronstrue": "^1.31.0", "d3-dag": "^0.3.4", diff --git a/packages/oss-console/src/App/ApplicationRouter.tsx b/packages/oss-console/src/App/ApplicationRouter.tsx index 0ae0fd3de..152ed418c 100644 --- a/packages/oss-console/src/App/ApplicationRouter.tsx +++ b/packages/oss-console/src/App/ApplicationRouter.tsx @@ -13,34 +13,52 @@ import { PrettyError } from '../components/Errors/PrettyError'; import { useDomainPathUpgrade } from '../routes/useDomainPathUpgrade'; import { Routes } from '../routes/routes'; import AnimateRoute from '../routes/AnimateRoute'; +import { loadChunkWithAuth } from '../components/data/loadChunkWithAuth'; -const SelectProject = lazy( - () => import(/* webpackChunkName: "SelectProject" */ '../components/SelectProject'), +const SelectProject = lazy(() => + loadChunkWithAuth( + () => import(/* webpackChunkName: "SelectProject" */ '../components/SelectProject'), + ), ); -const ListProjectEntities = lazy( - () => import(/* webpackChunkName: "ListProjectEntities" */ '../components/ListProjectEntities'), +const ListProjectEntities = lazy(() => + loadChunkWithAuth( + () => import(/* webpackChunkName: "ListProjectEntities" */ '../components/ListProjectEntities'), + ), ); -const ExecutionDetails = lazy( - () => - import(/* webpackChunkName: "ExecutionDetails" */ '../components/Executions/ExecutionDetails'), +const ExecutionDetails = lazy(() => + loadChunkWithAuth( + () => + import( + /* webpackChunkName: "ExecutionDetails" */ '../components/Executions/ExecutionDetails' + ), + ), ); -const TaskDetails = lazy(() => import(/* webpackChunkName: "TaskDetails" */ '../components/Task')); -const WorkflowDetails = lazy( - () => import(/* webpackChunkName: "WorkflowDetails" */ '../components/Workflow/WorkflowDetails'), +const TaskDetails = lazy(() => + loadChunkWithAuth(() => import(/* webpackChunkName: "TaskDetails" */ '../components/Task')), ); -const LaunchPlanDetails = lazy( - () => - import( - /* webpackChunkName: "LaunchPlanDetails" */ '../components/LaunchPlan/LaunchPlanDetails' - ), +const WorkflowDetails = lazy(() => + loadChunkWithAuth( + () => + import(/* webpackChunkName: "WorkflowDetails" */ '../components/Workflow/WorkflowDetails'), + ), ); -const EntityVersionsDetailsContainer = lazy( - () => - import( - /* webpackChunkName: "EntityVersionsDetailsContainer" */ '../components/Entities/VersionDetails/EntityVersionDetailsContainer' - ), +const LaunchPlanDetails = lazy(() => + loadChunkWithAuth( + () => + import( + /* webpackChunkName: "LaunchPlanDetails" */ '../components/LaunchPlan/LaunchPlanDetails' + ), + ), +); +const EntityVersionsDetailsContainer = lazy(() => + loadChunkWithAuth( + () => + import( + /* webpackChunkName: "EntityVersionsDetailsContainer" */ '../components/Entities/VersionDetails/EntityVersionDetailsContainer' + ), + ), ); export const ApplicationRouter: React.FC = () => { diff --git a/packages/oss-console/src/App/index.tsx b/packages/oss-console/src/App/index.tsx index b8b4fbee5..9cd49421a 100644 --- a/packages/oss-console/src/App/index.tsx +++ b/packages/oss-console/src/App/index.tsx @@ -16,7 +16,6 @@ import { FeatureFlagsProvider } from '../basics/FeatureFlags'; import { debug, debugPrefix } from '../common/log'; import { APIContext, useAPIState } from '../components/data/apiContext'; import { QueryAuthorizationObserver } from '../components/data/QueryAuthorizationObserver'; -import { createQueryClient } from '../components/data/queryCache'; import { SystemStatusBanner } from '../components/Notifications/SystemStatusBanner'; import { history } from '../routes/history'; import { LocalCacheProvider } from '../basics/LocalCache/ContextProvider'; @@ -30,12 +29,11 @@ import TopLevelLayoutProvider from '../components/common/TopLevelLayout/TopLevel import ApplicationRouter from './ApplicationRouter'; import { ErrorBoundary } from '../components/common/ErrorBoundary'; import DownForMaintenance from '../components/Errors/DownForMaintenance'; +import { globalQueryClient } from '../components/data/globalQueryClient'; const QueryClientProvider: React.FC> = QueryClientProviderImport; -const queryClient = createQueryClient(); - export const AppComponent: React.FC = () => { if (env.NODE_ENV === 'development') { debug.enable(`${debugPrefix}*:*`); @@ -61,7 +59,7 @@ export const AppComponent: React.FC = () => { anchorOrigin={{ vertical: 'top', horizontal: 'right' }} TransitionComponent={Collapse} > - + diff --git a/packages/oss-console/src/components/Breadcrumbs/registry/index.ts b/packages/oss-console/src/components/Breadcrumbs/registry/index.ts index 386a3e07c..17357ea90 100644 --- a/packages/oss-console/src/components/Breadcrumbs/registry/index.ts +++ b/packages/oss-console/src/components/Breadcrumbs/registry/index.ts @@ -106,7 +106,7 @@ export class BreadcrumbRegistry { static makeUrlSegments(location: Location, projectId = '', domainId = '') { const pathName = location.pathname; - const basePath = process.env.BASE_PATH || '/console'; + const basePath = process.env.BASE_URL || '/console'; // Remove first occurence of base path const pathNameWithoutBasePath = pathName.replace(basePath, ''); diff --git a/packages/oss-console/src/components/Entities/EntityDetails.tsx b/packages/oss-console/src/components/Entities/EntityDetails.tsx index b914ba23b..fd00452e1 100644 --- a/packages/oss-console/src/components/Entities/EntityDetails.tsx +++ b/packages/oss-console/src/components/Entities/EntityDetails.tsx @@ -23,6 +23,7 @@ import { EntityExecutions } from './EntityExecutions'; import { EntityVersions } from './EntityVersions'; import { executionFilterGenerator } from './generators'; import { executionSortFields } from '../../models/Execution/constants'; +import { EntitySchedules } from './EntitySchedules'; const EntityDetailsContainer = styled(Grid)(({ theme }) => ({ minHeight: '100vh', @@ -123,12 +124,18 @@ export const EntityDetails: React.FC = ({ id }) => { paddingRight: (theme) => theme.spacing(2), }} > - {sections.description && ( + {!!sections.description && ( )} + {!!sections.schedules && ( + + + + )} + {!!sections.inputs && ( diff --git a/packages/oss-console/src/components/Entities/constants.ts b/packages/oss-console/src/components/Entities/constants.ts index c3b5a3bf1..950ed3ca9 100644 --- a/packages/oss-console/src/components/Entities/constants.ts +++ b/packages/oss-console/src/components/Entities/constants.ts @@ -36,7 +36,7 @@ export const entitySections: { [k in ResourceType]: EntitySectionsFlags } = { executions: true, launch: false, inputs: true, - schedules: true, + schedules: false, versions: true, }, [ResourceType.TASK]: { diff --git a/packages/oss-console/src/components/Errors/ErrorHandler.tsx b/packages/oss-console/src/components/Errors/ErrorHandler.tsx new file mode 100644 index 000000000..fdaf3ef83 --- /dev/null +++ b/packages/oss-console/src/components/Errors/ErrorHandler.tsx @@ -0,0 +1,142 @@ +import NotAuthorizedError from '@clients/common/Errors/NotAuthorizedError'; +import NotFoundError from '@clients/common/Errors/NotFoundError'; +import { AxiosError } from 'axios'; +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import Button from '@mui/material/Button'; +import { useFlyteApi } from '@clients/flyte-api/ApiProvider'; +import { GenericError } from './GenericError'; + +export interface ErrorHandlerProps { + error: NotFoundError | NotAuthorizedError | AxiosError | Error; +} + +export const ErrorHandler: React.FC = ({ error }) => { + const { getLoginUrl } = useFlyteApi(); + + const contactSupport = ( + <> + + Please join the Slack community and explore its history on{' '} + + discuss.flyte.org + + {', '} + or file a GitHub issue on{' '} + + flyteorg/flyte + {' '} + if the problem persists. + + + ); + + if ( + error instanceof NotFoundError || + (error instanceof AxiosError && error.response?.status === 404) + ) { + return ( + + + The requested resource was not found + + + + {contactSupport} + + } + /> + ); + } + + if ( + error instanceof NotAuthorizedError || + (error instanceof AxiosError && error.response?.status === 401) + ) { + return ( + + + + You do not have the proper authentication to access this page + + + + + {contactSupport} + + + + + } + /> + ); + } + + if (error instanceof AxiosError && error.response?.status === 403) { + return ( + + + + You don't have permission to access this page + + + + + {contactSupport} + + } + /> + ); + } + + return ( + + + + {contactSupport} + + } + /> + ); +}; diff --git a/packages/oss-console/src/components/Errors/GenericError.tsx b/packages/oss-console/src/components/Errors/GenericError.tsx new file mode 100644 index 000000000..87a055874 --- /dev/null +++ b/packages/oss-console/src/components/Errors/GenericError.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { type SvgIconProps } from '@mui/material/SvgIcon'; +import PageMeta from '@clients/primitives/PageMeta'; +import Container from '@mui/material/Container'; +import NotFoundLogo from '@clients/ui-atoms/NotFoundLogo'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +export interface GenericErrorProps { + title?: string | number; + description?: string; + content?: React.ReactNode; + Icon?: (props: SvgIconProps) => React.JSX.Element; +} + +/** + * React prints the \n as "\n" in the DOM, + * so we need to split on that to make new lines + */ +const makeDescriptionSpans = (description: string = '') => { + const descriptionSpans = description.split('\\n').filter((span) => !!span); + return descriptionSpans.map((span) => ( + + {span} +
+
+ )); +}; + +export const GenericError: React.FC = ({ + title, + description, + content, + Icon = NotFoundLogo, +}) => { + return ( + <> + + + + + t.spacing(2), + paddingBottom: (t) => t.spacing(2), + }} + > + + {title} + + + {makeDescriptionSpans(description)} + + + {content} + + + {Icon && ( + + theme.palette.common.grays[20], + }} + /> + + )} + + + + ); +}; diff --git a/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionLabels.tsx b/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionLabels.tsx new file mode 100644 index 000000000..3586909aa --- /dev/null +++ b/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionLabels.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import Chip from '@mui/material/Chip'; +import makeStyles from '@mui/styles/makeStyles'; + +type ValuesType = {[p: string]: string}; +interface Props { + values: ValuesType; +} + +const useStyles = makeStyles({ + chipContainer: { + display: 'flex', + flexWrap: 'wrap', + width: '100%', + maxWidth: '420px' + }, + chip: { + margin: '2px 2px 2px 0', + }, +}); + + +export const ExecutionLabels: React.FC = ({values}) => { + const classes = useStyles(); + return ( +
+ {Object.entries(values).map(([key, value]) => ( + + ))} +
+ ); +}; diff --git a/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx b/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx index fef119a0c..1552677c6 100644 --- a/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx +++ b/packages/oss-console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx @@ -14,6 +14,7 @@ import { ExecutionContext } from '../contexts'; import { ExpandableExecutionError } from '../Tables/ExpandableExecutionError'; import { ExecutionMetadataLabels } from './constants'; import { ExecutionMetadataExtra } from './ExecutionMetadataExtra'; +import { ExecutionLabels } from './ExecutionLabels'; const StyledContainer = styled('div')(({ theme }) => { return { @@ -69,7 +70,12 @@ export const ExecutionMetadata: React.FC<{}> = () => { const startedAt = execution?.closure?.startedAt; const workflowId = execution?.closure?.workflowId; - const { referenceExecution, systemMetadata } = execution.spec.metadata; + const { labels } = execution.spec; + const { + referenceExecution, + systemMetadata , + parentNodeExecution, + } = execution.spec.metadata; const cluster = systemMetadata?.executionCluster ?? dashedValueString; const details: DetailItem[] = [ @@ -107,6 +113,30 @@ export const ExecutionMetadata: React.FC<{}> = () => { }); } + if (parentNodeExecution != null && parentNodeExecution.executionId != null) { + details.push({ + label: ExecutionMetadataLabels.parent, + value: ( + + {parentNodeExecution.executionId.name} + + ), + }); + } + + if (labels != null && labels.values != null) { + details.push({ + label: ExecutionMetadataLabels.labels, + value: ( + Object.entries(labels.values).length > 0 ? + : dashedValueString + ) + }) + } + return ( diff --git a/packages/oss-console/src/components/Executions/ExecutionDetails/constants.ts b/packages/oss-console/src/components/Executions/ExecutionDetails/constants.ts index 97522052e..b1031d595 100644 --- a/packages/oss-console/src/components/Executions/ExecutionDetails/constants.ts +++ b/packages/oss-console/src/components/Executions/ExecutionDetails/constants.ts @@ -13,6 +13,8 @@ export enum ExecutionMetadataLabels { interruptible = 'Interruptible override', overwriteCache = 'Overwrite cached outputs', executionClusterLabel = 'Execution Cluster Label', + parent = 'Parent', + labels = 'Labels', } export const tabs = { diff --git a/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionLabels.test.tsx b/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionLabels.test.tsx new file mode 100644 index 000000000..0bc9f5d3e --- /dev/null +++ b/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionLabels.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ExecutionLabels } from '../ExecutionLabels'; + +jest.mock('@mui/material/Chip', () => (props: any) => ( +
{props.label}
+)); + +describe('ExecutionLabels', () => { + it('renders chips with key-value pairs correctly', () => { + const values = { + 'random/uuid': 'f8b9ff18-4811-4bcc-aefd-4f4ec4de469d', + 'bar': 'baz', + 'foo': '', + }; + + render(); + + expect(screen.getByText('random/uuid: f8b9ff18-4811-4bcc-aefd-4f4ec4de469d')).toBeInTheDocument(); + expect(screen.getByText('bar: baz')).toBeInTheDocument(); + expect(screen.getByText('foo')).toBeInTheDocument(); + }); + + it('applies correct styles to chip container', () => { + const values = { + 'key': 'value', + }; + + const { container } = render(); + const chipContainer = container.firstChild; + + expect(chipContainer).toHaveStyle('display: flex'); + expect(chipContainer).toHaveStyle('flex-wrap: wrap'); + expect(chipContainer).toHaveStyle('width: 100%'); + expect(chipContainer).toHaveStyle('max-width: 420px'); + }); + + it('renders correct number of chips', () => { + const values = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + + render(); + + const chips = screen.getAllByTestId('chip'); + expect(chips.length).toBe(3); + }); +}); diff --git a/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx b/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx index 1fed6e736..782ac2b35 100644 --- a/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx +++ b/packages/oss-console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { getByTestId, render } from '@testing-library/react'; import Protobuf from '@clients/common/flyteidl/protobuf'; import * as React from 'react'; import { MemoryRouter } from 'react-router'; @@ -14,6 +14,9 @@ const startTimeTestId = `metadata-${ExecutionMetadataLabels.time}`; const durationTestId = `metadata-${ExecutionMetadataLabels.duration}`; const interruptibleTestId = `metadata-${ExecutionMetadataLabels.interruptible}`; const overwriteCacheTestId = `metadata-${ExecutionMetadataLabels.overwriteCache}`; +const relatedToTestId = `metadata-${ExecutionMetadataLabels.relatedTo}`; +const parentNodeExecutionTestId = `metadata-${ExecutionMetadataLabels.parent}` +const labelsTestId = `metadata-${ExecutionMetadataLabels.labels}`; jest.mock('../../../../models/Launch/api', () => ({ getLaunchPlan: jest.fn(() => Promise.resolve({ spec: {} })), @@ -106,4 +109,19 @@ describe('ExecutionMetadata', () => { const { getByTestId } = renderMetadata(); expect(getByTestId(overwriteCacheTestId)).toHaveTextContent('false'); }); + + it('shows related to if metadata is available', () => { + const { getByTestId } = renderMetadata(); + expect(getByTestId(relatedToTestId)).toHaveTextContent('name'); + }) + + it('shows parent execution if metadata is available', () => { + const { getByTestId } = renderMetadata(); + expect(getByTestId(parentNodeExecutionTestId)).toHaveTextContent('name'); + }) + + it('shows labels if spec has them', () => { + const { getByTestId } = renderMetadata(); + expect(getByTestId(labelsTestId)).toHaveTextContent("key: value"); + }) }); diff --git a/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx b/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx index a4684da79..5ab6ca2a6 100644 --- a/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx +++ b/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from 'react'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableRow from '@mui/material/TableRow'; +import Grid from '@mui/material/Grid'; import { formatDateUTC } from '../../../common/formatters'; import { padExecutionPaths, padExecutions, timestampToDate } from '../../../common/utils'; import { WaitForData } from '../../common/WaitForData'; @@ -45,26 +46,36 @@ export function useWorkflowVersionsTableColumns(): WorkflowVersionColumnDefiniti activeScheduleLaunchPlan.id.version === version; const versionText = version; return ( - - - - {versionText} - - {isActiveVersion && ( - theme.spacing(2), - }} - > - - - )} - - + + + + {versionText} + + + {isActiveVersion && ( + + + + )} + ); }, className: styles.columnName, diff --git a/packages/oss-console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx b/packages/oss-console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx index b45b213bd..a3dbbf5a4 100644 --- a/packages/oss-console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx +++ b/packages/oss-console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx @@ -451,7 +451,6 @@ const parseWorkflow = ({ } const templateNodeList = context.template.nodes; - /* Build Nodes from template */ for (let i = 0; i < templateNodeList.length; i++) { const compiledNode: CompiledNode = templateNodeList[i]; @@ -475,6 +474,28 @@ const parseWorkflow = ({ }; } + /* Build failure node and add downstream connection for edges building */ + const failureNode = { ...context.template.failureNode, failureNode: true }; + if (failureNode && failureNode.id) { + parseNode({ + node: failureNode as CompiledNode, + root, + nodeMetadataMap, + staticExecutionIdsMap, + compiledWorkflowClosure, + }); + nodeMap[failureNode.id] = { + dNode: root.nodes[root.nodes.length - 1], + compiledNode: failureNode as CompiledNode, + }; + if (!context.connections.downstream[startNodeId].ids.includes(failureNode.id)) { + context.connections.downstream[startNodeId].ids.push(failureNode.id); + context.connections.downstream[failureNode.id] = { + ids: [endNodeId], + }; + } + } + /* Build Edges */ buildWorkflowEdges({ root, diff --git a/packages/oss-console/src/components/common/ErrorBoundary.tsx b/packages/oss-console/src/components/common/ErrorBoundary.tsx index a14a601ea..9e37f5fc5 100644 --- a/packages/oss-console/src/components/common/ErrorBoundary.tsx +++ b/packages/oss-console/src/components/common/ErrorBoundary.tsx @@ -6,10 +6,12 @@ import Typography from '@mui/material/Typography'; import Card from '@mui/material/Card'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import NotFoundError from '@clients/common/Errors/NotFoundError'; +import NotAuthorizedError from '@clients/common/Errors/NotAuthorizedError'; +import { AxiosError } from 'axios'; import { log } from '../../common/log'; import { useCommonStyles } from './styles'; -import { PrettyError } from '../Errors/PrettyError'; import { NonIdealState } from './NonIdealState'; +import { ErrorHandler } from '../Errors/ErrorHandler'; interface ErrorBoundaryState { error?: Error; @@ -58,8 +60,12 @@ export class ErrorBoundary extends React.Component< render() { const { fixed = false } = this.props; if (this.state.error) { - if (this.state.error instanceof NotFoundError) { - return ; + if ( + this.state.error instanceof NotFoundError || + this.state.error instanceof NotAuthorizedError || + (this.state.error as AxiosError).response?.status === 403 + ) { + return ; } return ; diff --git a/packages/oss-console/src/components/data/QueryAuthorizationObserver.tsx b/packages/oss-console/src/components/data/QueryAuthorizationObserver.tsx index 29b4d6628..a42c9cead 100644 --- a/packages/oss-console/src/components/data/QueryAuthorizationObserver.tsx +++ b/packages/oss-console/src/components/data/QueryAuthorizationObserver.tsx @@ -1,26 +1,19 @@ import * as React from 'react'; import { onlineManager, Query, useQueryClient } from 'react-query'; import { useFlyteApi } from '@clients/flyte-api/ApiProvider'; -import NotAuthorizedError from '@clients/common/Errors/NotAuthorizedError'; /** Watches all queries to detect a NotAuthorized error, disabling future queries * and triggering the login refresh flow. * Note: Should be placed just below the QueryClient and ApiContext providers. */ -// TODO: narusina - move this one to flyte-api too export const QueryAuthorizationObserver: React.FC = () => { const queryCache = useQueryClient().getQueryCache(); const apiContext = useFlyteApi(); React.useEffect(() => { - const unsubscribe = queryCache.subscribe((query?: Query | undefined) => { - if (!query || !query.state.error) { - return; - } - if (query.state.error instanceof NotAuthorizedError) { - // Stop all in-progress and future requests - onlineManager.setOnline(false); - // Trigger auth flow + const unsubscribe = queryCache.subscribe(async (_query?: Query | undefined) => { + if (!onlineManager.isOnline()) { + // trigger sign in modal apiContext.loginStatus.setExpired(true); } }); diff --git a/packages/oss-console/src/components/data/axiosClient.tsx b/packages/oss-console/src/components/data/axiosClient.tsx new file mode 100644 index 000000000..8e7020329 --- /dev/null +++ b/packages/oss-console/src/components/data/axiosClient.tsx @@ -0,0 +1,54 @@ +import { env } from '@clients/common/environment'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { onlineManager } from 'react-query'; +import createAuthRefreshInterceptor from 'axios-auth-refresh'; + +export const axioClient = axios.create({ + baseURL: env.ADMIN_API_URL, + withCredentials: true, + maxRedirects: 0, +}); + +export const refreshAuth = async (axiosError?: AxiosError, isChunk?: boolean) => { + if (axiosError?.response?.status !== 401 && !isChunk) { + return; + } + return axioClient + .get(`${env.ADMIN_API_URL}/login?redirect_url=${env.BASE_URL}/select-project`, { + withCredentials: true, + headers: { + Accept: 'text/html', + }, + responseType: 'text', + maxRedirects: 5, + }) + .then((res) => { + const redirectUrl = `${env.ADMIN_API_URL}${env.BASE_URL}/select-project`; + if (res.request.responseURL.includes(redirectUrl)) { + onlineManager.setOnline(true); + return res; + } + + // throw error if not redirected to the console app + throw new Error(); + }) + .catch((_error) => { + onlineManager.setOnline(false); + + const unauthError = isChunk + ? new AxiosError('Not Authorized', '401', undefined, undefined, { + status: 401, + statusText: 'Not Authorized', + } as AxiosResponse) + : axiosError; + + return Promise.reject(unauthError); + }); +}; + +createAuthRefreshInterceptor(axioClient, refreshAuth, { + statusCodes: [401], // default: [ 401 ] + pauseInstanceWhileRefreshing: true, + retryInstance: axioClient, + interceptNetworkError: true, +}); diff --git a/packages/oss-console/src/components/data/globalQueryClient.ts b/packages/oss-console/src/components/data/globalQueryClient.ts new file mode 100644 index 000000000..95f182db3 --- /dev/null +++ b/packages/oss-console/src/components/data/globalQueryClient.ts @@ -0,0 +1,3 @@ +import { createQueryClient } from './queryCache'; + +export const globalQueryClient = createQueryClient(); diff --git a/packages/oss-console/src/components/data/loadChunkWithAuth.tsx b/packages/oss-console/src/components/data/loadChunkWithAuth.tsx new file mode 100644 index 000000000..eb2e42781 --- /dev/null +++ b/packages/oss-console/src/components/data/loadChunkWithAuth.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import NotAuthorizedError from '@clients/common/Errors/NotAuthorizedError'; +import { refreshAuth } from './axiosClient'; +import { ErrorHandler } from '../Errors/ErrorHandler'; + +type ImportFunction = () => Promise<{ default: React.ComponentType }>; + +export const loadChunkWithAuth = ( + importFunction: ImportFunction, +): Promise<{ default: React.ComponentType }> => { + return new Promise((resolve, _reject) => { + importFunction() + .then((res) => { + resolve(res); + }) + .catch((_error) => { + refreshAuth(undefined, true) + .then((_res) => { + importFunction().then((res) => { + resolve(res); + }); + }) + + .catch((_err) => { + // if loading chunk failed, show unauthorized error + resolve({ + default: () => , + }); + }); + }); + }); +}; diff --git a/packages/oss-console/src/models/AdminEntity/AdminEntity.ts b/packages/oss-console/src/models/AdminEntity/AdminEntity.ts index f67314c57..c776ebed2 100644 --- a/packages/oss-console/src/models/AdminEntity/AdminEntity.ts +++ b/packages/oss-console/src/models/AdminEntity/AdminEntity.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig, Method } from 'axios'; +import { AxiosRequestConfig, Method } from 'axios'; import { AdminEntityTransformer, DecodableType, @@ -7,6 +7,7 @@ import { } from '@clients/common/types/adminEntityTypes'; import { decodeProtoResponse } from '@clients/common/Utils/decodeProtoResponse'; import { transformRequestError } from '@clients/flyte-api/utils/transformRequestError'; +import { axioClient } from '@clients/oss-console/components/data/axiosClient'; import { generateAdminApiQuery } from './AdminApiQuery'; import { adminApiUrl, encodeProtoPayload, logProtoResponse } from './utils'; @@ -54,7 +55,7 @@ async function request( }; try { - const { data } = await axios.request(finalOptions); + const { data } = await axioClient.request(finalOptions); return data; } catch (e) { throw transformRequestError(e, endpoint, true); diff --git a/packages/oss-console/src/models/__mocks__/executionsData.ts b/packages/oss-console/src/models/__mocks__/executionsData.ts index 8e52b58d4..90727193e 100644 --- a/packages/oss-console/src/models/__mocks__/executionsData.ts +++ b/packages/oss-console/src/models/__mocks__/executionsData.ts @@ -21,6 +21,12 @@ export const MOCK_WORKFLOW_ID = { version: 'version', }; +export const MOCK_EXECUTION_ID = { + project: 'project', + domain: 'domain', + name: 'name', +} + export function fixedDuration(): Protobuf.Duration { return { nanos: 0, @@ -77,6 +83,15 @@ export function generateExecutionMetadata(): ExecutionMetadata { systemMetadata: { executionCluster: 'flyte', }, + referenceExecution: { + ...MOCK_EXECUTION_ID + }, + parentNodeExecution: { + nodeId: 'node', + executionId: { + ...MOCK_EXECUTION_ID + } + }, }; } @@ -85,6 +100,11 @@ export const createMockExecutionSpec: () => ExecutionSpec = () => ({ launchPlan: { ...MOCK_LAUNCH_PLAN_ID }, notifications: { notifications: [] }, metadata: generateExecutionMetadata(), + labels: { + values: { + "key": "value" + } + } }); export const createMockExecution: (id?: string | number) => Execution = (id = 1) => { diff --git a/packages/oss-console/src/test/setupTests.ts b/packages/oss-console/src/test/setupTests.ts index 9f7d0ed11..808b7d01d 100644 --- a/packages/oss-console/src/test/setupTests.ts +++ b/packages/oss-console/src/test/setupTests.ts @@ -6,7 +6,18 @@ jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ // mock axios to avoid open handles const axiosMock = jest.mock('axios', () => ({ - request: jest.fn().mockResolvedValue({ data: {} }), + create: jest.fn().mockReturnValue({ + request: jest.fn().mockResolvedValue({ response: {} }), + get: jest.fn().mockResolvedValue({ data: {} }), + interceptors: { + request: { + use: jest.fn(), + }, + response: { + use: jest.fn(), + }, + } + }), defaults: { transformRequest: [], transformResponse: [], diff --git a/packages/theme/src/Theme/muiTheme.ts b/packages/theme/src/Theme/muiTheme.ts index 6773564b8..8de833bc5 100644 --- a/packages/theme/src/Theme/muiTheme.ts +++ b/packages/theme/src/Theme/muiTheme.ts @@ -31,21 +31,21 @@ export const palette = { }, state: { default: '#e5e5e5', - succeeded: '#77C332', + succeeded: '#D1EABF', succeeding: '#C0D765', - aborted: '#C91DFF', + aborted: '#E694FF', aborting: '#CE90FF', - failed: '#EE678E', + failed: '#FFDAD6', failing: '#FC8D14', - running: '#456FFF', - dynamicrunning: '#456FFF', + running: '#91A7FF', + dynamicrunning: '#91A7FF', queued: '#F2840C', undefined: '#EBDBC8', recovered: '#FFE9B9', notrun: '#E6E7E8', nested: '#AAAAAA', timedout: '#FEABAB', - paused: '#FCB51D', + paused: '#FFDEAC', skipped: '#B6B6B6', // todo: special cases https://www.figma.com/file/49B9RGzHBX12aKzZbNjuFP/01-Design-System-for-Union-Cloud?type=design&node-id=6068-13885&mode=design&t=FBnnHgqlGDeqZwr4-0 initializing: '#E6E7E8', diff --git a/website/console/package.json b/website/console/package.json index cafccc8b9..3f89a1db7 100644 --- a/website/console/package.json +++ b/website/console/package.json @@ -25,6 +25,7 @@ "@patternfly/react-core": "^4.276.8", "@patternfly/react-log-viewer": "^4.87.100", "@tanstack/react-table": "^8.10.1", + "axios-auth-refresh": "^3.3.6", "chartjs-plugin-datalabels": "2.0.0", "cron-parser": "^4.9.0", "moment": "^2.29.4", diff --git a/yarn.lock b/yarn.lock index fb40cac2a..db6bd60c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2134,6 +2134,7 @@ __metadata: "@patternfly/react-core": ^4.276.8 "@patternfly/react-log-viewer": ^4.87.100 "@tanstack/react-table": ^8.10.1 + axios-auth-refresh: ^3.3.6 chartjs-plugin-datalabels: 2.0.0 cron-parser: ^4.9.0 moment: ^2.29.4 @@ -2172,7 +2173,7 @@ __metadata: version: 0.0.0-use.local resolution: "@clients/flyte-api@workspace:packages/flyte-api" dependencies: - axios: ^0.27.2 + axios: ^1.7.1 camelcase-keys: ^7.0.2 snakecase-keys: ^5.4.2 peerDependencies: @@ -2232,7 +2233,7 @@ __metadata: "@types/serve-static": ^1.7.31 "@types/shallowequal": ^0.2.3 "@xstate/react": ^1.0.0 - axios: ^0.27.2 + axios: ^1.7.1 copy-to-clipboard: ^3.0.8 cronstrue: ^1.31.0 d3-dag: ^0.3.4 @@ -9605,13 +9606,12 @@ __metadata: languageName: node linkType: hard -"axios@npm:^0.27.2": - version: 0.27.2 - resolution: "axios@npm:0.27.2" - dependencies: - follow-redirects: ^1.14.9 - form-data: ^4.0.0 - checksum: 38cb7540465fe8c4102850c4368053c21683af85c5fdf0ea619f9628abbcb59415d1e22ebc8a6390d2bbc9b58a9806c874f139767389c862ec9b772235f06854 +"axios-auth-refresh@npm:^3.3.6": + version: 3.3.6 + resolution: "axios-auth-refresh@npm:3.3.6" + peerDependencies: + axios: ">= 0.18 < 0.19.0 || >= 0.19.1" + checksum: 74206569065a4ece84830210d4f21900aa9690259c3c21743efb195309bcbcb6c3f5e59ce973ea8fe5d9155f5b241001c35448e527da9f32d7ee37d030d51544 languageName: node linkType: hard @@ -9626,6 +9626,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.7.1": + version: 1.7.1 + resolution: "axios@npm:1.7.1" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 77760d94b3812e07d4a5b02468a55eed5c8435ef4d605d159f2808775bdd15da60ab5b15b665a6f72800b5d261875d808b410cd3cb1d7571cdc6ec5e0108025a + languageName: node + linkType: hard + "axobject-query@npm:^3.2.1": version: 3.2.1 resolution: "axobject-query@npm:3.2.1" @@ -15022,7 +15033,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.9": +"follow-redirects@npm:^1.0.0": version: 1.15.2 resolution: "follow-redirects@npm:1.15.2" peerDependenciesMeta: @@ -15042,6 +15053,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3"