From 4a184447f4b91fcd2cccab3c32dcd5a4a3f3555c Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Mon, 8 Dec 2025 14:18:36 -0500 Subject: [PATCH 1/2] Add generated clients for Logos and Icons Signed-off-by: Charles Thao --- workspaces/frontend/scripts/swagger.version | 2 +- .../useWorkspaceCountPerKind.spec.tsx | 2 + workspaces/frontend/src/app/types.ts | 6 +- .../frontend/src/generated/Workspacekinds.ts | 38 +++++++++ .../frontend/src/generated/Workspaces.ts | 12 +-- .../frontend/src/generated/data-contracts.ts | 81 +++++++++++++++---- .../src/shared/mock/mockNotebookApis.ts | 3 + 7 files changed, 120 insertions(+), 24 deletions(-) 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/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/types.ts b/workspaces/frontend/src/app/types.ts index 3013d060d..72f2a4d99 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -1,6 +1,6 @@ import { + AssetsImageRef, WorkspacekindsImageConfigValue, - WorkspacekindsImageRef, WorkspacekindsPodConfigValue, WorkspacekindsPodMetadata, WorkspacekindsPodVolumeMounts, @@ -50,8 +50,8 @@ export interface WorkspaceKindProperties { deprecated: boolean; deprecationMessage: string; hidden: boolean; - icon: WorkspacekindsImageRef; - logo: WorkspacekindsImageRef; + icon: AssetsImageRef; + logo: AssetsImageRef; } export interface WorkspaceKindImageConfigValue extends WorkspacekindsImageConfigValue { diff --git a/workspaces/frontend/src/generated/Workspacekinds.ts b/workspaces/frontend/src/generated/Workspacekinds.ts index 4b250e178..709381930 100644 --- a/workspaces/frontend/src/generated/Workspacekinds.ts +++ b/workspaces/frontend/src/generated/Workspacekinds.ts @@ -86,4 +86,42 @@ export class Workspacekinds 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/mock/mockNotebookApis.ts b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts index 3947b6ee8..a47f3561b 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts @@ -43,6 +43,9 @@ export const mockNotebookApisImpl = (): NotebookApis => ({ }, workspaceKinds: { listWorkspaceKinds: async () => ({ data: mockWorkspaceKinds }), + getWorkspaceKindIcon: async (kind) => + mockWorkspaceKinds.find((w) => w.name === kind)?.icon.url ?? '', + getWorkspaceKindLogo: async (kind) => mockWorkspaceKinds.find((w) => w.name === kind)!.logo.url, getWorkspaceKind: async (kind) => ({ data: mockWorkspaceKinds.find((w) => w.name === kind)!, }), From d5b695777696cc0854d6396489aa7f6f4cc762be Mon Sep 17 00:00:00 2001 From: Charles Thao Date: Tue, 9 Dec 2025 14:16:35 -0500 Subject: [PATCH 2/2] Store and render images from Browser local storage Signed-off-by: Charles Thao --- .../src/app/components/WorkspaceTable.tsx | 2 + .../pages/WorkspaceKinds/WorkspaceKinds.tsx | 2 + .../details/WorkspaceKindDetailsOverview.tsx | 4 + .../Form/kind/WorkspaceFormKindList.tsx | 2 + .../src/shared/components/WithValidImage.tsx | 80 +++++++++++++++++-- .../frontend/src/shared/mock/mockBuilder.ts | 4 +- .../src/shared/mock/mockNotebookApis.ts | 15 +++- 7 files changed, 98 insertions(+), 11 deletions(-) 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/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) => ( 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 a47f3561b..c68e8b0d4 100644 --- a/workspaces/frontend/src/shared/mock/mockNotebookApis.ts +++ b/workspaces/frontend/src/shared/mock/mockNotebookApis.ts @@ -43,9 +43,18 @@ export const mockNotebookApisImpl = (): NotebookApis => ({ }, workspaceKinds: { listWorkspaceKinds: async () => ({ data: mockWorkspaceKinds }), - getWorkspaceKindIcon: async (kind) => - mockWorkspaceKinds.find((w) => w.name === kind)?.icon.url ?? '', - getWorkspaceKindLogo: async (kind) => mockWorkspaceKinds.find((w) => w.name === kind)!.logo.url, + 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)!, }),