diff --git a/workspaces/frontend/scripts/swagger.version b/workspaces/frontend/scripts/swagger.version index 21ab894be..dbc75bade 100644 --- a/workspaces/frontend/scripts/swagger.version +++ b/workspaces/frontend/scripts/swagger.version @@ -1 +1 @@ -4f0a29dec0d3c9f0d0f02caab4dc84101bfef8b0 +ec35a7c28bea82770c2f6af1ee35d3135114d451 diff --git a/workspaces/frontend/src/app/components/WorkspaceTable.tsx b/workspaces/frontend/src/app/components/WorkspaceTable.tsx index a054aa334..6dc816941 100644 --- a/workspaces/frontend/src/app/components/WorkspaceTable.tsx +++ b/workspaces/frontend/src/app/components/WorkspaceTable.tsx @@ -652,6 +652,8 @@ const WorkspaceTable = React.forwardRef( imageSrc={kindLogoDict[workspace.workspaceKind.name]} /> } + assetType="logo" + kindName={workspace.workspaceKind.name} > {(validSrc) => ( diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceCountPerKind.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceCountPerKind.spec.tsx index 4e0ec931a..9238a0eb6 100644 --- a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceCountPerKind.spec.tsx +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceCountPerKind.spec.tsx @@ -66,6 +66,8 @@ describe('useWorkspaceCountPerKind', () => { listWorkspaceKinds: mockListWorkspaceKinds, createWorkspaceKind: jest.fn(), getWorkspaceKind: jest.fn(), + getWorkspaceKindIcon: jest.fn(), + getWorkspaceKindLogo: jest.fn(), }, }; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx index 3ed374fdb..5cc3c8254 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx @@ -493,6 +493,8 @@ export const WorkspaceKinds: React.FunctionComponent = () => { imageSrc={workspaceKind.icon.url} skeletonWidth="20px" fallback={} + assetType="icon" + kindName={workspaceKind.name} > {(validSrc) => ( } + assetType="icon" + kindName={workspaceKind.name} > {(validSrc) => {workspaceKind.name}} @@ -84,6 +86,8 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent< message="Cannot load logo image" /> } + assetType="logo" + kindName={workspaceKind.name} > {(validSrc) => {workspaceKind.name}} diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx index daa8bdaf8..89b91950f 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx @@ -124,6 +124,8 @@ export const WorkspaceFormKindList: React.FunctionComponent } + assetType="logo" + kindName={kind.name} > {(validSrc) => ( extends HttpClient + this.request({ + path: `/workspacekinds/${name}/assets/icon.svg`, + method: 'GET', + type: ContentType.Json, + format: 'blob', + ...params, + }); + /** + * @description Returns the logo image for a specific workspace kind. If the logo is stored in a ConfigMap, it serves the image content. If the logo is a remote URL, returns 404 (browser should fetch directly). + * + * @tags workspacekinds + * @name GetWorkspaceKindLogo + * @summary Get workspace kind logo + * @request GET:/workspacekinds/{name}/assets/logo.svg + * @response `200` `string` SVG image content + * @response `404` `ApiErrorEnvelope` Not Found. Logo uses remote URL or resource does not exist. + * @response `500` `ApiErrorEnvelope` Internal server error. + */ + getWorkspaceKindLogo = (name: string, params: RequestParams = {}) => + this.request({ + path: `/workspacekinds/${name}/assets/logo.svg`, + method: 'GET', + type: ContentType.Json, + format: 'blob', + ...params, + }); } diff --git a/workspaces/frontend/src/generated/Workspaces.ts b/workspaces/frontend/src/generated/Workspaces.ts index 4c616f6cf..a7893dcfb 100644 --- a/workspaces/frontend/src/generated/Workspaces.ts +++ b/workspaces/frontend/src/generated/Workspaces.ts @@ -48,9 +48,9 @@ export class Workspaces extends HttpClient @@ -69,12 +69,13 @@ export class Workspaces extends HttpClient extends HttpClient extends HttpClient @@ -151,10 +153,10 @@ export class Workspaces extends HttpClient diff --git a/workspaces/frontend/src/generated/data-contracts.ts b/workspaces/frontend/src/generated/data-contracts.ts index 12a6a45e3..05efa6793 100644 --- a/workspaces/frontend/src/generated/data-contracts.ts +++ b/workspaces/frontend/src/generated/data-contracts.ts @@ -55,11 +55,39 @@ export enum FieldErrorType { ErrorTypeTypeInvalid = 'FieldValueTypeInvalid', } +export enum AssetsImageRefErrorCode { + ImageRefErrorCodeConfigMapMissing = 'CONFIGMAP_MISSING', + ImageRefErrorCodeConfigMapKeyMissing = 'CONFIGMAP_KEY_MISSING', + ImageRefErrorCodeConfigMapUnknown = 'CONFIGMAP_UNKNOWN', + ImageRefErrorCodeUnknown = 'UNKNOWN', +} + +export enum ApiErrorCauseOrigin { + OriginInternal = 'INTERNAL', + OriginKubernetes = 'KUBERNETES', +} + export interface ActionsWorkspaceActionPause { paused: boolean; } +export interface ApiConflictError { + /** + * A human-readable description of the cause of the error. + * This field may be presented as-is to a reader. + */ + message?: string; + /** + * Origin indicates where the conflict error originated. + * If value is empty, the origin is unknown. + */ + origin?: ApiErrorCauseOrigin; +} + export interface ApiErrorCause { + /** ConflictCauses contains details about conflict errors that caused the request to fail. */ + conflict_cause?: ApiConflictError[]; + /** ValidationErrors contains details about validation errors that caused the request to fail. */ validation_errors?: ApiValidationError[]; } @@ -68,8 +96,11 @@ export interface ApiErrorEnvelope { } export interface ApiHTTPError { + /** Cause contains detailed information about the cause of the error. */ cause?: ApiErrorCause; + /** Code is a string representation of the HTTP status code. */ code: string; + /** Message is a human-readable description of the error. */ message: string; } @@ -78,9 +109,32 @@ export interface ApiNamespaceListEnvelope { } export interface ApiValidationError { - field: string; - message: string; - type: FieldErrorType; + /** + * The field of the resource that has caused this error, as named by its JSON serialization. + * May include dot and postfix notation for nested attributes. + * Arrays are zero-indexed. + * Fields may appear more than once in an array of causes due to fields having multiple errors. + * + * Examples: + * "name" - the field "name" on the current resource + * "items[0].name" - the field "name" on the first array entry in "items" + */ + field?: string; + /** + * A human-readable description of the cause of the error. + * This field may be presented as-is to a reader. + */ + message?: string; + /** + * Origin indicates where the validation error originated. + * If value is empty, the origin is unknown. + */ + origin?: ApiErrorCauseOrigin; + /** + * A machine-readable description of the cause of the error. + * If value is empty, there is no information available. + */ + type?: FieldErrorType; } export interface ApiWorkspaceActionPauseEnvelope { @@ -107,6 +161,11 @@ export interface ApiWorkspaceListEnvelope { data: WorkspacesWorkspace[]; } +export interface AssetsImageRef { + error?: AssetsImageRefErrorCode; + url: string; +} + export interface HealthCheckHealthCheck { status: HealthCheckServiceStatus; systemInfo: HealthCheckSystemInfo; @@ -135,10 +194,6 @@ export interface WorkspacekindsImageConfigValue { redirect?: WorkspacekindsOptionRedirect; } -export interface WorkspacekindsImageRef { - url: string; -} - export interface WorkspacekindsOptionLabel { key: string; value: string; @@ -196,8 +251,8 @@ export interface WorkspacekindsWorkspaceKind { description: string; displayName: string; hidden: boolean; - icon: WorkspacekindsImageRef; - logo: WorkspacekindsImageRef; + icon: AssetsImageRef; + logo: AssetsImageRef; name: string; podTemplate: WorkspacekindsPodTemplate; } @@ -225,10 +280,6 @@ export interface WorkspacesImageConfig { redirectChain?: WorkspacesRedirectStep[]; } -export interface WorkspacesImageRef { - url: string; -} - export interface WorkspacesLastProbeInfo { /** Unix Epoch time in milliseconds */ endTimeMs: number; @@ -363,8 +414,8 @@ export interface WorkspacesWorkspaceCreate { } export interface WorkspacesWorkspaceKindInfo { - icon: WorkspacesImageRef; - logo: WorkspacesImageRef; + icon: AssetsImageRef; + logo: AssetsImageRef; missing: boolean; name: string; } diff --git a/workspaces/frontend/src/shared/components/WithValidImage.tsx b/workspaces/frontend/src/shared/components/WithValidImage.tsx index f4f72f3d7..234c82540 100644 --- a/workspaces/frontend/src/shared/components/WithValidImage.tsx +++ b/workspaces/frontend/src/shared/components/WithValidImage.tsx @@ -1,12 +1,16 @@ import React, { useEffect, useState } from 'react'; import { Skeleton, SkeletonProps } from '@patternfly/react-core/dist/esm/components/Skeleton'; +import { useBrowserStorage } from 'mod-arch-core'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; type WithValidImageProps = { imageSrc: string | undefined | null; fallback: React.ReactNode; children: (validImageSrc: string) => React.ReactNode; + assetType: 'icon' | 'logo'; skeletonWidth?: SkeletonProps['width']; skeletonShape?: SkeletonProps['shape']; + kindName: string; }; const DEFAULT_SKELETON_WIDTH = '32px'; @@ -14,16 +18,30 @@ const DEFAULT_SKELETON_SHAPE: SkeletonProps['shape'] = 'square'; type LoadState = 'loading' | 'valid' | 'invalid'; +const isAbsoluteUrl = (url: string): boolean => { + try { + const urlObj = new URL(url); + return !!urlObj.protocol; + } catch { + // If URL constructor throws, it's not a valid absolute URL + return false; + } +}; + const WithValidImage: React.FC = ({ imageSrc, fallback, children, skeletonWidth = DEFAULT_SKELETON_WIDTH, skeletonShape = DEFAULT_SKELETON_SHAPE, + assetType, + kindName, }) => { const [status, setStatus] = useState('loading'); const [resolvedSrc, setResolvedSrc] = useState(''); - + const { api } = useNotebookAPI(); + const shouldCache = !!imageSrc; + const [image, setImage] = useBrowserStorage(imageSrc || 'temp', ''); useEffect(() => { let cancelled = false; @@ -32,15 +50,65 @@ const WithValidImage: React.FC = ({ return; } - const img = new Image(); - img.onload = () => !cancelled && (setResolvedSrc(imageSrc), setStatus('valid')); - img.onerror = () => !cancelled && setStatus('invalid'); - img.src = imageSrc; + const fetchImage = async () => { + // Check if we have a cached base64 data URL + if (shouldCache && image.length > 0) { + setResolvedSrc(image); + setStatus('valid'); + return; + } + + let blob: Blob; + try { + // Check if the URL is absolute (e.g., https://example.com/image.png) + if (isAbsoluteUrl(imageSrc)) { + const response = await fetch(imageSrc); + blob = await response.blob(); + } else { + // Use API for relative URL (e.g., /api/v1/workspacekinds/jupyter/assets/icon.svg) + const response = + assetType === 'icon' + ? await api.workspaceKinds.getWorkspaceKindIcon(kindName) + : await api.workspaceKinds.getWorkspaceKindLogo(kindName); + if (typeof response === 'string') { + // If response is a string, create blob from string + blob = new Blob([response]); + } else { + blob = response; + } + } + const reader = new FileReader(); + reader.onloadend = () => { + if (!cancelled && reader.result) { + const dataUrl = reader.result as string; + setResolvedSrc(dataUrl); + setStatus('valid'); + if (shouldCache) { + setImage(dataUrl); + } + } + }; + reader.onerror = () => { + console.error('Failed to convert image to data URL'); + if (!cancelled) { + setStatus('invalid'); + } + }; + reader.readAsDataURL(blob); + } catch (error) { + console.error('Failed to fetch image:', error); + if (!cancelled) { + setStatus('invalid'); + } + } + }; + + fetchImage(); return () => { cancelled = true; }; - }, [imageSrc]); + }, [imageSrc, setImage, image, shouldCache, assetType, api.workspaceKinds, kindName]); if (status === 'loading') { return ( diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index 8aaed5c2e..bfd7bc27f 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -39,7 +39,7 @@ export const buildMockWorkspaceKindInfo = ( url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', }, logo: { - url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + url: '/api/v1/workspacekinds/jupyter/assets/logo.svg', }, ...workspaceKindInfo, }); @@ -188,7 +188,7 @@ export const buildMockWorkspaceKind = ( url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', }, logo: { - url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + url: '/api/v1/workspacekinds/jupyter/assets/logo.svg', }, clusterMetrics: { workspacesCount: 10, diff --git a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts index 3947b6ee8..c68e8b0d4 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts @@ -43,6 +43,18 @@ export const mockNotebookApisImpl = (): NotebookApis => ({ }, workspaceKinds: { listWorkspaceKinds: async () => ({ data: mockWorkspaceKinds }), + getWorkspaceKindIcon: async () => { + const response = await fetch( + 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', + ); + return (await response.blob()) as unknown as string; + }, + getWorkspaceKindLogo: async () => { + const response = await fetch( + 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + ); + return (await response.blob()) as unknown as string; + }, getWorkspaceKind: async (kind) => ({ data: mockWorkspaceKinds.find((w) => w.name === kind)!, }),