diff --git a/frontend/src/components/DetailsTable.test.tsx b/frontend/src/components/DetailsTable.test.tsx index c3b33dde3e1..8ec2bfcd2ec 100644 --- a/frontend/src/components/DetailsTable.test.tsx +++ b/frontend/src/components/DetailsTable.test.tsx @@ -17,27 +17,77 @@ import * as React from 'react'; import DetailsTable from './DetailsTable'; -import { shallow } from 'enzyme'; import { render } from '@testing-library/react'; +jest.mock('./Editor', () => { + return ({ value }: { value: string }) =>
{value}
; +}); + describe('DetailsTable', () => { it('shows no rows', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('shows one row', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + value + +
+
+
+ `); }); it('shows a row with a title', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+ some title +
+
+
+ + key + + + value + +
+
+
+ `); }); it('shows key and value for large values', () => { - const tree = shallow( + const { container } = render( { ]} />, ); - expect(tree).toMatchSnapshot(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + Lorem Ipsum is simply dummy text of the printing and typesetting + industry. Lorem Ipsum has been the industry's standard dummy text ever + since the 1500s, when an unknown printer took a galley of type and + scrambled it to make a type specimen book. It has survived not only five + centuries, but also the leap into electronic typesetting, remaining + essentially unchanged. It was popularised in the 1960s with the release + of Letraset sheets containing Lorem Ipsum passages, and more recently + with desktop publishing software like Aldus PageMaker including versions + of Lorem Ipsum. + +
+
+
+ `); }); it('shows key and value in row', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + value + +
+
+
+ `); }); it('shows key and JSON value in row', () => { - const tree = shallow( + const { container } = render( , ); - expect(tree).toMatchSnapshot(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + +
+              [
+        {
+          "jsonKey": "jsonValue"
+        }
+      ]
+            
+
+
+
+ `); }); it('does render arrays as JSON', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + +
+              []
+            
+
+
+
+ `); }); - it('does render arrays as JSON', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + it('does render empty object as JSON', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + +
+              {}
+            
+
+
+
+ `); }); it('does not render nulls as JSON', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + null + +
+
+
+ `); }); it('does not render numbers as JSON', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + 10 + +
+
+
+ `); }); it('does not render strings as JSON', () => { - const tree = shallow(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key + + + "some string" + +
+
+
+ `); }); it('does not render booleans as JSON', () => { - const tree = shallow( + const { container } = render( { ]} />, ); - expect(tree).toMatchSnapshot(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key1 + + + true + +
+
+ + key2 + + + false + +
+
+
+ `); }); it('shows keys and values for multiple rows', () => { - const tree = shallow( + const { container } = render( { ['key5', JSON.stringify({ jsonKey: { nestedJsonKey: 'jsonValue' } })], ['key6', 'value6'], ['key6', 'value7'], + ['key', { key: 'foobar', bucket: 'bucket', endpoint: 's3.amazonaws.com' }], ]} />, ); - expect(tree).toMatchSnapshot(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ + key1 + + + value1 + +
+
+ + key2 + +
+              [
+        {
+          "jsonKey": "jsonValue2"
+        }
+      ]
+            
+
+
+ + key3 + + + value3 + +
+
+ + key4 + + + value4 + +
+
+ + key5 + +
+              {
+        "jsonKey": {
+          "nestedJsonKey": "jsonValue"
+        }
+      }
+            
+
+
+ + key6 + + + value6 + +
+
+ + key6 + + + value7 + +
+
+ + key + + + [object Object] + +
+
+
+ `); }); it('does render values with the provided valueComponent', () => { - const ValueComponent: React.FC = ({ value }) => ( - {JSON.stringify(value)} + const ValueComponent: React.FC = ({ value, ...rest }) => ( + + {JSON.stringify(value)} + ); - const { container, getByTestId } = render( - , + const { getByTestId } = render( + , ); - const valueNode = getByTestId('value-component'); - expect(valueNode).toMatchInlineSnapshot(` + expect(getByTestId('value-component')).toMatchInlineSnapshot(` - {"key":"foobar"} + {"key":"foobar","bucket":"bucket","endpoint":"s3.amazonaws.com"} `); }); diff --git a/frontend/src/components/DetailsTable.tsx b/frontend/src/components/DetailsTable.tsx index 04989c93056..a0b9b12f185 100644 --- a/frontend/src/components/DetailsTable.tsx +++ b/frontend/src/components/DetailsTable.tsx @@ -23,7 +23,6 @@ import 'brace'; import 'brace/ext/language_tools'; import 'brace/mode/json'; import 'brace/theme/github'; -import { S3Artifact } from 'third_party/argo-ui/argo_template'; export const css = stylesheet({ key: { @@ -48,22 +47,29 @@ export const css = stylesheet({ }, }); -interface DetailsTableProps { - fields: Array>; +export interface ValueComponentProps { + value?: string | T; + [key: string]: any; +} + +interface DetailsTableProps { + fields: Array>; title?: string; - valueComponent?: React.FC<{ value: S3Artifact }>; + valueComponent?: React.FC>; + valueComponentProps?: { [key: string]: any }; } function isString(x: any): x is string { return typeof x === 'string'; } -const DetailsTable = (props: DetailsTableProps) => { +const DetailsTable = (props: DetailsTableProps) => { + const { fields, title, valueComponent: ValueComponent, valueComponentProps } = props; return ( - {!!props.title &&
{props.title}
} + {!!title &&
{title}
}
- {props.fields.map((f, i) => { + {fields.map((f, i) => { const [key, value] = f; // only try to parse json if value is a string @@ -97,18 +103,17 @@ const DetailsTable = (props: DetailsTableProps) => { // do nothing } } - // If the value isn't a JSON object, just display it as is + // If a ValueComponent and a value is provided, render the value with + // the ValueComponent. Otherwise render the value as a string (empty string if null or undefined). return (
{key} - {(() => { - const ValueComponent = props.valueComponent; - if (ValueComponent && !!value && !isString(value)) { - return ; - } - return value; - })()} + {ValueComponent && value ? ( + + ) : ( + `${value || ''}` + )}
); diff --git a/frontend/src/components/MinioArtifactLink.test.tsx b/frontend/src/components/MinioArtifactLink.test.tsx deleted file mode 100644 index eb2affb869b..00000000000 --- a/frontend/src/components/MinioArtifactLink.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the 'License'); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an 'AS IS' BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import MinioArtifactLink from './MinioArtifactLink'; -import { render } from '@testing-library/react'; - -describe('MinioArtifactLink', () => { - it('handles undefined artifact', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('handles null artifact', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('handles empty artifact', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('handles invalid artifact: no bucket', () => { - const s3Artifact = { - accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, - bucket: '', - endpoint: 'minio.kubeflow', - key: 'bar', - secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, - }; - const { container } = render(); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('handles invalid artifact: no key', () => { - const s3Artifact = { - accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, - bucket: 'foo', - endpoint: 'minio.kubeflow', - key: '', - secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, - }; - const { container } = render(); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('handles s3 artifact', () => { - const s3Artifact = { - accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, - bucket: 'foo', - endpoint: 's3.amazonaws.com', - key: 'bar', - secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, - }; - const { container } = render(); - expect(container).toMatchInlineSnapshot(` - - `); - }); - - it('handles minio artifact', () => { - const minioArtifact = { - accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, - bucket: 'foo', - endpoint: 'minio.kubeflow', - key: 'bar', - secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, - }; - const { container } = render(); - expect(container).toMatchInlineSnapshot(` - - `); - }); -}); diff --git a/frontend/src/components/MinioArtifactLink.tsx b/frontend/src/components/MinioArtifactLink.tsx deleted file mode 100644 index cd1080c26a4..00000000000 --- a/frontend/src/components/MinioArtifactLink.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import { StoragePath, StorageService } from '../lib/WorkflowParser'; -import { S3Artifact } from '../../third_party/argo-ui/argo_template'; -import { buildQuery } from 'src/lib/Utils'; - -const artifactApiUri = ({ source, bucket, key }: StoragePath, namespace?: string) => - `artifacts/get${buildQuery({ source, bucket, key, namespace })}`; - -/** - * A component that renders an artifact link. - */ -const MinioArtifactLink: React.FC<{ - artifact: Partial | null | undefined; - namespace?: string; -}> = ({ artifact, namespace }) => { - if (!artifact || !artifact.key || !artifact.bucket) { - return null; - } - - const { key, bucket, endpoint } = artifact; - const source = endpoint === 's3.amazonaws.com' ? StorageService.S3 : StorageService.MINIO; - const linkText = `${source.toString()}://${bucket}/${key}`; - // Opens in new window safely - return ( - - {linkText} - - ); -}; - -export default MinioArtifactLink; diff --git a/frontend/src/components/MinioArtifactPreview.test.tsx b/frontend/src/components/MinioArtifactPreview.test.tsx new file mode 100644 index 00000000000..80f0e50a6c6 --- /dev/null +++ b/frontend/src/components/MinioArtifactPreview.test.tsx @@ -0,0 +1,372 @@ +/* + * Copyright 2019-2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import MinioArtifactPreview from './MinioArtifactPreview'; +import React from 'react'; +import TestUtils from '../TestUtils'; +import { act, render } from '@testing-library/react'; +import { Apis } from '../lib/Apis'; + +describe('MinioArtifactPreview', () => { + const readFile = jest.spyOn(Apis, 'readFile'); + + beforeEach(() => { + readFile.mockResolvedValue('preview ...'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('handles undefined artifact', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('handles null artifact', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('handles empty artifact', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('handles invalid artifact: no bucket', () => { + const s3Artifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: '', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('handles invalid artifact: no key', () => { + const s3Artifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: '', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('handles string value', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+ teststring +
+ `); + }); + + it('handles boolean value', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+ false +
+ `); + }); + + it('handles s3 artifact', async () => { + const s3Artifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 's3.amazonaws.com', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + + const { container } = render(); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` +
+
+ + s3://foo/bar + +
+ +
+                preview ...
+              
+
+
+
+
+ `); + }); + + it('handles minio artifact', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const container = document.body.appendChild(document.createElement('div')); + await act(async () => { + render(, { container }); + }); + expect(container).toMatchInlineSnapshot(` +
+
+ + minio://foo/bar + +
+ +
+                preview ...
+              
+
+
+
+
+ `); + }); + + it('handles artifact with namespace', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const { container } = render( + , + ); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` +
+
+ + minio://foo/bar + +
+ +
+                preview ...
+              
+
+
+
+
+ `); + }); + + it('handles artifact cleanly even when fetch fails', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + readFile.mockRejectedValue('unknown error'); + const { container } = render(); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` + + `); + }); + + it('handles artifact that previews fully', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const data = `012\n345\n678\n910`; + readFile.mockResolvedValue(data); + const { container } = render( + , + ); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` +
+
+ + minio://foo/bar + +
+ +
+                012
+      345
+      678
+      910
+              
+
+
+
+
+ `); + }); + + it('handles artifact that previews with maxlines', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const data = `012\n345\n678\n910`; + readFile.mockResolvedValue(data); + const { container } = render( + , + ); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` +
+
+ + minio://foo/bar + +
+ +
+                012
+      345
+      ...
+              
+
+
+
+
+ `); + }); + + it('handles artifact that previews with maxbytes', async () => { + const minioArtifact = { + accessKeySecret: { key: 'accesskey', optional: false, name: 'minio' }, + bucket: 'foo', + endpoint: 'minio.kubeflow', + key: 'bar', + secretKeySecret: { key: 'secretkey', optional: false, name: 'minio' }, + }; + const data = `012\n345\n678\n910`; + readFile.mockResolvedValue(data); + const { container } = render( + , + ); + await act(TestUtils.flushPromises); + expect(container).toMatchInlineSnapshot(` +
+
+ + minio://foo/bar + +
+ +
+                012
+      345
+      67
+      ...
+              
+
+
+
+
+ `); + }); +}); diff --git a/frontend/src/components/MinioArtifactPreview.tsx b/frontend/src/components/MinioArtifactPreview.tsx new file mode 100644 index 00000000000..9241ffcc7e8 --- /dev/null +++ b/frontend/src/components/MinioArtifactPreview.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { stylesheet } from 'typestyle'; +import { color } from '../Css'; +import { StorageService, StoragePath } from '../lib/WorkflowParser'; +import { S3Artifact } from '../../third_party/argo-ui/argo_template'; +import { isS3Endpoint } from '../lib/AwsHelper'; +import { Apis } from '../lib/Apis'; +import { ExternalLink } from '../atoms/ExternalLink'; +import { ValueComponentProps } from './DetailsTable'; + +const css = stylesheet({ + root: { + width: '100%', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + preview: { + maxHeight: 250, + overflowY: 'auto', + padding: 3, + backgroundColor: color.lightGrey, + }, +}); + +/** + * Check if a javascript object is an argo S3Artifact object. + * @param value Any javascript object. + */ +export function isS3Artifact(value: any): value is S3Artifact { + return value && value.key && value.bucket; +} + +export interface MinioArtifactPreviewProps extends ValueComponentProps> { + namespace?: string; + maxbytes?: number; + maxlines?: number; +} + +function getStoragePath(value?: string | Partial) { + if (!value || typeof value === 'string') return; + const { key, bucket, endpoint } = value; + if (!bucket || !key) return; + const source = isS3Endpoint(endpoint) ? StorageService.S3 : StorageService.MINIO; + return { source, bucket, key }; +} + +async function getPreview( + storagePath: StoragePath, + namespace: string | undefined, + maxbytes: number, + maxlines?: number, +) { + // TODO how to handle binary data (can probably use magic number to id common mime types) + let data = await Apis.readFile(storagePath, namespace, maxbytes + 1); + // is preview === data and no maxlines + if (data.length <= maxbytes && !maxlines) { + return data; + } + // remove extra byte at the end (we requested maxbytes +1) + data = data.slice(0, maxbytes); + // check num lines + if (maxlines) { + data = data + .split('\n') + .slice(0, maxlines) + .join('\n') + .trim(); + } + return `${data}\n...`; +} + +/** + * A component that renders a preview to an artifact with a link to the full content. + */ +const MinioArtifactPreview: React.FC = ({ + value, + namespace, + maxbytes = 255, + maxlines, +}) => { + const [content, setContent] = React.useState(undefined); + const storagePath = getStoragePath(value); + + React.useEffect(() => { + let cancelled = false; + if (storagePath) { + getPreview(storagePath, namespace, maxbytes, maxlines).then( + data => !cancelled && setContent(data), + error => console.error(error), // TODO error badge on link? + ); + } + return () => { + cancelled = true; + }; + }, [storagePath, namespace, maxbytes, maxlines]); + + if (!storagePath) { + // if value is undefined, null, or an invalid s3artifact object, don't render + if (value === null || value === undefined || typeof value === 'object') return null; + // otherwise render value as string (with default string method) + return {`${value}`}; + } + + // TODO need to come to an agreement how to encode artifact info inside a url + // namespace is currently not supported + const linkText = Apis.buildArtifactUrl(storagePath); + const artifactUrl = Apis.buildReadFileUrl(storagePath, namespace, maxbytes); + + // Opens in new window safely + // TODO use ArtifactLink instead (but it need to support namespace) + return ( +
+ + {linkText} + + {content && ( +
+ +
{content}
+
+
+ )} +
+ ); +}; + +export default MinioArtifactPreview; diff --git a/frontend/src/components/__snapshots__/DetailsTable.test.tsx.snap b/frontend/src/components/__snapshots__/DetailsTable.test.tsx.snap deleted file mode 100644 index 9d154c8fe96..00000000000 --- a/frontend/src/components/__snapshots__/DetailsTable.test.tsx.snap +++ /dev/null @@ -1,571 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DetailsTable does not render booleans as JSON 1`] = ` - -
-
- - key1 - - - true - -
-
- - key2 - - - false - -
-
-
-`; - -exports[`DetailsTable does not render nulls as JSON 1`] = ` - -
-
- - key - - - null - -
-
-
-`; - -exports[`DetailsTable does not render numbers as JSON 1`] = ` - -
-
- - key - - - 10 - -
-
-
-`; - -exports[`DetailsTable does not render strings as JSON 1`] = ` - -
-
- - key - - - "some string" - -
-
-
-`; - -exports[`DetailsTable does render arrays as JSON 1`] = ` - -
-
- - key - - -
-
-
-`; - -exports[`DetailsTable does render arrays as JSON 2`] = ` - -
-
- - key - - -
-
-
-`; - -exports[`DetailsTable shows a row with a title 1`] = ` - -
- some title -
-
-
- - key - - - value - -
-
-
-`; - -exports[`DetailsTable shows key and JSON value in row 1`] = ` - -
-
- - key - - -
-
-
-`; - -exports[`DetailsTable shows key and value for large values 1`] = ` - -
-
- - key - - - Lorem Ipsum is simply dummy text of the printing and typesetting - industry. Lorem Ipsum has been the industry's standard dummy text ever - since the 1500s, when an unknown printer took a galley of type and - scrambled it to make a type specimen book. It has survived not only five - centuries, but also the leap into electronic typesetting, remaining - essentially unchanged. It was popularised in the 1960s with the release - of Letraset sheets containing Lorem Ipsum passages, and more recently - with desktop publishing software like Aldus PageMaker including versions - of Lorem Ipsum. - -
-
-
-`; - -exports[`DetailsTable shows key and value in row 1`] = ` - -
-
- - key - - - value - -
-
-
-`; - -exports[`DetailsTable shows keys and values for multiple rows 1`] = ` - -
-
- - key1 - - - value1 - -
-
- - key2 - - -
-
- - key3 - - - value3 - -
-
- - key4 - - - value4 - -
-
- - key5 - - -
-
- - key6 - - - value6 - -
-
- - key6 - - - value7 - -
-
-
-`; - -exports[`DetailsTable shows no rows 1`] = ` - -
- -`; - -exports[`DetailsTable shows one row 1`] = ` - -
-
- - key - - - value - -
-
-
-`; diff --git a/frontend/src/lib/Apis.test.ts b/frontend/src/lib/Apis.test.ts index f074b6d934a..95f7157305e 100644 --- a/frontend/src/lib/Apis.test.ts +++ b/frontend/src/lib/Apis.test.ts @@ -135,6 +135,32 @@ describe('Apis', () => { }); }); + it('buildReadFileUrl', () => { + expect( + Apis.buildReadFileUrl( + { + bucket: 'testbucket', + key: 'testkey', + source: StorageService.GCS, + }, + 'testnamespace', + 255, + ), + ).toEqual( + 'artifacts/get?source=gcs&namespace=testnamespace&peek=255&bucket=testbucket&key=testkey', + ); + }); + + it('buildArtifactUrl', () => { + expect( + Apis.buildArtifactUrl({ + bucket: 'testbucket', + key: 'testkey', + source: StorageService.GCS, + }), + ).toEqual('gcs://testbucket/testkey'); + }); + it('getTensorboardApp', async () => { const spy = fetchSpy( JSON.stringify({ podAddress: 'http://some/address', tfVersion: '1.14.0' }), diff --git a/frontend/src/lib/Apis.ts b/frontend/src/lib/Apis.ts index 1ba8c4cce4e..2675a010e0a 100644 --- a/frontend/src/lib/Apis.ts +++ b/frontend/src/lib/Apis.ts @@ -205,9 +205,28 @@ export class Apis { /** * Reads file from storage using server. */ - public static readFile(path: StoragePath, namespace?: string): Promise { - const { source, bucket, key } = path; - return this._fetch(`artifacts/get${buildQuery({ source, bucket, key, namespace })}`); + public static readFile(path: StoragePath, namespace?: string, peek?: number): Promise { + return this._fetch(this.buildReadFileUrl(path, namespace, peek)); + } + + /** + * Builds an url for the readFile API to retrieve a workflow artifact. + * @param props object describing the artifact (e.g. source, bucket, and key) + */ + public static buildReadFileUrl(path: StoragePath, namespace?: string, peek?: number) { + const { source, ...rest } = path; + return `artifacts/get${buildQuery({ source: `${source}`, namespace, peek, ...rest })}`; + } + + /** + * Builds an url to visually represents a workflow artifact location. + * @param param.source source of the artifact (e.g. minio, gcs, s3, http, or https) + * @param param.bucket name of the bucket with the artifact (or host for http/https) + * @param param.key key (i.e. path) of the artifact in the bucket + */ + public static buildArtifactUrl({ source, bucket, key }: StoragePath) { + // TODO see https://github.com/kubeflow/pipelines/pull/3725 + return `${source}://${bucket}/${key}`; } /** diff --git a/frontend/src/lib/Utils.test.ts b/frontend/src/lib/Utils.test.ts index f5396cea15e..5de4a894284 100644 --- a/frontend/src/lib/Utils.test.ts +++ b/frontend/src/lib/Utils.test.ts @@ -241,7 +241,7 @@ describe('Utils', () => { describe('generateMinioArtifactUrl', () => { it('handles minio:// URIs', () => { expect(generateMinioArtifactUrl('minio://my-bucket/a/b/c')).toBe( - 'artifacts/get?source=minio&bucket=my-bucket&key=a/b/c', + 'artifacts/get?source=minio&bucket=my-bucket&key=a%2Fb%2Fc', ); }); diff --git a/frontend/src/lib/Utils.tsx b/frontend/src/lib/Utils.tsx index 6d20fd071bd..a0ad3f4d2f5 100644 --- a/frontend/src/lib/Utils.tsx +++ b/frontend/src/lib/Utils.tsx @@ -292,8 +292,21 @@ export function generateGcsConsoleUri(gcsUri: string): string | undefined { const MINIO_URI_PREFIX = 'minio://'; -function generateArtifactUrl(source: string, bucket: string, key: string): string { - return `artifacts/get?source=${source}&bucket=${bucket}&key=${key}`; +/** + * Generates the path component of the url to retrieve an artifact. + * + * @param source source of the artifact. Can be "minio", "s3", "http", "https", or "gcs". + * @param bucket bucket where the artifact is stored, value is assumed to be uri encoded. + * @param key path to the artifact, value is assumed to be uri encoded. + * @param peek number of characters or bytes to return. If not provided, the entire content of the artifact will be returned. + */ +export function generateArtifactUrl( + source: string, + bucket: string, + key: string, + peek?: number, +): string { + return `artifacts/get${buildQuery({ source, bucket, key, peek })}`; } /** @@ -302,7 +315,7 @@ function generateArtifactUrl(source: string, bucket: string, key: string): strin * @param minioUri Minio uri that starts with minio://, like minio://ml-pipeline/path/file * @returns A URL that leads to the artifact data. Returns undefined when minioUri is not valid. */ -export function generateMinioArtifactUrl(minioUri: string): string | undefined { +export function generateMinioArtifactUrl(minioUri: string, peek?: number): string | undefined { if (!minioUri.startsWith(MINIO_URI_PREFIX)) { return undefined; } @@ -312,12 +325,12 @@ export function generateMinioArtifactUrl(minioUri: string): string | undefined { if (matches == null) { return undefined; } - return generateArtifactUrl('minio', matches[1], matches[2]); + return generateArtifactUrl('minio', matches[1], matches[2], peek); } -export function buildQuery(queriesMap: { [key: string]: string | undefined }): string { +export function buildQuery(queriesMap: { [key: string]: string | number | undefined }): string { const queryContent = Object.entries(queriesMap) - .filter((entry): entry is [string, string] => entry[1] != null) + .filter((entry): entry is [string, string | number] => entry[1] != null) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); if (!queryContent) { diff --git a/frontend/src/pages/RunDetails.tsx b/frontend/src/pages/RunDetails.tsx index 3aceca29d17..ff08f2b5a8e 100644 --- a/frontend/src/pages/RunDetails.tsx +++ b/frontend/src/pages/RunDetails.tsx @@ -45,12 +45,12 @@ import CompareTable from '../components/CompareTable'; import DetailsTable from '../components/DetailsTable'; import Graph from '../components/Graph'; import LogViewer from '../components/LogViewer'; -import MinioArtifactLink from '../components/MinioArtifactLink'; import PlotCard from '../components/PlotCard'; import { PodEvents, PodInfo } from '../components/PodYaml'; import { RoutePage, RoutePageFactory, RouteParams } from '../components/Router'; import SidePanel from '../components/SidePanel'; import { ToolbarProps } from '../components/Toolbar'; +import MinioArtifactPreview from '../components/MinioArtifactPreview'; import { HTMLViewerConfig } from '../components/viewers/HTMLViewer'; import { PlotType, ViewerConfig } from '../components/viewers/Viewer'; import { componentMap } from '../components/viewers/ViewerContainer'; @@ -361,9 +361,10 @@ class RunDetails extends Page { ( - - )} + valueComponent={MinioArtifactPreview} + valueComponentProps={{ + namespace: this.state.workflow?.metadata?.namespace, + }} /> @@ -371,9 +372,10 @@ class RunDetails extends Page { ( - - )} + valueComponent={MinioArtifactPreview} + valueComponentProps={{ + namespace: this.state.workflow?.metadata?.namespace, + }} />
)} diff --git a/frontend/src/pages/__snapshots__/RunDetails.test.tsx.snap b/frontend/src/pages/__snapshots__/RunDetails.test.tsx.snap index 59551b1d2e2..9543229f82e 100644 --- a/frontend/src/pages/__snapshots__/RunDetails.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/RunDetails.test.tsx.snap @@ -1062,6 +1062,11 @@ exports[`RunDetails opens side panel when graph node is clicked 1`] = ` fields={Array []} title="Input artifacts" valueComponent={[Function]} + valueComponentProps={ + Object { + "namespace": undefined, + } + } />
@@ -1641,6 +1651,11 @@ exports[`RunDetails switches to inputs/outputs tab in side pane 1`] = ` fields={Array []} title="Input artifacts" valueComponent={[Function]} + valueComponentProps={ + Object { + "namespace": undefined, + } + } />