From 2039532a24077e9ac8ef4029992d8ebc205b0360 Mon Sep 17 00:00:00 2001 From: Maksim Chervonnyi Date: Fri, 15 Nov 2024 10:28:59 +0100 Subject: [PATCH 1/9] Athena: refactoring and minor UI/UX improvements (alphabetize lists, better loading/error visuals) (#4208) Co-authored-by: Alexei Mochalov --- catalog/CHANGELOG.md | 1 + .../Bucket/Queries/Athena/Athena.tsx | 495 +++---- .../Bucket/Queries/Athena/Components.tsx | 27 +- .../Bucket/Queries/Athena/CreatePackage.tsx | 117 +- .../Bucket/Queries/Athena/Database.spec.tsx | 144 ++ .../Bucket/Queries/Athena/Database.tsx | 181 +-- .../Bucket/Queries/Athena/History.tsx | 349 ++--- .../Bucket/Queries/Athena/QueryEditor.tsx | 199 +-- .../Bucket/Queries/Athena/Results.tsx | 6 +- .../Bucket/Queries/Athena/Workgroups.tsx | 134 +- .../__snapshots__/Database.spec.tsx.snap | 553 ++++++++ .../Athena/model/createPackage.spec.ts | 213 +++ .../Queries/Athena/model/createPackage.ts | 81 ++ .../Bucket/Queries/Athena/model/index.ts | 4 + .../Queries/Athena/model/requests.spec.ts | 1199 +++++++++++++++++ .../Bucket/Queries/Athena/model/requests.ts | 754 +++++++++++ .../Queries/Athena/model/state.spec.tsx | 110 ++ .../Bucket/Queries/Athena/model/state.tsx | 149 ++ .../Bucket/Queries/Athena/model/storage.ts | 28 + .../Bucket/Queries/Athena/model/utils.ts | 96 ++ .../Bucket/Queries/ElasticSearch.tsx | 11 +- .../Bucket/Queries/QuerySelect.spec.tsx | 11 +- .../containers/Bucket/Queries/QuerySelect.tsx | 70 +- .../__snapshots__/QuerySelect.spec.tsx.snap | 162 +-- .../Bucket/Queries/requests/athena.ts | 582 -------- .../Bucket/Queries/requests/index.ts | 2 +- .../Bucket/Queries/requests/storage.ts | 10 - catalog/app/utils/AWS/Bedrock/History.spec.ts | 4 +- catalog/app/utils/Sentry.ts | 6 +- 29 files changed, 4095 insertions(+), 1603 deletions(-) create mode 100644 catalog/app/containers/Bucket/Queries/Athena/Database.spec.tsx create mode 100644 catalog/app/containers/Bucket/Queries/Athena/__snapshots__/Database.spec.tsx.snap create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/createPackage.spec.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/createPackage.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/index.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/requests.spec.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/requests.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/state.spec.tsx create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/state.tsx create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/storage.ts create mode 100644 catalog/app/containers/Bucket/Queries/Athena/model/utils.ts delete mode 100644 catalog/app/containers/Bucket/Queries/requests/athena.ts delete mode 100644 catalog/app/containers/Bucket/Queries/requests/storage.ts diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index 5bb0bf9fd17..8758f53dd0a 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [Changed] Athena: improve loading state and errors visuals; fix minor bugs; alphabetize and persist selection in workgroups, catalog names and databases ([#4208](https://github.com/quiltdata/quilt/pull/4208)) - [Changed] Show stack release version in footer ([#4200](https://github.com/quiltdata/quilt/pull/4200)) - [Added] Selective package downloading ([#4173](https://github.com/quiltdata/quilt/pull/4173)) - [Added] Qurator Omni: initial public release ([#4032](https://github.com/quiltdata/quilt/pull/4032), [#4181](https://github.com/quiltdata/quilt/pull/4181)) diff --git a/catalog/app/containers/Bucket/Queries/Athena/Athena.tsx b/catalog/app/containers/Bucket/Queries/Athena/Athena.tsx index b7c20d6f26c..9093a8159e2 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Athena.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Athena.tsx @@ -1,144 +1,156 @@ import cx from 'classnames' -import invariant from 'invariant' import * as R from 'ramda' import * as React from 'react' import * as RRDom from 'react-router-dom' import * as M from '@material-ui/core' import Code from 'components/Code' +import Placeholder from 'components/Placeholder' import Skeleton from 'components/Skeleton' +import * as BucketPreferences from 'utils/BucketPreferences' import * as NamedRoutes from 'utils/NamedRoutes' import QuerySelect from '../QuerySelect' -import * as requests from '../requests' -import { Alert, Section, makeAsyncDataErrorHandler } from './Components' -import CreatePackage from './CreatePackage' +import { Alert, Section } from './Components' import * as QueryEditor from './QueryEditor' -import Results from './Results' import History from './History' +import Results from './Results' import Workgroups from './Workgroups' +import * as Model from './model' +import { doQueryResultsContainManifestEntries } from './model/createPackage' -interface QuerySelectSkeletonProps { - className?: string -} +const CreatePackage = React.lazy(() => import('./CreatePackage')) -function QuerySelectSkeleton({ className }: QuerySelectSkeletonProps) { +function SeeDocsForCreatingPackage() { return ( -
- - -
+ + + + help_outline + + + ) } -const useAthenaQueriesStyles = M.makeStyles((t) => ({ - form: { - margin: t.spacing(3, 0, 0), +const useRelieveMessageStyles = M.makeStyles((t) => ({ + root: { + padding: t.spacing(2), + }, + text: { + animation: '$show 0.3s ease-out', + }, + '@keyframes show': { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, }, })) -interface QueryConstructorProps { - bucket: string - className?: string - initialValue?: requests.athena.QueryExecution - workgroup: requests.athena.Workgroup +const RELIEVE_INITIAL_TIMEOUT = 1000 + +interface RelieveMessageProps { + className: string + messages: string[] } -function QueryConstructor({ - bucket, - className, - initialValue, - workgroup, -}: QueryConstructorProps) { - const [query, setQuery] = React.useState(null) - const [prev, setPrev] = React.useState(null) - const data = requests.athena.useQueries(workgroup, prev) - const classes = useAthenaQueriesStyles() - const [value, setValue] = React.useState( - initialValue || null, +function RelieveMessage({ className, messages }: RelieveMessageProps) { + const classes = useRelieveMessageStyles() + const [relieve, setRelieve] = React.useState('') + const timersData = React.useMemo( + () => + messages.map((message, index) => ({ + timeout: RELIEVE_INITIAL_TIMEOUT * (index + 1) ** 2, + message, + })), + [messages], ) - const handleNamedQueryChange = React.useCallback( - (q: requests.athena.AthenaQuery | null) => { - setQuery(q) - setValue((x) => ({ - ...x, - query: q?.body, - })) - }, - [], + React.useEffect(() => { + const timers = timersData.map(({ timeout, message }) => + setTimeout(() => setRelieve(message), timeout), + ) + return () => { + timers.forEach((timer) => clearTimeout(timer)) + } + }, [timersData]) + if (!relieve) return null + return ( + + + {relieve} + + ) +} - const handleChange = React.useCallback((x: requests.athena.QueryExecution) => { - setValue(x) - setQuery(null) - }, []) +interface QuerySelectSkeletonProps { + className?: string +} +function QuerySelectSkeleton({ className }: QuerySelectSkeletonProps) { return (
- {data.case({ - Ok: (queries) => ( -
- {!!queries.list.length && ( - - onChange={handleNamedQueryChange} - onLoadMore={queries.next ? () => setPrev(queries) : undefined} - queries={queries.list} - value={query} - /> - )} -
- ), - Err: makeAsyncDataErrorHandler('Select query'), - _: () => , - })} - + +
) } -function QueryConstructorSkeleton() { - const classes = useStyles() +interface QueryConstructorProps { + className?: string +} + +function QueryConstructor({ className }: QueryConstructorProps) { + const { query, queries, queryRun } = Model.use() + + if (Model.isError(queries.data)) { + return + } + + if (!Model.hasData(queries.data) || !Model.isReady(query.value)) { + return + } + + if (!queries.data.list.length && !Model.isError(query.value)) { + return No saved queries. + } + return ( <> - - + + label="Select a query" + className={className} + disabled={Model.isLoading(queryRun)} + onChange={query.setValue} + onLoadMore={queries.data.next ? queries.loadMore : undefined} + queries={queries.data.list} + value={Model.isError(query.value) ? null : query.value} + /> + {Model.isError(query.value) && ( + {query.value.message} + )} ) } -interface HistoryContainerProps { - bucket: string - className: string - workgroup: requests.athena.Workgroup -} - -function HistoryContainer({ bucket, className, workgroup }: HistoryContainerProps) { - const [prev, setPrev] = React.useState( - null, - ) - const data = requests.athena.useQueryExecutions(workgroup, prev) +function HistoryContainer() { + const { bucket, executions } = Model.use() + if (Model.isError(executions.data)) { + return + } + if (!Model.hasData(executions.data)) { + return + } return ( -
- {data.case({ - Ok: (executions) => ( - setPrev(executions) : undefined} - workgroup={workgroup} - /> - ), - Err: makeAsyncDataErrorHandler('Executions Data'), - _: () => , - })} -
+ ) } @@ -146,89 +158,90 @@ const useResultsContainerStyles = M.makeStyles((t) => ({ breadcrumbs: { margin: t.spacing(0, 0, 1), }, + relieve: { + left: '50%', + position: 'absolute', + top: t.spacing(7), + transform: 'translateX(-50%)', + }, + table: { + position: 'relative', + }, })) interface ResultsContainerSkeletonProps { bucket: string className: string - queryExecutionId: string - workgroup: requests.athena.Workgroup } -function ResultsContainerSkeleton({ - bucket, - className, - queryExecutionId, - workgroup, -}: ResultsContainerSkeletonProps) { +const relieveMessages = [ + 'Still loading…', + 'This is taking a moment. Thanks for your patience!', + 'Looks like a heavy task! We’re still working on it.', + 'Hang in there, we haven’t forgotten about you! Your request is still being processed.', +] + +function ResultsContainerSkeleton({ bucket, className }: ResultsContainerSkeletonProps) { const classes = useResultsContainerStyles() return (
- + - +
+ + +
) } interface ResultsContainerProps { - bucket: string className: string - queryExecutionId: string - queryResults: requests.athena.QueryResultsResponse - workgroup: requests.athena.Workgroup - onLoadMore?: () => void } -function ResultsContainer({ - bucket, - className, - queryExecutionId, - queryResults, - onLoadMore, - workgroup, -}: ResultsContainerProps) { +function ResultsContainer({ className }: ResultsContainerProps) { const classes = useResultsContainerStyles() + const { bucket, execution, results } = Model.use() + + if (Model.isError(execution)) { + return ( +
+ + +
+ ) + } + + if (Model.isError(results.data)) { + return ( +
+ + +
+ ) + } + + if (!Model.isReady(execution) || !Model.isReady(results.data)) { + return + } + return (
- - {!!queryResults.rows.length && ( - + + {doQueryResultsContainManifestEntries(results.data) ? ( + }> + + + ) : ( + )} - {/* eslint-disable-next-line no-nested-ternary */} - {queryResults.rows.length ? ( - - ) : // eslint-disable-next-line no-nested-ternary - queryResults.queryExecution.error ? ( - - ) : queryResults.queryExecution ? ( - - ) : ( - - )} +
) } @@ -248,19 +261,6 @@ function TableSkeleton({ size }: TableSkeletonProps) { ) } -interface QueryResults { - data: requests.AsyncData - loadMore: (prev: requests.athena.QueryResultsResponse) => void -} - -function useQueryResults(queryExecutionId?: string): QueryResults { - const [prev, setPrev] = React.useState( - null, - ) - const data = requests.athena.useQueryResults(queryExecutionId || null, prev) - return React.useMemo(() => ({ data, loadMore: setPrev }), [data]) -} - const useOverrideStyles = M.makeStyles({ li: { '&::before': { @@ -278,7 +278,7 @@ const useResultsBreadcrumbsStyles = M.makeStyles({ display: 'flex', }, actions: { - marginLeft: 'auto', + margin: '-3px 0 -3px auto', }, breadcrumb: { display: 'flex', @@ -290,19 +290,12 @@ const useResultsBreadcrumbsStyles = M.makeStyles({ interface ResultsBreadcrumbsProps { bucket: string - children: React.ReactNode + children?: React.ReactNode className?: string - queryExecutionId?: string - workgroup: requests.athena.Workgroup } -function ResultsBreadcrumbs({ - bucket, - children, - className, - queryExecutionId, - workgroup, -}: ResultsBreadcrumbsProps) { +function ResultsBreadcrumbs({ bucket, children, className }: ResultsBreadcrumbsProps) { + const { workgroup, queryExecutionId } = Model.use() const classes = useResultsBreadcrumbsStyles() const overrideClasses = useOverrideStyles() const { urls } = NamedRoutes.use() @@ -311,7 +304,7 @@ function ResultsBreadcrumbs({ Query Executions @@ -320,7 +313,7 @@ function ResultsBreadcrumbs({ -
{children}
+ {children &&
{children}
} ) } @@ -335,97 +328,13 @@ const useStyles = M.makeStyles((t) => ({ section: { margin: t.spacing(3, 0, 0), }, + form: { + margin: t.spacing(3, 0, 0), + }, })) -interface AthenaMainProps { - bucket: string - workgroup: string -} - -function AthenaMain({ bucket, workgroup }: AthenaMainProps) { - const classes = useStyles() - const data = requests.athena.useDefaultQueryExecution() - return ( -
- {data.case({ - Ok: (queryExecution) => ( - - ), - Err: makeAsyncDataErrorHandler('Default catalog and database'), - _: () => , - })} - -
- ) -} - -interface AthenaExecutionProps { - bucket: string - queryExecutionId: string - workgroup: string -} - -function AthenaExecution({ bucket, workgroup, queryExecutionId }: AthenaExecutionProps) { - const classes = useStyles() - const results = useQueryResults(queryExecutionId) - return ( -
- {results.data.case({ - Ok: (value) => ( - - ), - _: () => , - })} - - {results.data.case({ - Ok: (queryResults) => ( - results.loadMore(queryResults) : undefined - } - workgroup={workgroup} - /> - ), - _: () => ( - - ), - Err: makeAsyncDataErrorHandler('Query Results Data'), - })} -
- ) -} - -export default function AthenaContainer() { - const { bucket, queryExecutionId, workgroup } = RRDom.useParams<{ - bucket: string - queryExecutionId?: string - workgroup?: string - }>() - invariant(!!bucket, '`bucket` must be defined') +function AthenaContainer() { + const { bucket, queryExecutionId, workgroup } = Model.use() const classes = useStyles() return ( @@ -434,18 +343,38 @@ export default function AthenaContainer() { Athena SQL - - - {workgroup && - (queryExecutionId ? ( - - ) : ( - - ))} + + + {Model.hasData(workgroup.data) && ( +
+
+ + +
+ {queryExecutionId ? ( + + ) : ( +
+ +
+ )} +
+ )} ) } + +export default function Wrapper() { + const prefs = BucketPreferences.use() + return BucketPreferences.Result.match( + { + Ok: ({ ui }) => ( + + + + ), + _: () => , + }, + prefs, + ) +} diff --git a/catalog/app/containers/Bucket/Queries/Athena/Components.tsx b/catalog/app/containers/Bucket/Queries/Athena/Components.tsx index 4521962c352..e4114ff055c 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Components.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Components.tsx @@ -3,11 +3,10 @@ import * as React from 'react' import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' -import * as Sentry from 'utils/Sentry' - const useSectionStyles = M.makeStyles((t) => ({ header: { - margin: t.spacing(0, 0, 1), + ...t.typography.body1, + margin: t.spacing(2, 0, 1), }, })) @@ -20,36 +19,28 @@ interface SectionProps { export function Section({ className, empty, title, children }: SectionProps) { const classes = useSectionStyles() - if (!children && empty) - return {empty} + if (!children && empty) { + return
{empty}
+ } return (
- {title} +
{title}
{children}
) } interface AlertProps { + className?: string error: Error title: string } -export function Alert({ error, title }: AlertProps) { - const sentry = Sentry.use() - - React.useEffect(() => { - sentry('captureException', error) - }, [error, sentry]) - +export function Alert({ className, error, title }: AlertProps) { return ( - + {title} {error.message} ) } - -export function makeAsyncDataErrorHandler(title: string) { - return (error: Error) => -} diff --git a/catalog/app/containers/Bucket/Queries/Athena/CreatePackage.tsx b/catalog/app/containers/Bucket/Queries/Athena/CreatePackage.tsx index 43516279e67..5d0229e22c0 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/CreatePackage.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/CreatePackage.tsx @@ -4,114 +4,16 @@ import * as M from '@material-ui/core' import * as Dialog from 'components/Dialog' import * as AddToPackage from 'containers/AddToPackage' import { usePackageCreationDialog } from 'containers/Bucket/PackageDialog/PackageCreationForm' -import type * as Model from 'model' -import * as s3paths from 'utils/s3paths' -import * as requests from '../requests' +import type * as requests from './model/requests' +import { + ParsedRows, + doQueryResultsContainManifestEntries, + parseQueryResults, +} from './model/createPackage' import Results from './Results' -type ManifestKey = 'hash' | 'logical_key' | 'meta' | 'physical_keys' | 'size' -type ManifestEntryStringified = Record - -function SeeDocsForCreatingPackage() { - return ( - - - - help_outline - - - - ) -} - -function doQueryResultsContainManifestEntries( - queryResults: requests.athena.QueryResultsResponse, -): queryResults is requests.athena.QueryManifestsResponse { - const columnNames = queryResults.columns.map(({ name }) => name) - return ( - columnNames.includes('size') && - columnNames.includes('physical_keys') && - columnNames.includes('logical_key') - ) -} - -// TODO: this name doesn't make sense without `parseManifestEntryStringified` -// merge it into one -function rowToManifestEntryStringified( - row: string[], - columns: requests.athena.QueryResultsColumns, -): ManifestEntryStringified { - return row.reduce((acc, value, index) => { - if (!columns[index].name) return acc - return { - ...acc, - [columns[index].name]: value, - } - }, {} as ManifestEntryStringified) -} - -function parseManifestEntryStringified(entry: ManifestEntryStringified): { - [key: string]: Model.S3File -} | null { - if (!entry.logical_key) return null - if (!entry.physical_keys) return null - try { - const handle = s3paths.parseS3Url( - entry.physical_keys.replace(/^\[/, '').replace(/\]$/, ''), - ) - const sizeParsed = Number(entry.size) - const size = Number.isNaN(sizeParsed) ? 0 : sizeParsed - return { - [entry.logical_key]: { - ...handle, - size, - }, - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(e) - return null - } -} - -interface ParsedRows { - valid: Record - invalid: requests.athena.QueryResultsRows -} - -function parseQueryResults( - queryResults: requests.athena.QueryManifestsResponse, -): ParsedRows { - // TODO: use one reduce-loop - // merge `rowToManifestEntryStringified` and `parseManifestEntryStringified` into one function - const manifestEntries: ManifestEntryStringified[] = queryResults.rows.reduce( - (memo, row) => memo.concat(rowToManifestEntryStringified(row, queryResults.columns)), - [] as ManifestEntryStringified[], - ) - return manifestEntries.reduce( - (memo, entry, index) => { - const parsed = parseManifestEntryStringified(entry) - return parsed - ? // if entry is ok then add it to valid map, and invalid is pristine - { - valid: { - ...memo.valid, - ...parsed, - }, - invalid: memo.invalid, - } - : // if no entry then add original data to list of invalid, and valid is pristine - { - valid: memo.valid, - invalid: [...memo.invalid, queryResults.rows[index]], - } - }, - { valid: {}, invalid: [] } as ParsedRows, - ) -} - const useStyles = M.makeStyles((t) => ({ results: { 'div&': { @@ -123,7 +25,7 @@ const useStyles = M.makeStyles((t) => ({ interface CreatePackageProps { bucket: string - queryResults: requests.athena.QueryResultsResponse + queryResults: requests.QueryResults } export default function CreatePackage({ bucket, queryResults }: CreatePackageProps) { @@ -150,7 +52,6 @@ export default function CreatePackage({ bucket, queryResults }: CreatePackagePro const onPackage = React.useCallback(() => { if (!doQueryResultsContainManifestEntries(queryResults)) return - // TODO: make it lazy, and disable button const parsed = parseQueryResults(queryResults) setEntries(parsed) if (parsed.invalid.length) { @@ -161,10 +62,6 @@ export default function CreatePackage({ bucket, queryResults }: CreatePackagePro } }, [addToPackage, confirm, createDialog, queryResults]) - if (!doQueryResultsContainManifestEntries(queryResults)) { - return - } - return ( <> {createDialog.render({ diff --git a/catalog/app/containers/Bucket/Queries/Athena/Database.spec.tsx b/catalog/app/containers/Bucket/Queries/Athena/Database.spec.tsx new file mode 100644 index 00000000000..582b1a6d433 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/Database.spec.tsx @@ -0,0 +1,144 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' + +import WithGlobalDialogs from 'utils/GlobalDialogs' + +import Database from './Database' + +import * as Model from './model' + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +const noop = () => {} + +const emptyState: Model.State = { + bucket: 'any', + + catalogName: { value: undefined, setValue: noop }, + catalogNames: { data: undefined, loadMore: noop }, + database: { value: undefined, setValue: noop }, + databases: { data: undefined, loadMore: noop }, + execution: undefined, + executions: { data: undefined, loadMore: noop }, + queries: { data: undefined, loadMore: noop }, + query: { value: undefined, setValue: noop }, + queryBody: { value: undefined, setValue: noop }, + results: { data: undefined, loadMore: noop }, + workgroups: { data: undefined, loadMore: noop }, + workgroup: { data: undefined, loadMore: noop }, + + submit: () => Promise.resolve({ id: 'bar' }), + queryRun: undefined, +} + +interface ProviderProps { + children: React.ReactNode + value: Model.State +} + +function Provider({ children, value }: ProviderProps) { + return {children} +} + +describe('containers/Bucket/Queries/Athena/Database', () => { + beforeAll(() => {}) + + afterAll(() => {}) + + it('should render skeletons', () => { + const tree = renderer.create( + + + , + ) + expect(tree).toMatchSnapshot() + }) + + it('should render selected values', () => { + const tree = renderer.create( + + + , + ) + expect(tree).toMatchSnapshot() + }) + + it('should show no value (zero-width space) if selected no value', () => { + const tree = renderer.create( + + + , + ) + expect(tree).toMatchSnapshot() + }) + + it('should disable selection if no spare values', () => { + const tree = renderer.create( + + + , + ) + expect(tree).toMatchSnapshot() + }) + + it('should show error when values failed', () => { + const tree = renderer.create( + + + + + , + ) + expect(tree).toMatchSnapshot() + }) + + it('should show error when data failed', () => { + const tree = renderer.create( + + + + + , + ) + expect(tree).toMatchSnapshot() + }) +}) diff --git a/catalog/app/containers/Bucket/Queries/Athena/Database.tsx b/catalog/app/containers/Bucket/Queries/Athena/Database.tsx index dbe46d38b72..7f73a8e36f6 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Database.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Database.tsx @@ -6,7 +6,17 @@ import * as Lab from '@material-ui/lab' import Skeleton from 'components/Skeleton' import * as Dialogs from 'utils/GlobalDialogs' -import * as requests from '../requests' +import * as Model from './model' +import * as storage from './model/storage' + +const useSelectErrorStyles = M.makeStyles((t) => ({ + button: { + whiteSpace: 'nowrap', + }, + dialog: { + padding: t.spacing(2), + }, +})) interface SelectErrorProps { className?: string @@ -14,18 +24,24 @@ interface SelectErrorProps { } function SelectError({ className, error }: SelectErrorProps) { + const classes = useSelectErrorStyles() const openDialog = Dialogs.use() const handleClick = React.useCallback(() => { openDialog(() => ( - +
{error.message} - +
)) - }, [error.message, openDialog]) + }, [classes.dialog, error.message, openDialog]) return ( + Show more } @@ -39,6 +55,8 @@ function SelectError({ className, error }: SelectErrorProps) { const LOAD_MORE = '__load-more__' +const EMPTY = '__empty__' + interface Response { list: string[] next?: string @@ -84,13 +102,22 @@ function Select({ return ( {label} - + {data.list.map((item) => ( {item} ))} {data.next && Load more} + {!data.list.length && ( + + {value || 'Empty list'} + + )} ) @@ -98,54 +125,78 @@ function Select({ interface SelectCatalogNameProps { className?: string - value: requests.athena.CatalogName | null - onChange: (catalogName: requests.athena.CatalogName) => void } -function SelectCatalogName({ className, value, onChange }: SelectCatalogNameProps) { - const [prev, setPrev] = React.useState( - null, +function SelectCatalogName({ className }: SelectCatalogNameProps) { + const { catalogName, catalogNames, queryRun } = Model.use() + + const handleChange = React.useCallback( + (value) => { + storage.setCatalog(value) + storage.clearDatabase() + catalogName.setValue(value) + }, + [catalogName], + ) + + if (Model.isError(catalogNames.data)) { + return + } + if (Model.isError(catalogName.value)) { + return + } + if (!Model.hasValue(catalogName.value) || !Model.hasData(catalogNames.data)) { + return + } + + return ( + - ), - Err: (error) => , - _: () => , - }) } -interface SelectDatabaseProps - extends Omit { - catalogName: requests.athena.CatalogName | null - onChange: (database: requests.athena.Database) => void - value: requests.athena.Database | null +interface SelectDatabaseProps { + className: string } -function SelectDatabase({ catalogName, onChange, ...rest }: SelectDatabaseProps) { - const [prev, setPrev] = React.useState(null) - const data = requests.athena.useDatabases(catalogName, prev) - return data.case({ - Ok: (response) => ( - + ) } const useStyles = M.makeStyles((t) => ({ @@ -155,8 +206,8 @@ const useStyles = M.makeStyles((t) => ({ }, field: { cursor: 'pointer', + flexBasis: '50%', marginRight: t.spacing(2), - width: '50%', '& input': { cursor: 'pointer', }, @@ -171,42 +222,14 @@ const useStyles = M.makeStyles((t) => ({ interface DatabaseProps { className?: string - value: requests.athena.ExecutionContext | null - onChange: (value: requests.athena.ExecutionContext | null) => void } -export default function Database({ className, value, onChange }: DatabaseProps) { +export default function Database({ className }: DatabaseProps) { const classes = useStyles() - const [catalogName, setCatalogName] = - React.useState(value?.catalogName || null) - const handleCatalogName = React.useCallback( - (name) => { - setCatalogName(name) - onChange(null) - }, - [onChange], - ) - const handleDatabase = React.useCallback( - (database) => { - if (!catalogName) return - onChange({ catalogName, database }) - }, - [catalogName, onChange], - ) return (
- - + +
) } diff --git a/catalog/app/containers/Bucket/Queries/Athena/History.tsx b/catalog/app/containers/Bucket/Queries/Athena/History.tsx index 2653c70a7be..23de3ea9811 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/History.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/History.tsx @@ -2,16 +2,15 @@ import cx from 'classnames' import * as dateFns from 'date-fns' import * as R from 'ramda' import * as React from 'react' +import * as RRDom from 'react-router-dom' import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' import * as Notifications from 'containers/Notifications' import * as NamedRoutes from 'utils/NamedRoutes' -import Link from 'utils/StyledLink' import copyToClipboard from 'utils/clipboard' -import { trimCenter } from 'utils/string' -import * as requests from '../requests' +import * as Model from './model' const useToggleButtonStyles = M.makeStyles({ root: { @@ -58,133 +57,154 @@ function Date({ date }: DateProps) { return {formatted} } -interface QueryDateCompletedProps { - bucket: string - queryExecution: requests.athena.QueryExecution - workgroup: requests.athena.Workgroup -} - -function QueryDateCompleted({ - bucket, - queryExecution, - workgroup, -}: QueryDateCompletedProps) { - const { urls } = NamedRoutes.use() - if (queryExecution.status !== 'SUCCEEDED') { - return - } - return ( - - - - ) -} - -interface CopyButtonProps { - queryExecution: requests.athena.QueryExecution -} - -function CopyButton({ queryExecution }: CopyButtonProps) { - const { push } = Notifications.use() - const handleCopy = React.useCallback(() => { - if (queryExecution.query) { - copyToClipboard(queryExecution.query) - push('Query has been copied to clipboard') - } - }, [push, queryExecution.query]) - return ( - - content_copy - - ) -} - const useFullQueryRowStyles = M.makeStyles((t) => ({ - cell: { - paddingBottom: 0, - paddingTop: 0, - }, - collapsed: { - borderBottom: 0, + root: { + borderBottom: `1px solid ${t.palette.divider}`, + padding: t.spacing(2, 7.5), }, query: { maxHeight: t.spacing(30), maxWidth: '100%', overflow: 'auto', + margin: t.spacing(0, 0, 2), + whiteSpace: 'pre-wrap', + }, + button: { + '& + &': { + marginLeft: t.spacing(1), + }, }, })) interface FullQueryRowProps { expanded: boolean - queryExecution: requests.athena.QueryExecution + query: string } -function FullQueryRow({ expanded, queryExecution }: FullQueryRowProps) { +function FullQueryRow({ expanded, query }: FullQueryRowProps) { + const { push } = Notifications.use() + const { queryBody } = Model.use() const classes = useFullQueryRowStyles() + const handleInsert = React.useCallback(() => { + queryBody.setValue(query) + push('Query has been pasted into editor') + }, [push, queryBody, query]) + const handleCopy = React.useCallback(() => { + copyToClipboard(query) + push('Query has been copied to clipboard') + }, [push, query]) return ( - - - {!!expanded && } - - - -
{queryExecution.query}
-
-
-
+ +
+
{query}
+ content_copy} + variant="outlined" + > + Copy + + replay} + variant="outlined" + > + Paste into query editor + +
+
) } -interface ExecutionProps { - bucket: string - queryExecution: requests.athena.QueryExecution - workgroup: requests.athena.Workgroup +const useRowStyles = M.makeStyles((t) => ({ + root: { + alignItems: 'center', + display: 'grid', + gridColumnGap: t.spacing(2), + gridTemplateColumns: '30px auto 160px 160px 160px', + padding: t.spacing(0, 2), + lineHeight: `${t.spacing(4)}px`, + borderBottom: `1px solid ${t.palette.divider}`, + whiteSpace: 'nowrap', + }, +})) + +interface RowProps { + className: string + children: React.ReactNode } -function Execution({ bucket, queryExecution, workgroup }: ExecutionProps) { - const [expanded, setExpanded] = React.useState(false) - const onToggle = React.useCallback(() => setExpanded(!expanded), [expanded]) +function Row({ className, children }: RowProps) { + const classes = useRowStyles() + return
{children}
+} + +interface LinkCellProps { + children: React.ReactNode + className: string + to?: string +} - if (queryExecution.error) +function LinkCell({ children, className, to }: LinkCellProps) { + if (to) { return ( - - - {queryExecution.error.message} - - + + {children} + ) + } + return {children} +} + +const useExecutionStyles = M.makeStyles((t) => ({ + hover: { + '&:has($link:hover)': { + background: t.palette.action.hover, + }, + }, + failed: { + color: t.palette.text.disabled, + }, + link: {}, + query: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +})) + +interface ExecutionProps { + to?: string + queryExecution: Model.QueryExecution +} + +function Execution({ to, queryExecution }: ExecutionProps) { + const classes = useExecutionStyles() + const [expanded, setExpanded] = React.useState(false) + const onToggle = React.useCallback(() => setExpanded(!expanded), [expanded]) return ( <> - - - - - {trimCenter(queryExecution.query || '', 50)} - + + + + {queryExecution.query} + + {queryExecution.status || 'UNKNOWN'} - - + + - - - - - + + + + + {queryExecution.query && ( - + )} ) @@ -199,48 +219,42 @@ function Empty() { ) } +function isFailedExecution( + x: Model.QueryExecutionsItem, +): x is Model.QueryExecutionFailed { + return !!(x as Model.QueryExecutionFailed).error +} + const useStyles = M.makeStyles((t) => ({ - queryCell: { - width: '40%', - }, - actionCell: { - width: '24px', - }, header: { - margin: t.spacing(0, 0, 1), + lineHeight: `${t.spacing(4.5)}px`, + fontWeight: 500, }, footer: { + alignItems: 'center', display: 'flex', - padding: t.spacing(1), + padding: t.spacing(1, 2), }, more: { marginLeft: 'auto', }, - table: { - tableLayout: 'fixed', - }, })) interface HistoryProps { bucket: string - executions: requests.athena.QueryExecution[] + executions: Model.QueryExecutionsItem[] onLoadMore?: () => void - workgroup: requests.athena.Workgroup } -export default function History({ - bucket, - executions, - onLoadMore, - workgroup, -}: HistoryProps) { +export default function History({ bucket, executions, onLoadMore }: HistoryProps) { + const { urls } = NamedRoutes.use() const classes = useStyles() const pageSize = 10 const [page, setPage] = React.useState(1) const handlePagination = React.useCallback( - (event, value) => { + (_event, value) => { setPage(value) }, [setPage], @@ -249,8 +263,8 @@ export default function History({ const rowsSorted = React.useMemo( () => R.sort( - (a: requests.athena.QueryExecution, b: requests.athena.QueryExecution) => - b?.completed && a?.completed + (a: Model.QueryExecutionsItem, b: Model.QueryExecutionsItem) => + !isFailedExecution(a) && !isFailedExecution(b) && b?.completed && a?.completed ? b.completed.valueOf() - a.completed.valueOf() : -1, executions, @@ -260,54 +274,55 @@ export default function History({ const rowsPaginated = rowsSorted.slice(pageSize * (page - 1), pageSize * page) const hasPagination = rowsSorted.length > rowsPaginated.length + const { workgroup } = Model.use() + if (!Model.hasValue(workgroup)) return null + return ( - - - - - - Query - Status - Date created - Date completed - - - - {rowsPaginated.map((queryExecution) => ( + <> + + +
+ Query + Status + Date created + Date completed + + {rowsPaginated.map((queryExecution) => + isFailedExecution(queryExecution) ? ( + + {queryExecution.error.message} + + ) : ( - ))} - {!executions.length && ( - - - - - - )} - - - - {(hasPagination || !!onLoadMore) && ( -
- {hasPagination && ( - - )} - {onLoadMore && ( - - Load more - - )} -
- )} - + ), + )} + {!executions.length && } + {(hasPagination || !!onLoadMore) && ( +
+ {hasPagination && ( + + )} + {onLoadMore && ( + + Load more + + )} +
+ )} + + ) } diff --git a/catalog/app/containers/Bucket/Queries/Athena/QueryEditor.tsx b/catalog/app/containers/Bucket/Queries/Athena/QueryEditor.tsx index 584f53ff45f..f3a6b04746a 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/QueryEditor.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/QueryEditor.tsx @@ -1,21 +1,18 @@ import * as React from 'react' import AceEditor from 'react-ace' -import * as RRDom from 'react-router-dom' import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' import 'ace-builds/src-noconflict/mode-sql' import 'ace-builds/src-noconflict/theme-eclipse' -import { useConfirm } from 'components/Dialog' +import Lock from 'components/Lock' import Skeleton from 'components/Skeleton' -import * as Notifications from 'containers/Notifications' -import * as NamedRoutes from 'utils/NamedRoutes' +import * as Dialogs from 'utils/GlobalDialogs' import StyledLink from 'utils/StyledLink' -import * as requests from '../requests' - import Database from './Database' +import * as Model from './model' const ATHENA_REF_INDEX = 'https://aws.amazon.com/athena/' const ATHENA_REF_SQL = @@ -46,23 +43,31 @@ function HelperText() { const useStyles = M.makeStyles((t) => ({ editor: { padding: t.spacing(1), + position: 'relative', }, header: { - margin: t.spacing(0, 0, 1), + margin: t.spacing(2, 0, 1), }, })) -interface EditorFieldProps { - className?: string - onChange: (value: string) => void - query: string -} - -function EditorField({ className, query, onChange }: EditorFieldProps) { +function EditorField() { const classes = useStyles() + const { queryBody, queryRun } = Model.use() + + if (Model.isNone(queryBody.value)) { + return null + } + + if (Model.isError(queryBody.value)) { + return {queryBody.value.message} + } + + if (!Model.hasValue(queryBody.value)) { + return + } return ( -
+
Query body @@ -71,68 +76,19 @@ function EditorField({ className, query, onChange }: EditorFieldProps) { editorProps={{ $blockScrolling: true }} height="200px" mode="sql" - onChange={onChange} + onChange={queryBody.setValue} theme="eclipse" - value={query} + value={queryBody.value || ''} width="100%" /> + {Model.isLoading(queryRun) && }
) } -function useQueryRun( - bucket: string, - workgroup: requests.athena.Workgroup, - queryExecutionId?: string, -) { - const { urls } = NamedRoutes.use() - const history = RRDom.useHistory() - const [loading, setLoading] = React.useState(false) - const [error, setError] = React.useState() - const runQuery = requests.athena.useQueryRun(workgroup) - const { push: notify } = Notifications.use() - const goToExecution = React.useCallback( - (id: string) => history.push(urls.bucketAthenaExecution(bucket, workgroup, id)), - [bucket, history, urls, workgroup], - ) - const onSubmit = React.useCallback( - async (value: string, executionContext: requests.athena.ExecutionContext | null) => { - setLoading(true) - setError(undefined) - try { - const { id } = await runQuery(value, executionContext) - if (id === queryExecutionId) notify('Query execution results remain unchanged') - setLoading(false) - goToExecution(id) - } catch (e) { - setLoading(false) - if (e instanceof Error) { - setError(e) - } else { - throw e - } - } - }, - [goToExecution, notify, runQuery, queryExecutionId], - ) - return React.useMemo( - () => ({ - loading, - error, - onSubmit, - }), - [loading, error, onSubmit], - ) -} - const useFormSkeletonStyles = M.makeStyles((t) => ({ - button: { - height: t.spacing(4), - marginTop: t.spacing(2), - width: t.spacing(14), - }, canvas: { flexGrow: 1, height: t.spacing(27), @@ -157,7 +113,7 @@ const useFormSkeletonStyles = M.makeStyles((t) => ({ })) interface FormSkeletonProps { - className: string + className?: string } function FormSkeleton({ className }: FormSkeletonProps) { @@ -170,18 +126,43 @@ function FormSkeleton({ className }: FormSkeletonProps) {
-
) } +interface FormConfirmProps { + close: () => void + submit: () => void +} + +function FormConfirm({ close, submit }: FormConfirmProps) { + return ( + <> + + Database is not selected. Run the query without it? + + + Close + { + close() + submit() + }} + > + Confirm, run without + + + + ) +} + export { FormSkeleton as Skeleton } const useFormStyles = M.makeStyles((t) => ({ actions: { display: 'flex', justifyContent: 'space-between', - margin: t.spacing(2, 0), + margin: t.spacing(2, 0, 4), [t.breakpoints.up('sm')]: { alignItems: 'center', }, @@ -203,86 +184,38 @@ const useFormStyles = M.makeStyles((t) => ({ })) interface FormProps { - bucket: string - className?: string - onChange: (value: requests.athena.QueryExecution) => void - value: requests.athena.QueryExecution | null - workgroup: requests.athena.Workgroup + className: string } -export function Form({ bucket, className, onChange, value, workgroup }: FormProps) { +export function Form({ className }: FormProps) { const classes = useFormStyles() - const executionContext = React.useMemo( - () => - value?.catalog && value?.db - ? { - catalogName: value.catalog, - database: value.db, - } - : null, - [value], - ) - const confirm = useConfirm({ - onSubmit: (confirmed) => { - if (confirmed) { - if (!value?.query) { - throw new Error('Query is not set') - } - onSubmit(value!.query, executionContext) - } - }, - submitTitle: 'Proceed', - title: 'Execution context is not set', - }) - const { loading, error, onSubmit } = useQueryRun(bucket, workgroup, value?.id) - const handleSubmit = React.useCallback(() => { - if (!value?.query) return - if (!executionContext) { - return confirm.open() + const { submit, queryRun } = Model.use() + + const openDialog = Dialogs.use() + const handleSubmit = React.useCallback(async () => { + const output = await submit(false) + if (output === Model.NO_DATABASE) { + openDialog(({ close }) => submit(true)} />) } - onSubmit(value.query, executionContext) - }, [confirm, executionContext, onSubmit, value]) - const handleExecutionContext = React.useCallback( - (exeContext) => { - if (!exeContext) { - onChange({ ...value, catalog: undefined, db: undefined }) - return - } - const { catalogName, database } = exeContext - onChange({ ...value, catalog: catalogName, db: database }) - }, - [onChange, value], - ) + }, [openDialog, submit]) return (
- {confirm.render( - - Data catalog and database are not set. Run query without them? - , - )} - onChange({ ...value, query })} - query={value?.query || ''} - /> + - {error && ( + {Model.isError(queryRun) && ( - {error.message} + {queryRun.message} )}
- + Run query diff --git a/catalog/app/containers/Bucket/Queries/Athena/Results.tsx b/catalog/app/containers/Bucket/Queries/Athena/Results.tsx index f454f3ca198..e0ba16741df 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Results.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Results.tsx @@ -9,7 +9,7 @@ import log from 'utils/Logging' import * as NamedRoutes from 'utils/NamedRoutes' import * as s3paths from 'utils/s3paths' -import * as requests from '../requests' +import * as Model from './model' function Empty() { return ( @@ -63,9 +63,9 @@ const useResultsStyles = M.makeStyles((t) => ({ interface ResultsProps { className?: string - columns: requests.athena.QueryResultsColumns + columns: Model.QueryResultsColumns onLoadMore?: () => void - rows: requests.athena.QueryResultsRows + rows: Model.QueryResultsRows } export default function Results({ className, columns, onLoadMore, rows }: ResultsProps) { diff --git a/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx b/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx index 14209c44b33..b9713539063 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx @@ -4,40 +4,31 @@ import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' import { docs } from 'constants/urls' -import * as NamedRoutes from 'utils/NamedRoutes' import Skeleton from 'components/Skeleton' +import * as NamedRoutes from 'utils/NamedRoutes' import StyledLink from 'utils/StyledLink' -import * as requests from '../requests' -import * as storage from '../requests/storage' - -import { Alert, Section } from './Components' - -const useStyles = M.makeStyles((t) => ({ - selectWrapper: { - width: '100%', - }, - select: { - padding: t.spacing(1), - }, -})) +import { Alert } from './Components' +import * as Model from './model' +import * as storage from './model/storage' const LOAD_MORE = 'load-more' interface WorkgroupSelectProps { bucket: string - onLoadMore: (workgroups: requests.athena.WorkgroupsResponse) => void - value: requests.athena.Workgroup | null - workgroups: requests.athena.WorkgroupsResponse + disabled?: boolean + onLoadMore: (workgroups: Model.List) => void + value: Model.Workgroup | null + workgroups: Model.List } function WorkgroupSelect({ bucket, + disabled, onLoadMore, value, workgroups, }: WorkgroupSelectProps) { - const classes = useStyles() const { urls } = NamedRoutes.use() const history = RRDom.useHistory() @@ -61,29 +52,27 @@ function WorkgroupSelect({ ) return ( - - - - {workgroups.list.map((name) => ( - - {name} - - ))} - {workgroups.next && ( - - - Load more - - - )} - - - + + Select workgroup + + {workgroups.list.map((name) => ( + + {name} + + ))} + {workgroups.next && ( + + + Load more + + + )} + + ) } @@ -116,54 +105,33 @@ function WorkgroupsEmpty({ error }: WorkgroupsEmptyProps) { ) } -interface RedirectToDefaultWorkgroupProps { - bucket: string - workgroups: requests.athena.WorkgroupsResponse -} - -function RedirectToDefaultWorkgroup({ - bucket, - workgroups, -}: RedirectToDefaultWorkgroupProps) { - const { urls } = NamedRoutes.use() - return ( - - ) -} - interface AthenaWorkgroupsProps { bucket: string - workgroup: requests.athena.Workgroup | null } -export default function AthenaWorkgroups({ bucket, workgroup }: AthenaWorkgroupsProps) { - const [prev, setPrev] = React.useState(null) - const data = requests.athena.useWorkgroups(prev) - return data.case({ - Ok: (workgroups) => { - if (!workgroup && workgroups.defaultWorkgroup) - return - return ( -
}> - {workgroups.list.length && ( - - )} -
- ) - }, - Err: (error) => , - _: () => ( +export default function AthenaWorkgroups({ bucket }: AthenaWorkgroupsProps) { + const { queryRun, workgroup, workgroups } = Model.use() + + if (Model.isError(workgroups.data)) return + if (Model.isError(workgroup.data)) return + if (!Model.hasData(workgroups.data) || !Model.hasData(workgroup.data)) { + return ( <> - ), - }) + ) + } + + if (!workgroups.data.list.length) return + + return ( + + ) } diff --git a/catalog/app/containers/Bucket/Queries/Athena/__snapshots__/Database.spec.tsx.snap b/catalog/app/containers/Bucket/Queries/Athena/__snapshots__/Database.spec.tsx.snap new file mode 100644 index 00000000000..f8826f5a665 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/__snapshots__/Database.spec.tsx.snap @@ -0,0 +1,553 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`containers/Bucket/Queries/Athena/Database should disable selection if no spare values 1`] = ` +
+
+ +
+
+ Empty list +
+ + + + +
+
+
+ +
+
+ Empty list +
+ + + + +
+
+
+`; + +exports[`containers/Bucket/Queries/Athena/Database should render selected values 1`] = ` +
+
+ +
+
+ foo +
+ + + + +
+
+
+ +
+
+ bar +
+ + + + +
+
+
+`; + +exports[`containers/Bucket/Queries/Athena/Database should render skeletons 1`] = ` +
+
+
+
+`; + +exports[`containers/Bucket/Queries/Athena/Database should show error when data failed 1`] = ` +
+
+
+ + + +
+
+ Error +
+
+ +
+
+
+
+ + + +
+
+ Error +
+
+ +
+
+
+`; + +exports[`containers/Bucket/Queries/Athena/Database should show error when values failed 1`] = ` +
+
+
+ + + +
+
+ Error +
+
+ +
+
+
+
+ + + +
+
+ Error +
+
+ +
+
+
+`; + +exports[`containers/Bucket/Queries/Athena/Database should show no value (zero-width space) if selected no value 1`] = ` +
+
+ +
+
+ +
+ + + + +
+
+
+ +
+
+ +
+ + + + +
+
+
+`; diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.spec.ts b/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.spec.ts new file mode 100644 index 00000000000..8dc33cc15c7 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.spec.ts @@ -0,0 +1,213 @@ +import Log from 'utils/Logging' + +import type * as Model from './requests' +import { doQueryResultsContainManifestEntries, parseQueryResults } from './createPackage' + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +describe('containers/Bucket/Queries/Athena/model/createPackage', () => { + describe('parseQueryResults', () => { + it('should return empty', () => { + const results: Model.QueryManifests = { + rows: [], + columns: [], + } + expect(parseQueryResults(results)).toEqual({ + valid: {}, + invalid: [], + }) + }) + + it('should return invalid rows', () => { + const results1: Model.QueryManifests = { + rows: [['s3://foo']], + columns: [{ name: 'physical_key', type: 'varchar' }], + } + const results2: Model.QueryManifests = { + rows: [['s3://foo/a/b/c', 'foo'], ['s3://foo'], ['s3://foo/d/e/f', 'bar', 'baz']], + columns: [ + { name: 'physical_key', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + ], + } + const results3: Model.QueryManifests = { + rows: [['foo', 'bar']], + columns: [ + { name: 'size', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + ], + } + expect(parseQueryResults(results1)).toEqual({ + valid: {}, + invalid: [ + // Not enough columns for a manifest entry + ['s3://foo'], + ], + }) + expect(parseQueryResults(results2)).toEqual({ + valid: { + foo: { + bucket: 'foo', + key: 'a/b/c', + size: 0, + version: undefined, + }, + bar: { + bucket: 'foo', + key: 'd/e/f', + size: 0, + version: undefined, + }, + }, + invalid: [ + // Not enough row elements for a manifest entry + ['s3://foo'], + ], + }) + expect(parseQueryResults(results3)).toEqual({ + valid: {}, + invalid: [ + // Not enough columns for a manifest entry + ['foo', 'bar'], + ], + }) + }) + + it('should return all valid rows', () => { + const results: Model.QueryManifests = { + rows: [ + ['abc', 'a/b/c', '{"a": "b"}', '[s3://a/b/c/d?versionId=def]', '123'], + ['def', 'd/e/f', '{"d": "e"}', '[s3://d/e/f/g?versionId=ghi]', '456', 'extra'], + ['xyz', 'x/y/z', '{"x": "y"}', '[s3://x/y/z/w?versionId=uvw]', '789'], + ], + columns: [ + { name: 'hash', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + { name: 'meta', type: 'varchar' }, + { name: 'physical_keys', type: 'varchar' }, + { name: 'size', type: 'varchar' }, + ], + } + expect(parseQueryResults(results)).toEqual({ + valid: { + 'a/b/c': { + bucket: 'a', + key: 'b/c/d', + size: 123, + version: 'def', + // meta: { a: 'b' }, discarded, not supported for creating packages yet + }, + 'd/e/f': { + bucket: 'd', + key: 'e/f/g', + size: 456, + version: 'ghi', + // meta: { d: 'e' }, discarded, not supported for creating packages yet + }, + 'x/y/z': { + bucket: 'x', + key: 'y/z/w', + size: 789, + version: 'uvw', + // meta: { x: 'y' }, discarded, not supported for creating packages yet + }, + }, + invalid: [], + }) + }) + it('should catch error', () => { + const results: Model.QueryManifests = { + rows: [ + ['abc', 'a/b/c', '{"a": "b"}', '[s3://a/b/c/d?versionId=def]', '123'], + ['def', 'd/e/f', '{"d": "e"}', '[s3://]', '456', 'extra'], + ], + columns: [ + { name: 'hash', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + { name: 'meta', type: 'varchar' }, + { name: 'physical_keys', type: 'varchar' }, + { name: 'size', type: 'varchar' }, + ], + } + const loglevel = Log.getLevel() + Log.setLevel('silent') + expect(parseQueryResults(results)).toEqual({ + valid: { + 'a/b/c': { + bucket: 'a', + key: 'b/c/d', + size: 123, + version: 'def', + // meta: { a: 'b' }, discarded, not supported for creating packages yet + }, + }, + invalid: [['def', 'd/e/f', '{"d": "e"}', '[s3://]', '456', 'extra']], + }) + Log.setLevel(loglevel) + }) + }) + + describe('doQueryResultsContainManifestEntries', () => { + it('does not contain rows', () => { + expect(doQueryResultsContainManifestEntries({ columns: [], rows: [] })).toBe(false) + }) + + it('does not contain valid columns', () => { + expect( + doQueryResultsContainManifestEntries({ + columns: [ + { name: 'foo', type: 'varchar' }, + { name: 'bar', type: 'varchar' }, + ], + rows: [['some']], + }), + ).toBe(false) + }) + + it('does not contain enough columns', () => { + expect( + doQueryResultsContainManifestEntries({ + columns: [ + { name: 'size', type: 'varchar' }, + { name: 'physical_keys', type: 'varchar' }, + ], + rows: [['some']], + }), + ).toBe(false) + expect( + doQueryResultsContainManifestEntries({ + columns: [ + { name: 'size', type: 'varchar' }, + { name: 'physical_key', type: 'varchar' }, + ], + rows: [['some']], + }), + ).toBe(false) + expect( + doQueryResultsContainManifestEntries({ + columns: [ + { name: 'size', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + ], + rows: [['some']], + }), + ).toBe(false) + }) + + it('does contain enough valid data', () => { + expect( + doQueryResultsContainManifestEntries({ + columns: [ + { name: 'size', type: 'varchar' }, + { name: 'physical_key', type: 'varchar' }, + { name: 'logical_key', type: 'varchar' }, + ], + rows: [['some']], + }), + ).toBe(true) + }) + }) +}) diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.ts b/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.ts new file mode 100644 index 00000000000..d7f83ebaa7f --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/createPackage.ts @@ -0,0 +1,81 @@ +import type * as Model from 'model' +import * as s3paths from 'utils/s3paths' + +import Log from 'utils/Logging' +import type * as requests from './requests' + +export function doQueryResultsContainManifestEntries( + queryResults: requests.QueryResults, +): queryResults is requests.QueryManifests { + if (!queryResults.rows.length) return false + const columnNames = queryResults.columns.map(({ name }) => name) + return ( + columnNames.includes('size') && + (columnNames.includes('physical_keys') || columnNames.includes('physical_key')) && + columnNames.includes('logical_key') + ) +} + +type Row = requests.QueryManifests['rows'][0] +function parseRow( + row: Row, + columns: requests.QueryResultsColumns, +): { fail?: undefined; ok: [string, Model.S3File] } | { fail: Row; ok?: undefined } { + try { + const entry = row.reduce( + (acc, value, index) => { + if (!columns[index]?.name) return acc + return { + ...acc, + [columns[index].name]: value, + } + }, + {} as Record, + ) + if (!entry.logical_key) return { fail: row } + if (!entry.physical_key && !entry.physical_keys) return { fail: row } + const handle = entry.physical_key + ? s3paths.parseS3Url(entry.physical_key) + : s3paths.parseS3Url(entry.physical_keys.replace(/^\[/, '').replace(/\]$/, '')) + const sizeParsed = Number(entry.size) + const size = Number.isNaN(sizeParsed) ? 0 : sizeParsed + return { + ok: [ + entry.logical_key, + { + ...handle, + size, + }, + ], + } + } catch (e) { + Log.error(e) + return { fail: row } + } +} + +export interface ParsedRows { + valid: Record + invalid: requests.QueryResultsRows +} + +export function parseQueryResults(queryResults: requests.QueryManifests): ParsedRows { + return queryResults.rows + .map((row) => parseRow(row, queryResults.columns)) + .reduce( + (memo, { ok, fail }) => + ok + ? { + valid: { + ...memo.valid, + [ok[0]]: ok[1], + }, + invalid: memo.invalid, + } + : { + valid: memo.valid, + invalid: [...memo.invalid, fail], + }, + { valid: {}, invalid: [] } as ParsedRows, + ) +} diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/index.ts b/catalog/app/containers/Bucket/Queries/Athena/model/index.ts new file mode 100644 index 00000000000..8c441c4a0ab --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/index.ts @@ -0,0 +1,4 @@ +export type * from './requests' +export { NO_DATABASE } from './requests' +export * from './state' +export * from './utils' diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/requests.spec.ts b/catalog/app/containers/Bucket/Queries/Athena/model/requests.spec.ts new file mode 100644 index 00000000000..bf8a0793922 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/requests.spec.ts @@ -0,0 +1,1199 @@ +import type A from 'aws-sdk/clients/athena' +import { act, renderHook } from '@testing-library/react-hooks' + +import Log from 'utils/Logging' + +import * as Model from './utils' +import * as requests from './requests' + +jest.mock( + 'utils/Logging', + jest.fn(() => ({ + error: jest.fn(), + info: jest.fn(), + })), +) + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +const getStorageKey = jest.fn((): string => '') +jest.mock('utils/storage', () => () => ({ + get: jest.fn(() => getStorageKey()), +})) + +function req(output: O, delay = 100) { + return jest.fn((_x: I, callback: (e: Error | null, d: O) => void) => { + const timer = setTimeout(() => { + callback(null, output) + }, delay) + return { + abort: jest.fn(() => { + clearTimeout(timer) + }), + } + }) +} + +function reqThen(output: (x: I) => O, delay = 100) { + return jest.fn((x: I) => ({ + promise: () => + new Promise((resolve) => { + setTimeout(() => { + resolve(output(x)) + }, delay) + }), + })) +} + +const reqThrow = jest.fn(() => ({ + promise: () => { + throw new Error() + }, +})) + +const reqThrowWith = (o: unknown) => + jest.fn(() => ({ + promise: () => { + throw o + }, + })) + +const batchGetQueryExecution = jest.fn() +const getWorkGroup = jest.fn() +const listDataCatalogs = jest.fn() +const listDatabases = jest.fn() +const listQueryExecutions = jest.fn() +const listWorkGroups = jest.fn() +const getQueryExecution = jest.fn() +const listNamedQueries = jest.fn() +const batchGetNamedQuery = jest.fn() +const getQueryResults = jest.fn() +const startQueryExecution = jest.fn() + +jest.mock('utils/AWS', () => ({ + Athena: { + use: () => ({ + batchGetNamedQuery, + batchGetQueryExecution, + getQueryExecution, + getQueryResults, + getWorkGroup, + listDataCatalogs, + listDatabases, + listNamedQueries, + listQueryExecutions, + listWorkGroups, + startQueryExecution, + }), + }, +})) + +describe('containers/Bucket/Queries/Athena/model/requests', () => { + describe('useCatalogNames', () => { + it('return catalog names', async () => { + listDataCatalogs.mockImplementationOnce( + req({ + DataCatalogsSummary: [{ CatalogName: 'foo' }, { CatalogName: 'bar' }], + }), + ) + const { result, waitForNextUpdate } = renderHook(() => requests.useCatalogNames()) + expect(result.current.data).toBe(undefined) + + await act(async () => { + await waitForNextUpdate() + }) + expect(result.current.data).toMatchObject({ list: ['foo', 'bar'] }) + }) + + it('return empty list', async () => { + listDataCatalogs.mockImplementationOnce( + req({ + DataCatalogsSummary: [], + }), + ) + const { result, waitForNextUpdate } = renderHook(() => requests.useCatalogNames()) + + await act(async () => { + await waitForNextUpdate() + }) + expect(result.current.data).toMatchObject({ list: [] }) + }) + + it('return unknowns on invalid data', async () => { + listDataCatalogs.mockImplementationOnce( + req({ + // @ts-expect-error + DataCatalogsSummary: [{ Nonsense: true }, { Absurd: false }], + }), + ) + const { result, waitForNextUpdate } = renderHook(() => requests.useCatalogNames()) + + await act(async () => { + await waitForNextUpdate() + }) + expect(result.current.data).toMatchObject({ list: ['Unknown', 'Unknown'] }) + }) + + it('return empty list on invalid data', async () => { + listDataCatalogs.mockImplementationOnce( + req({ + // @ts-expect-error + Invalid: [], + }), + ) + const { result, waitForNextUpdate } = renderHook(() => requests.useCatalogNames()) + + await act(async () => { + await waitForNextUpdate() + }) + expect(result.current.data).toMatchObject({ list: [] }) + }) + }) + + describe('useCatalogName', () => { + // hooks doesn't support multiple arguments + // https://github.com/testing-library/react-testing-library/issues/1350 + function useWrapper(props: Parameters) { + return requests.useCatalogName(...props) + } + + it('wait for catalog names list', async () => { + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, null] }, + ) + expect(result.current.value).toBe(undefined) + + const error = new Error('Fail') + await act(async () => { + rerender([error, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(error) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + unmount() + }) + + it('switch catalog when execution query loaded', async () => { + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { catalog: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('bar') + unmount() + }) + + it('select execution catalog when catalog list loaded after execution', async () => { + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([Model.Loading, { catalog: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(Model.Loading) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { catalog: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('bar') + + unmount() + }) + + it('keep selection when execution has catalog that doesnt exist', async () => { + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { catalog: 'baz' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + unmount() + }) + + it('select null when catalog doesnt exist', async () => { + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: [] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(null) + + act(() => { + result.current.setValue('baz') + }) + expect(result.current.value).toBe('baz') + + unmount() + }) + + it('select initial catalog from local storage', async () => { + getStorageKey.mockImplementationOnce(() => 'catalog-bar') + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: ['foo', 'catalog-bar'] }, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('catalog-bar') + + unmount() + }) + }) + + describe('useDatabases', () => { + it('wait for catalogName', async () => { + const { result, rerender, waitForNextUpdate } = renderHook( + (...c: Parameters) => requests.useDatabases(...c), + { + initialProps: undefined, + }, + ) + + await act(async () => { + rerender(Model.Loading) + await waitForNextUpdate() + }) + expect(result.current.data).toBe(Model.Loading) + + const error = new Error('foo') + await act(async () => { + rerender(error) + await waitForNextUpdate() + }) + expect(result.current.data).toBe(error) + }) + + it('return databases', async () => { + listDatabases.mockImplementation( + req({ + DatabaseList: [{ Name: 'bar' }, { Name: 'baz' }], + }), + ) + const { result, waitFor } = renderHook(() => requests.useDatabases('foo')) + + expect((result.all[0] as Model.DataController).data).toBe(undefined) + expect((result.all[1] as Model.DataController).data).toBe(Model.Loading) + await waitFor(() => + expect(result.current.data).toMatchObject({ list: ['bar', 'baz'] }), + ) + }) + + it('handle invalid database', async () => { + listDatabases.mockImplementation( + req({ + // @ts-expect-error + DatabaseList: [{ A: 'B' }, { C: 'D' }], + }), + ) + const { result, waitFor } = renderHook(() => requests.useDatabases('foo')) + await waitFor(() => + expect(result.current.data).toMatchObject({ list: ['Unknown', 'Unknown'] }), + ) + }) + + it('handle invalid list', async () => { + listDatabases.mockImplementation( + req({ + // @ts-expect-error + Foo: 'Bar', + }), + ) + const { result, waitFor } = renderHook(() => requests.useDatabases('foo')) + await waitFor(() => expect(result.current.data).toMatchObject({ list: [] })) + }) + }) + + describe('useDatabase', () => { + function useWrapper(props: Parameters) { + return requests.useDatabase(...props) + } + + it('wait for databases', async () => { + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, null] }, + ) + expect(result.current.value).toBe(undefined) + + await act(async () => { + rerender([Model.Loading, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(Model.Loading) + + const error = new Error('Fail') + await act(async () => { + rerender([error, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(error) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + unmount() + }) + + it('switch database when execution query loaded', async () => { + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { db: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('bar') + + unmount() + }) + + it('select execution db when databases loaded after execution', async () => { + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([Model.Loading, { db: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(Model.Loading) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { db: 'bar' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('bar') + + unmount() + }) + + it('keep selection when execution has db that doesn’t exist', async () => { + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, { db: 'baz' }]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + + unmount() + }) + + it('select null when db doesn’t exist', async () => { + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: [] }, undefined]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe(null) + + act(() => { + result.current.setValue('baz') + }) + expect(result.current.value).toBe('baz') + + unmount() + }) + + it('select initial db from local storage', async () => { + getStorageKey.mockImplementationOnce(() => 'bar') + const { result, rerender, waitForNextUpdate, unmount } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [undefined, undefined] }, + ) + + await act(async () => { + rerender([{ list: ['foo', 'bar'] }, null]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('bar') + + unmount() + }) + }) + + describe('useWorkgroups', () => { + listWorkGroups.mockImplementation( + reqThen(() => ({ + WorkGroups: [{ Name: 'foo' }, { Name: 'bar' }], + })), + ) + + it('return workgroups', async () => { + await act(async () => { + getWorkGroup.mockImplementation( + reqThen(({ WorkGroup: Name }) => ({ + WorkGroup: { + Configuration: { + ResultConfiguration: { + OutputLocation: 'any', + }, + }, + State: 'ENABLED', + Name, + }, + })), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => + expect(result.current.data).toMatchObject({ list: ['bar', 'foo'] }), + ) + unmount() + }) + }) + + it('return only valid workgroups', async () => { + await act(async () => { + getWorkGroup.mockImplementation( + reqThen(({ WorkGroup: Name }) => ({ + WorkGroup: { + Configuration: { + ResultConfiguration: { + OutputLocation: 'any', + }, + }, + State: Name === 'foo' ? 'DISABLED' : 'ENABLED', + Name, + }, + })), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => expect(result.current.data).toMatchObject({ list: ['bar'] })) + unmount() + }) + }) + + it('handle invalid workgroup', async () => { + await act(async () => { + getWorkGroup.mockImplementation( + // @ts-expect-error + reqThen(() => ({ + Invalid: 'foo', + })), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ list: [] }) + unmount() + }) + }) + + it('handle fail in workgroup', async () => { + await act(async () => { + getWorkGroup.mockImplementation(reqThrow) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => typeof result.current.data === 'object') + expect(Log.error).toBeCalledWith( + 'Fetching "bar" workgroup failed:', + expect.any(Error), + ) + expect(Log.error).toBeCalledWith( + 'Fetching "foo" workgroup failed:', + expect.any(Error), + ) + expect(result.current.data).toMatchObject({ list: [] }) + unmount() + }) + }) + + it('handle access denied for workgroup list', async () => { + await act(async () => { + getWorkGroup.mockImplementation( + reqThrowWith({ + code: 'AccessDeniedException', + }), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => typeof result.current.data === 'object') + expect(Log.info).toBeCalledWith( + 'Fetching "bar" workgroup failed: AccessDeniedException', + ) + expect(Log.info).toBeCalledWith( + 'Fetching "foo" workgroup failed: AccessDeniedException', + ) + expect(result.current.data).toMatchObject({ list: [] }) + unmount() + }) + }) + + it('handle invalid list', async () => { + await act(async () => { + listWorkGroups.mockImplementation( + // @ts-expect-error + reqThen(() => ({ + Invalid: [{ Name: 'foo' }, { Name: 'bar' }], + })), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ list: [] }) + unmount() + }) + }) + + it('handle no data in list', async () => { + await act(async () => { + listWorkGroups.mockImplementation( + // @ts-expect-error + reqThen(() => null), + ) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => result.current.data instanceof Error) + expect(Log.error).toBeCalledWith( + new TypeError(`Cannot read properties of null (reading 'WorkGroups')`), + ) + expect(result.current.data).toBeInstanceOf(TypeError) + unmount() + }) + }) + + it('handle fail in list', async () => { + await act(async () => { + listWorkGroups.mockImplementation(reqThrow) + const { result, unmount, waitFor } = renderHook(() => requests.useWorkgroups()) + await waitFor(() => result.current.data instanceof Error) + expect(Log.error).toBeCalledWith(expect.any(Error)) + expect(result.current.data).toBeInstanceOf(Error) + unmount() + }) + }) + }) + + describe('useExecutions', () => { + listQueryExecutions.mockImplementation( + req({ + QueryExecutionIds: ['foo', 'bar'], + }), + ) + it('return results', async () => { + batchGetQueryExecution.mockImplementation( + req({ + QueryExecutions: [ + { + QueryExecutionId: '$foo', + }, + { + QueryExecutionId: '$bar', + }, + ], + UnprocessedQueryExecutionIds: [ + { QueryExecutionId: '$baz', ErrorMessage: 'fail' }, + ], + }), + ) + await act(async () => { + const { result, unmount, waitFor } = renderHook(() => + requests.useExecutions('any'), + ) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ + list: [ + { id: '$foo' }, + { id: '$bar' }, + { id: '$baz', error: new Error('fail') }, + ], + }) + unmount() + }) + }) + }) + + describe('useWaitForQueryExecution', () => { + it('return execution', async () => { + getQueryExecution.mockImplementation( + req({ + QueryExecution: { QueryExecutionId: '$foo', Status: { State: 'SUCCEEDED' } }, + }), + ) + await act(async () => { + const { result, unmount, waitFor } = renderHook(() => + requests.useWaitForQueryExecution('any'), + ) + await waitFor(() => typeof result.current === 'object') + expect(result.current).toMatchObject({ + id: '$foo', + }) + unmount() + }) + }) + }) + + describe('useQueries', () => { + listNamedQueries.mockImplementation( + req({ + NamedQueryIds: ['foo', 'bar'], + }), + ) + it('return results', async () => { + batchGetNamedQuery.mockImplementation( + req({ + NamedQueries: [ + { + Database: 'any', + QueryString: 'SELECT * FROM *', + NamedQueryId: '$foo', + Name: 'Foo', + }, + { + Database: 'any', + QueryString: 'SELECT * FROM *', + NamedQueryId: '$bar', + Name: 'Bar', + }, + ], + }), + ) + await act(async () => { + const { result, unmount, waitFor } = renderHook(() => requests.useQueries('any')) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ + list: [ + { name: 'Bar', key: '$bar', body: 'SELECT * FROM *' }, + { name: 'Foo', key: '$foo', body: 'SELECT * FROM *' }, + ], + }) + unmount() + }) + }) + }) + + describe('useResults', () => { + it('handle empty results', async () => { + getQueryResults.mockImplementation( + req({ + ResultSet: { + Rows: [], + ResultSetMetadata: { + ColumnInfo: [{ Name: 'any', Type: 'some' }], + }, + }, + }), + ) + await act(async () => { + const { result, unmount, waitFor } = renderHook(() => + requests.useResults({ id: 'any' }), + ) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ + rows: [], + columns: [], + }) + unmount() + }) + }) + + it('return results', async () => { + getQueryResults.mockImplementation( + req({ + ResultSet: { + Rows: [ + { + Data: [{ VarCharValue: 'foo' }, { VarCharValue: 'bar' }], + }, + { + Data: [{ VarCharValue: 'bar' }, { VarCharValue: 'baz' }], + }, + ], + ResultSetMetadata: { + ColumnInfo: [ + { Name: 'foo', Type: 'some' }, + { Name: 'bar', Type: 'another' }, + ], + }, + }, + }), + ) + await act(async () => { + const { result, unmount, waitFor } = renderHook(() => + requests.useResults({ id: 'any' }), + ) + await waitFor(() => typeof result.current.data === 'object') + expect(result.current.data).toMatchObject({ + rows: [['bar', 'baz']], + columns: [ + { name: 'foo', type: 'some' }, + { name: 'bar', type: 'another' }, + ], + }) + unmount() + }) + }) + }) + + describe('useQueryRun', () => { + it('return execution id', async () => { + startQueryExecution.mockImplementation( + reqThen(() => ({ + QueryExecutionId: 'foo', + })), + ) + await act(async () => { + const { result, unmount, waitForNextUpdate } = renderHook(() => + requests.useQueryRun({ + workgroup: 'a', + catalogName: 'b', + database: 'c', + queryBody: 'd', + }), + ) + await waitForNextUpdate() + const run = await result.current[1](false) + expect(run).toMatchObject({ + id: 'foo', + }) + unmount() + }) + }) + + it('return error if no execution id', async () => { + startQueryExecution.mockImplementation( + reqThen(() => ({})), + ) + await act(async () => { + const { result, unmount, waitForNextUpdate } = renderHook(() => + requests.useQueryRun({ + workgroup: 'a', + catalogName: 'b', + database: 'c', + queryBody: 'd', + }), + ) + await waitForNextUpdate() + const run = await result.current[1](false) + expect(run).toBeInstanceOf(Error) + expect(Log.error).toBeCalledWith(new Error('No execution id')) + if (Model.isError(run)) { + expect(run.message).toBe('No execution id') + } else { + throw new Error('queryRun is not an error') + } + unmount() + }) + }) + + it('handle fail in request', async () => { + startQueryExecution.mockImplementation(reqThrow) + await act(async () => { + const { result, unmount, waitForNextUpdate } = renderHook(() => + requests.useQueryRun({ + workgroup: 'a', + catalogName: 'b', + database: 'c', + queryBody: 'd', + }), + ) + await waitForNextUpdate() + const run = await result.current[1](false) + expect(run).toBeInstanceOf(Error) + unmount() + }) + }) + }) + + describe('useWorkgroup', () => { + function useWrapper(props: Parameters) { + return requests.useWorkgroup(...props) + } + + it('select requested workgroup if it exists', async () => { + await act(async () => { + const workgroups = { + data: { list: ['foo', 'bar'] }, + loadMore: jest.fn(), + } + const { result, waitFor } = renderHook(() => + useWrapper([workgroups, 'bar', undefined]), + ) + await waitFor(() => typeof result.current.data === 'string') + expect(result.current.data).toBe('bar') + }) + }) + + it('select initial workgroup from storage if valid', async () => { + const storageMock = getStorageKey.getMockImplementation() + getStorageKey.mockImplementation(() => 'bar') + const workgroups = { + data: { list: ['foo', 'bar'] }, + loadMore: jest.fn(), + } + + const { result, waitFor, unmount } = renderHook(() => + useWrapper([workgroups, undefined, undefined]), + ) + + await act(async () => { + await waitFor(() => typeof result.current.data === 'string') + expect(result.current.data).toBe('bar') + }) + getStorageKey.mockImplementation(storageMock) + unmount() + }) + + it('select default workgroup from preferences if valid', async () => { + const workgroups = { + data: { list: ['foo', 'bar'] }, + loadMore: jest.fn(), + } + const preferences = { defaultWorkgroup: 'bar' } + + const { result, waitFor, unmount } = renderHook(() => + useWrapper([workgroups, undefined, preferences]), + ) + + await act(async () => { + await waitFor(() => typeof result.current.data === 'string') + expect(result.current.data).toBe('bar') + }) + unmount() + }) + + it('select the first available workgroup if no requested or default', async () => { + await act(async () => { + const workgroups = { + data: { list: ['foo', 'bar', 'baz'] }, + loadMore: jest.fn(), + } + + const { result, waitFor } = renderHook(() => + useWrapper([workgroups, undefined, undefined]), + ) + + await waitFor(() => typeof result.current.data === 'string') + expect(result.current.data).toBe('foo') + }) + }) + + it('return error if no workgroups are available', async () => { + await act(async () => { + const workgroups = { + data: { list: [] }, + loadMore: jest.fn(), + } + + const { result, waitFor } = renderHook(() => + useWrapper([workgroups, undefined, undefined]), + ) + + await waitFor(() => result.current.data instanceof Error) + if (Model.isError(result.current.data)) { + expect(result.current.data.message).toBe('Workgroup not found') + } else { + throw new Error('Not an error') + } + }) + }) + + it('wait for workgroups', async () => { + const workgroups = { + data: undefined, + loadMore: jest.fn(), + } + + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { initialProps: [workgroups, undefined, undefined] }, + ) + expect(result.current.data).toBeUndefined() + + await act(async () => { + rerender() + await waitForNextUpdate() + }) + expect(result.current.data).toBeUndefined() + unmount() + }) + }) + + describe('useQuery', () => { + function useWrapper(props: Parameters) { + return requests.useQuery(...props) + } + + it('sets query to the one matching the execution query', () => { + const queries = { + list: [ + { key: 'foo', name: 'Foo', body: 'SELECT * FROM foo' }, + { key: 'bar', name: 'Bar', body: 'SELECT * FROM bar' }, + ], + } + const execution = { query: 'SELECT * FROM bar' } + const { result } = renderHook(() => useWrapper([queries, execution])) + + if (Model.hasData(result.current.value)) { + expect(result.current.value.body).toBe('SELECT * FROM bar') + } else { + throw new Error('No data') + } + }) + + it('unsets query if no matching execution query', () => { + const queries = { + list: [ + { key: 'foo', name: 'Foo', body: 'SELECT * FROM foo' }, + { key: 'bar', name: 'Bar', body: 'SELECT * FROM bar' }, + ], + } + const execution = { query: 'SELECT * FROM baz' } + const { result } = renderHook(() => useWrapper([queries, execution])) + + if (Model.hasValue(result.current.value)) { + expect(result.current.value).toBe(null) + } else { + throw new Error('No data') + } + }) + + it('sets query to the first one if no execution query is set', () => { + const queries = { + list: [ + { key: 'foo', name: 'Foo', body: 'SELECT * FROM foo' }, + { key: 'bar', name: 'Bar', body: 'SELECT * FROM bar' }, + ], + } + const execution = {} + const { result } = renderHook(() => useWrapper([queries, execution])) + + if (Model.hasData(result.current.value)) { + expect(result.current.value.body).toBe('SELECT * FROM foo') + } else { + throw new Error('No data') + } + }) + + it('sets query to null if no queries are available', () => { + const queries = { list: [] } + const execution = {} + const { result } = renderHook(() => useWrapper([queries, execution])) + + if (Model.hasValue(result.current.value)) { + expect(result.current.value).toBeNull() + } else { + throw new Error('No data') + } + }) + + it('does not change query if a valid query is already selected', async () => { + const queries = { + list: [ + { key: 'foo', name: 'Foo', body: 'SELECT * FROM foo' }, + { key: 'bar', name: 'Bar', body: 'SELECT * FROM bar' }, + ], + } + const execution = { + query: 'SELECT * FROM bar', + } + const { result, rerender, waitForNextUpdate } = renderHook( + (props: Parameters) => useWrapper(props), + { + initialProps: [queries, execution], + }, + ) + + if (Model.hasData(result.current.value)) { + expect(result.current.value.body).toBe('SELECT * FROM bar') + } else { + throw new Error('No data') + } + await act(async () => { + rerender([ + { + list: [ + { key: 'baz', name: 'Baz', body: 'SELECT * FROM baz' }, + { key: 'foo', name: 'Foo', body: 'SELECT * FROM foo' }, + { key: 'bar', name: 'Bar', body: 'SELECT * FROM bar' }, + ], + }, + execution, + ]) + await waitForNextUpdate() + }) + if (Model.hasData(result.current.value)) { + expect(result.current.value.body).toBe('SELECT * FROM bar') + } else { + throw new Error('No data') + } + }) + }) + + describe('useQueryBody', () => { + function useWrapper(props: Parameters) { + return requests.useQueryBody(...props) + } + + it('sets query body from query if query is ready', () => { + const query = { name: 'Foo', key: 'foo', body: 'SELECT * FROM foo' } + const execution = {} + const setQuery = jest.fn() + + const { result } = renderHook(() => useWrapper([query, setQuery, execution])) + + if (Model.hasData(result.current.value)) { + expect(result.current.value).toBe('SELECT * FROM foo') + } else { + throw new Error('No data') + } + }) + + it('sets query body from execution if query is not ready', () => { + const query = null + const execution = { query: 'SELECT * FROM bar' } + const setQuery = jest.fn() + + const { result } = renderHook(() => useWrapper([query, setQuery, execution])) + + if (Model.hasData(result.current.value)) { + expect(result.current.value).toBe('SELECT * FROM bar') + } else { + throw new Error('No data') + } + }) + + it('sets query body to null if query is an error', () => { + const query = new Error('Query failed') + const execution = {} + const setQuery = jest.fn() + + const { result } = renderHook(() => useWrapper([query, setQuery, execution])) + + if (Model.hasValue(result.current.value)) { + expect(result.current.value).toBeNull() + } else { + throw new Error('Unexpected state') + } + }) + + it('does not change value if query and execution are both not ready', async () => { + const query = null + const execution = null + const setQuery = jest.fn() + + const { result, rerender, waitForNextUpdate } = renderHook( + (x: Parameters) => useWrapper(x), + { + initialProps: [query, setQuery, execution], + }, + ) + + expect(result.current.value).toBeUndefined() + act(() => { + result.current.setValue('foo') + }) + expect(result.current.value).toBe('foo') + + await act(async () => { + rerender([query, setQuery, execution]) + await waitForNextUpdate() + }) + expect(result.current.value).toBe('foo') + }) + + it('updates query body and resets query when handleValue is called', async () => { + const query = { name: 'Foo', key: 'foo', body: 'SELECT * FROM foo' } + const execution = {} + const setQuery = jest.fn() + + const { result } = renderHook(() => useWrapper([query, setQuery, execution])) + + act(() => { + result.current.setValue('SELECT * FROM bar') + }) + + expect(result.current.value).toBe('SELECT * FROM bar') + expect(setQuery).toHaveBeenCalledWith(null) + }) + + it('retains value when execution and query are initially empty but later updates', async () => { + const initialQuery = null + const initialExecution = null + const setQuery = jest.fn() + + const { result, rerender, waitForNextUpdate } = renderHook( + (props: Parameters) => useWrapper(props), + { + initialProps: [initialQuery, setQuery, initialExecution], + }, + ) + + expect(result.current.value).toBeUndefined() + + await act(async () => { + rerender([ + { key: 'up', name: 'Updated', body: 'SELECT * FROM updated' }, + setQuery, + initialExecution, + ]) + await waitForNextUpdate() + }) + + if (Model.hasData(result.current.value)) { + expect(result.current.value).toBe('SELECT * FROM updated') + } else { + throw new Error('No data') + } + }) + }) +}) diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/requests.ts b/catalog/app/containers/Bucket/Queries/Athena/model/requests.ts new file mode 100644 index 00000000000..227e6ecbde2 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/requests.ts @@ -0,0 +1,754 @@ +import type Athena from 'aws-sdk/clients/athena' +import * as React from 'react' +import * as Sentry from '@sentry/react' + +import * as AWS from 'utils/AWS' +import * as BucketPreferences from 'utils/BucketPreferences' +import Log from 'utils/Logging' + +import * as storage from './storage' +import * as Model from './utils' + +export interface Query { + // TODO: database? + body: string + description?: string + key: string + name: string +} + +function parseNamedQuery(query: Athena.NamedQuery): Query { + // TODO: database: query.Database! + return { + body: query.QueryString, + description: query.Description, + key: query.NamedQueryId!, + name: query.Name, + } +} + +function listIncludes(list: string[], value: string): boolean { + return list.map((x) => x.toLowerCase()).includes(value.toLowerCase()) +} + +export type Workgroup = string + +interface WorkgroupArgs { + athena: Athena + workgroup: Workgroup +} + +async function fetchWorkgroup({ + athena, + workgroup, +}: WorkgroupArgs): Promise { + try { + const workgroupOutput = await athena.getWorkGroup({ WorkGroup: workgroup }).promise() + if ( + workgroupOutput?.WorkGroup?.Configuration?.ResultConfiguration?.OutputLocation && + workgroupOutput?.WorkGroup?.State === 'ENABLED' && + workgroupOutput?.WorkGroup?.Name + ) { + return workgroupOutput.WorkGroup.Name + } + return null + } catch (error) { + if ((error as $TSFixMe).code === 'AccessDeniedException') { + Log.info(`Fetching "${workgroup}" workgroup failed: ${(error as $TSFixMe).code}`) + } else { + Log.error(`Fetching "${workgroup}" workgroup failed:`, error) + } + return null + } +} + +async function fetchWorkgroups( + athena: Athena, + prev: Model.List | null, +): Promise> { + try { + const workgroupsOutput = await athena + .listWorkGroups({ NextToken: prev?.next }) + .promise() + const parsed = (workgroupsOutput.WorkGroups || []) + .map(({ Name }) => Name || '') + .filter(Boolean) + .sort() + const available = ( + await Promise.all(parsed.map((workgroup) => fetchWorkgroup({ athena, workgroup }))) + ).filter(Boolean) + const list = (prev?.list || []).concat(available as Workgroup[]) + return { + list, + next: workgroupsOutput.NextToken, + } + } catch (e) { + Log.error(e) + throw e + } +} + +export function useWorkgroups(): Model.DataController> { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState | null>(null) + const [data, setData] = React.useState>>() + React.useEffect(() => { + let mounted = true + if (!athena) return + fetchWorkgroups(athena, prev) + .then((d) => mounted && setData(d)) + .catch((d) => mounted && setData(d)) + return () => { + mounted = false + } + }, [athena, prev]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +export function useWorkgroup( + workgroups: Model.DataController>, + requestedWorkgroup?: Workgroup, + preferences?: BucketPreferences.AthenaPreferences, +): Model.DataController { + const [data, setData] = React.useState>() + React.useEffect(() => { + if (!Model.hasData(workgroups.data)) return + setData((d) => { + if (!Model.hasData(workgroups.data)) return d + if (requestedWorkgroup && listIncludes(workgroups.data.list, requestedWorkgroup)) { + return requestedWorkgroup + } + const initialWorkgroup = storage.getWorkgroup() || preferences?.defaultWorkgroup + if (initialWorkgroup && listIncludes(workgroups.data.list, initialWorkgroup)) { + return initialWorkgroup + } + return workgroups.data.list[0] || new Error('Workgroup not found') + }) + }, [preferences, requestedWorkgroup, workgroups]) + return React.useMemo( + () => Model.wrapData(data, workgroups.loadMore), + [data, workgroups.loadMore], + ) +} + +export interface QueryExecution { + catalog?: string + completed?: Date + created?: Date + db?: string + id?: string + outputBucket?: string + query?: string + status?: string // 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' + workgroup?: Athena.WorkGroupName +} + +export interface QueryExecutionFailed { + id?: string + error: Error +} + +function parseQueryExecution(queryExecution: Athena.QueryExecution): QueryExecution { + return { + catalog: queryExecution?.QueryExecutionContext?.Catalog, + completed: queryExecution?.Status?.CompletionDateTime, + created: queryExecution?.Status?.SubmissionDateTime, + db: queryExecution?.QueryExecutionContext?.Database, + id: queryExecution?.QueryExecutionId, + outputBucket: queryExecution?.ResultConfiguration?.OutputLocation, + query: queryExecution?.Query, + status: queryExecution?.Status?.State, + workgroup: queryExecution?.WorkGroup, + } +} + +function parseQueryExecutionError( + error: Athena.UnprocessedQueryExecutionId, +): QueryExecutionFailed { + return { + error: new Error(error?.ErrorMessage || 'Unknown'), + id: error?.QueryExecutionId, + } +} + +export type QueryExecutionsItem = QueryExecution | QueryExecutionFailed + +export function useExecutions( + workgroup: Model.Data, + queryExecutionId?: string, +): Model.DataController> { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState | null>(null) + const [data, setData] = React.useState>>() + + React.useEffect(() => { + if (queryExecutionId) return + if (!Model.hasValue(workgroup)) { + setData(workgroup) + return + } + setData(Model.Loading) + let batchRequest: ReturnType['batchGetQueryExecution']> + + const request = athena?.listQueryExecutions( + { WorkGroup: workgroup, NextToken: prev?.next }, + (error, d) => { + const { QueryExecutionIds, NextToken: next } = d || {} + if (error) { + Sentry.captureException(error) + setData(error) + return + } + if (!QueryExecutionIds || !QueryExecutionIds.length) { + setData({ + list: [], + next, + }) + return + } + batchRequest = athena?.batchGetQueryExecution( + { QueryExecutionIds }, + (batchErr, batchData) => { + const { QueryExecutions, UnprocessedQueryExecutionIds } = batchData || {} + if (batchErr) { + Sentry.captureException(batchErr) + setData(batchErr) + return + } + const parsed = (QueryExecutions || []) + .map(parseQueryExecution) + .concat((UnprocessedQueryExecutionIds || []).map(parseQueryExecutionError)) + const list = (prev?.list || []).concat(parsed) + setData({ + list, + next, + }) + }, + ) + }, + ) + return () => { + request?.abort() + batchRequest?.abort() + } + }, [athena, workgroup, prev, queryExecutionId]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +function useFetchQueryExecution( + QueryExecutionId?: string, +): [Model.Value, () => void] { + const athena = AWS.Athena.use() + const [data, setData] = React.useState>( + QueryExecutionId ? undefined : null, + ) + const [counter, setCounter] = React.useState(0) + React.useEffect(() => { + if (!QueryExecutionId) { + setData(null) + return + } + setData(Model.Loading) + const request = athena?.getQueryExecution({ QueryExecutionId }, (error, d) => { + const { QueryExecution } = d || {} + if (error) { + Sentry.captureException(error) + setData(error) + return + } + const status = QueryExecution?.Status?.State + const parsed = QueryExecution + ? parseQueryExecution(QueryExecution) + : { id: QueryExecutionId } + switch (status) { + case 'FAILED': + case 'CANCELLED': { + const reason = QueryExecution?.Status?.StateChangeReason || '' + setData(new Error(`${status}: ${reason}`)) + break + } + case 'SUCCEEDED': + setData(parsed) + break + case 'QUEUED': + case 'RUNNING': + break + default: + setData(new Error('Unknown query execution status')) + break + } + }) + return () => request?.abort() + }, [athena, QueryExecutionId, counter]) + const fetch = React.useCallback(() => setCounter((prev) => prev + 1), []) + return [data, fetch] +} + +export function useWaitForQueryExecution( + queryExecutionId?: string, +): Model.Value { + const [data, fetch] = useFetchQueryExecution(queryExecutionId) + const [timer, setTimer] = React.useState(null) + React.useEffect(() => { + const t = setInterval(fetch, 1000) + setTimer(t) + return () => clearInterval(t) + }, [queryExecutionId, fetch]) + React.useEffect(() => { + if (Model.isReady(data) && timer) { + clearInterval(timer) + } + }, [timer, data]) + return data +} + +export type QueryResultsValue = Athena.datumString + +interface QueryResultsColumnInfo { + name: T + type: Athena.String +} + +export type QueryResultsColumns = QueryResultsColumnInfo[] +type Row = QueryResultsValue[] +export type QueryResultsRows = Row[] + +export interface QueryResults { + columns: QueryResultsColumns + next?: string + rows: QueryResultsRows +} + +export type ManifestKey = + | 'hash' + | 'logical_key' + | 'meta' + | 'physical_key' + | 'physical_keys' + | 'size' + +export interface QueryManifests extends QueryResults { + columns: QueryResultsColumns +} + +const emptyRow: Row = [] +const emptyList: QueryResultsRows = [] +const emptyColumns: QueryResultsColumns = [] + +export interface QueryRun { + id: string +} + +export type CatalogName = string + +export type Database = string + +export type QueryId = string +export interface QueriesIdsResponse { + list: QueryId[] + next?: string +} + +export function useQueries( + workgroup: Model.Data, +): Model.DataController> { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState | null>(null) + const [data, setData] = React.useState>>() + React.useEffect(() => { + if (!Model.hasValue(workgroup)) { + setData(workgroup) + return + } + setData(Model.Loading) + + let batchRequest: ReturnType['batchGetNamedQuery']> + const request = athena?.listNamedQueries( + { + WorkGroup: workgroup, + NextToken: prev?.next, + }, + async (error, d) => { + const { NamedQueryIds, NextToken: next } = d || {} + if (error) { + Sentry.captureException(error) + setData(error) + return + } + if (!NamedQueryIds || !NamedQueryIds.length) { + setData({ + list: prev?.list || [], + next, + }) + return + } + batchRequest = athena?.batchGetNamedQuery( + { NamedQueryIds }, + (batchErr, batchData) => { + const { NamedQueries } = batchData || {} + if (batchErr) { + Sentry.captureException(batchErr) + setData(batchErr) + return + } + const parsed = (NamedQueries || []) + .map(parseNamedQuery) + .sort((a, b) => a.name.localeCompare(b.name)) + const list = (prev?.list || []).concat(parsed) + setData({ + list, + next, + }) + }, + ) + }, + ) + return () => { + request?.abort() + batchRequest?.abort() + } + }, [athena, workgroup, prev]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +export function useResults( + execution: Model.Value, +): Model.DataController { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState(null) + const [data, setData] = React.useState>() + + React.useEffect(() => { + if (execution === null) { + setData(undefined) + return + } + if (!Model.hasValue(execution)) { + setData(execution) + return + } + if (!execution.id) { + setData(new Error('Query execution has no ID')) + return + } + + const request = athena?.getQueryResults( + { QueryExecutionId: execution.id, NextToken: prev?.next }, + (error, d) => { + const { ResultSet, NextToken: next } = d || {} + if (error) { + Sentry.captureException(error) + setData(error) + return + } + const parsed = + ResultSet?.Rows?.map( + (row) => row?.Data?.map((item) => item?.VarCharValue || '') || emptyRow, + ) || emptyList + const rows = [...(prev?.rows || emptyList), ...parsed] + if (!rows.length) { + setData({ + rows: [], + columns: [], + next, + }) + return + } + const columns = + ResultSet?.ResultSetMetadata?.ColumnInfo?.map(({ Name, Type }) => ({ + name: Name, + type: Type, + })) || emptyColumns + const isHeadColumns = columns.every(({ name }, index) => name === rows[0][index]) + setData({ + rows: isHeadColumns ? rows.slice(1) : rows, + columns, + next, + }) + }, + ) + return () => request?.abort() + }, [athena, execution, prev]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +export function useDatabases( + catalogName: Model.Value, +): Model.DataController> { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState | null>(null) + const [data, setData] = React.useState>>() + React.useEffect(() => { + if (!Model.hasData(catalogName)) { + setData(catalogName || undefined) + return + } + setData(Model.Loading) + const request = athena?.listDatabases( + { + CatalogName: catalogName, + NextToken: prev?.next, + }, + (error, d) => { + const { DatabaseList, NextToken: next } = d || {} + if (error) { + Sentry.captureException(error) + setData(error) + return + } + const list = DatabaseList?.map(({ Name }) => Name || 'Unknown').sort() || [] + setData({ list: (prev?.list || []).concat(list), next }) + }, + ) + return () => request?.abort() + }, [athena, catalogName, prev]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +export function useDatabase( + databases: Model.Data>, + execution: Model.Value, +): Model.ValueController { + const [value, setValue] = React.useState>() + React.useEffect(() => { + if (!Model.hasData(databases)) { + setValue(databases) + return + } + setValue((v) => { + if ( + Model.hasData(execution) && + execution.db && + listIncludes(databases.list, execution.db) + ) { + return execution.db + } + if (Model.hasData(v) && listIncludes(databases.list, v)) { + return v + } + const initialDatabase = storage.getDatabase() + if (initialDatabase && listIncludes(databases.list, initialDatabase)) { + return initialDatabase + } + return databases.list[0] || null + }) + }, [databases, execution]) + return React.useMemo(() => Model.wrapValue(value, setValue), [value]) +} + +export function useCatalogNames(): Model.DataController> { + const athena = AWS.Athena.use() + const [prev, setPrev] = React.useState | null>(null) + const [data, setData] = React.useState>>() + React.useEffect(() => { + const request = athena?.listDataCatalogs({ NextToken: prev?.next }, (error, d) => { + const { DataCatalogsSummary, NextToken: next } = d || {} + setData(Model.Loading) + if (error) { + Sentry.captureException(error) + setData(error) + return + } + const list = DataCatalogsSummary?.map(({ CatalogName }) => CatalogName || 'Unknown') + setData({ + list: (prev?.list || []).concat(list || []), + next, + }) + }) + return () => request?.abort() + }, [athena, prev]) + return React.useMemo(() => Model.wrapData(data, setPrev), [data]) +} + +export function useCatalogName( + catalogNames: Model.Data>, + execution: Model.Value, +): Model.ValueController { + const [value, setValue] = React.useState>() + React.useEffect(() => { + if (!Model.hasData(catalogNames)) { + setValue(catalogNames) + return + } + setValue((v) => { + if ( + Model.hasData(execution) && + execution.catalog && + listIncludes(catalogNames.list, execution.catalog) + ) { + return execution.catalog + } + if (Model.hasData(v) && listIncludes(catalogNames.list, v)) { + return v + } + const initialCatalogName = storage.getCatalog() + if (initialCatalogName && listIncludes(catalogNames.list, initialCatalogName)) { + return initialCatalogName + } + return catalogNames.list[0] || null + }) + }, [catalogNames, execution]) + return React.useMemo(() => Model.wrapValue(value, setValue), [value]) +} + +export function useQuery( + queries: Model.Data>, + execution: Model.Value, +): Model.ValueController { + const [value, setValue] = React.useState>() + React.useEffect(() => { + if (!Model.hasData(queries)) { + setValue(queries) + return + } + setValue((v) => { + if (Model.hasData(execution) && execution.query) { + const executionQuery = queries.list.find((q) => execution.query === q.body) + return executionQuery || null + } + if (Model.hasData(v) && queries.list.includes(v)) { + return v + } + return queries.list[0] || null + }) + }, [execution, queries]) + return React.useMemo(() => Model.wrapValue(value, setValue), [value]) +} + +export function useQueryBody( + query: Model.Value, + setQuery: (value: null) => void, + execution: Model.Value, +): Model.ValueController { + const [value, setValue] = React.useState>() + React.useEffect(() => { + if (!Model.isReady(query)) { + setValue(query) + return + } + setValue((v) => { + if (Model.isError(query)) return null + if (Model.hasData(query)) return query.body + if (Model.hasData(execution) && execution.query) return execution.query + return v + }) + }, [execution, query]) + const handleValue = React.useCallback( + (v: string | null) => { + setQuery(null) + setValue(v) + }, + [setQuery], + ) + return React.useMemo(() => Model.wrapValue(value, handleValue), [value, handleValue]) +} + +export interface ExecutionContext { + catalogName: CatalogName + database: Database +} + +export const NO_DATABASE = new Error('No database') + +interface QueryRunArgs { + workgroup: Model.Data + catalogName: Model.Value + database: Model.Value + queryBody: Model.Value +} + +export function useQueryRun({ + workgroup, + catalogName, + database, + queryBody, +}: QueryRunArgs): [ + Model.Value, + (force: boolean) => Promise>, +] { + const athena = AWS.Athena.use() + // `undefined` = "is not initialized" → is not ready for run + // `null` = is ready but not set, because not submitted for new run + const [value, setValue] = React.useState>() + const prepare = React.useCallback( + (forceDefaultExecutionContext?: boolean) => { + if (!Model.hasData(workgroup)) { + return new Error('No workgroup') + } + + if (!Model.hasValue(catalogName)) { + return catalogName + } + + if (!Model.hasValue(database)) { + return database + } + if (!database && !forceDefaultExecutionContext) { + // We only check if database is selected, + // because if catalogName is not selected, no databases loaded and no database selected as well + return NO_DATABASE + } + + if (!Model.hasData(queryBody)) { + return queryBody + } + return { workgroup, catalogName, database, queryBody } + }, + [workgroup, catalogName, database, queryBody], + ) + React.useEffect(() => { + const init = prepare(true) + setValue(Model.hasData(init) ? null : undefined) + }, [prepare]) + const run = React.useCallback( + async (forceDefaultExecutionContext: boolean) => { + const init = prepare(forceDefaultExecutionContext) + if (!Model.hasData(init)) { + // Error shouldn't be here, because we already checked for errors + // Except `NO_DATABASE`, and if there is some mistake in code + setValue(init) + return init + } + + const options: Athena.Types.StartQueryExecutionInput = { + QueryString: init.queryBody, + ResultConfiguration: { + EncryptionConfiguration: { + EncryptionOption: 'SSE_S3', + }, + }, + WorkGroup: init.workgroup, + } + if (init.catalogName && init.database) { + options.QueryExecutionContext = { + Catalog: init.catalogName, + Database: init.database, + } + } + setValue(Model.Loading) + try { + const d = await athena?.startQueryExecution(options).promise() + const { QueryExecutionId } = d || {} + if (!QueryExecutionId) { + const error = new Error('No execution id') + Log.error(error) + setValue(error) + return error + } + const output = { id: QueryExecutionId } + setValue(output) + return output + } catch (error) { + if (error) { + Log.error(error) + if (error instanceof Error) { + setValue(error) + } + } + return error as Error + } + }, + [athena, prepare], + ) + return [value, run] +} diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/state.spec.tsx b/catalog/app/containers/Bucket/Queries/Athena/model/state.spec.tsx new file mode 100644 index 00000000000..27bfad46eb6 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/state.spec.tsx @@ -0,0 +1,110 @@ +import * as React from 'react' +import renderer from 'react-test-renderer' +import { act, renderHook } from '@testing-library/react-hooks' + +import * as Model from './' + +jest.mock('utils/NamedRoutes', () => ({ + ...jest.requireActual('utils/NamedRoutes'), + use: jest.fn(() => ({ + urls: { + bucketAthenaExecution: () => 'bucket-route', + bucketAthenaWorkgroup: () => 'workgroup-route', + }, + })), +})) + +const useParams = jest.fn( + () => + ({ + bucket: 'b', + workgroup: 'w', + }) as Record, +) + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => useParams()), + Redirect: jest.fn(() => null), +})) + +const batchGetQueryExecution = jest.fn() +const getWorkGroup = jest.fn() +const listDataCatalogs = jest.fn() +const listDatabases = jest.fn() +const listQueryExecutions = jest.fn() +const listWorkGroups = jest.fn() +const getQueryExecution = jest.fn() +const listNamedQueries = jest.fn() +const batchGetNamedQuery = jest.fn() +const getQueryResults = jest.fn() +const startQueryExecution = jest.fn() + +const AthenaApi = { + batchGetNamedQuery, + batchGetQueryExecution, + getQueryExecution, + getQueryResults, + getWorkGroup, + listDataCatalogs, + listDatabases, + listNamedQueries, + listQueryExecutions, + listWorkGroups, + startQueryExecution, +} + +jest.mock('utils/AWS', () => ({ Athena: { use: () => AthenaApi } })) + +describe('app/containers/Queries/Athena/model/state', () => { + it('throw error when no bucket', () => { + jest.spyOn(console, 'error').mockImplementationOnce(jest.fn()) + useParams.mockImplementationOnce(() => ({})) + const Component = () => { + const state = Model.useState() + return <>{JSON.stringify(state, null, 2)} + } + const tree = () => + renderer.create( + + + , + ) + expect(tree).toThrowError('`bucket` must be defined') + }) + + it('load workgroups and set current workgroup', async () => { + listWorkGroups.mockImplementation(() => ({ + promise: () => + Promise.resolve({ + WorkGroups: [{ Name: 'foo' }, { Name: 'bar' }, { Name: 'w' }], + }), + })) + getWorkGroup.mockImplementation(({ WorkGroup: Name }: { WorkGroup: string }) => ({ + promise: () => + Promise.resolve({ + WorkGroup: { + Configuration: { ResultConfiguration: { OutputLocation: 'any' } }, + State: 'ENABLED', + Name, + }, + }), + })) + listQueryExecutions.mockImplementation((_x, cb) => { + cb(undefined, { QueryExecutionIds: [] }) + return { + abort: jest.fn(), + } + }) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + const { result, waitFor, unmount } = renderHook(() => Model.useState(), { wrapper }) + await act(async () => { + await waitFor(() => typeof result.current.executions.data === 'object') + }) + expect(result.current.workgroups.data).toMatchObject({ list: ['bar', 'foo', 'w'] }) + expect(result.current.workgroup.data).toBe('w') + unmount() + }) +}) diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/state.tsx b/catalog/app/containers/Bucket/Queries/Athena/model/state.tsx new file mode 100644 index 00000000000..a7bc4631fcb --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/state.tsx @@ -0,0 +1,149 @@ +import invariant from 'invariant' +import * as React from 'react' +import * as RRDom from 'react-router-dom' + +import type * as BucketPreferences from 'utils/BucketPreferences' +import * as NamedRoutes from 'utils/NamedRoutes' + +import * as requests from './requests' +import * as Model from './utils' + +export interface State { + bucket: string + queryExecutionId?: string + + /** + * Query execution loaded by id on the corresponding page. + * On the index page (where there is no queryExecutionId) its value is null. + */ + execution: Model.Value + + /** List of workgroups from Athena */ + workgroups: Model.DataController> + /** + * Workgroup selected by user explicitly or from page URL, and validated that it does exist + * If workgroup doesn't exist, then its value is Error + * It can't be null + */ + workgroup: Model.DataController + /** List of named queries, including query body for each query */ + queries: Model.DataController> + /** Selected named query */ + query: Model.ValueController + /** Query body, typed by user or set from selected named query or query execution */ + queryBody: Model.ValueController + /** List of catalog names from Athena */ + catalogNames: Model.DataController> + /** Catalog name selected by user, or set initially */ + catalogName: Model.ValueController + /** List of databases from Athena */ + databases: Model.DataController> + /** Database selected by user, or set initially */ + database: Model.ValueController + /** List of query executions, in other words, history of executions */ + executions: Model.DataController> + /** Rows and columns of query results */ + results: Model.DataController + + /** + * Submit query to Athena with values memoized here in state + * If catalog name or database is not selected, then it will return specific output + * Which is handled and then user can re-submit with `forceDefaultExecutionContext: true` + */ + submit: ( + forceDefaultExecutionContext: boolean, + ) => Promise> + /** + * Query run is `undefined` when there is not enough data to run the query + * It is `null` when it is ready to run + * Error when submit failed or when validation failed (e.g. no database selected) + */ + queryRun: Model.Value +} + +export const Ctx = React.createContext(null) + +interface ProviderProps { + preferences?: BucketPreferences.AthenaPreferences + children: React.ReactNode +} + +export function Provider({ preferences, children }: ProviderProps) { + const { urls } = NamedRoutes.use() + + const { + bucket, + queryExecutionId, + workgroup: workgroupId, + } = RRDom.useParams<{ + bucket: string + queryExecutionId?: string + workgroup?: requests.Workgroup + }>() + invariant(!!bucket, '`bucket` must be defined') + + const execution = requests.useWaitForQueryExecution(queryExecutionId) + + const workgroups = requests.useWorkgroups() + const workgroup = requests.useWorkgroup(workgroups, workgroupId, preferences) + const queries = requests.useQueries(workgroup.data) + const query = requests.useQuery(queries.data, execution) + const queryBody = requests.useQueryBody(query.value, query.setValue, execution) + const catalogNames = requests.useCatalogNames() + const catalogName = requests.useCatalogName(catalogNames.data, execution) + const databases = requests.useDatabases(catalogName.value) + const database = requests.useDatabase(databases.data, execution) + const executions = requests.useExecutions(workgroup.data, queryExecutionId) + const results = requests.useResults(execution) + + const [queryRun, submit] = requests.useQueryRun({ + workgroup: workgroup.data, + catalogName: catalogName.value, + database: database.value, + queryBody: queryBody.value, + }) + + const value: State = { + bucket, + queryExecutionId, + workgroup, + + catalogName, + catalogNames, + database, + databases, + execution, + executions, + queries, + query, + queryBody, + results, + workgroups, + + submit, + queryRun, + } + + if (Model.hasData(queryRun) && queryExecutionId !== queryRun.id) { + return ( + + ) + } + + if (Model.hasData(workgroup.data) && !workgroupId) { + return + } + + return {children} +} + +/** state object is not memoized, use destructuring down to memoized properties */ +export function useState() { + const model = React.useContext(Ctx) + invariant(model, 'Athena state accessed outside of provider') + return model +} + +export const use = useState diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/storage.ts b/catalog/app/containers/Bucket/Queries/Athena/model/storage.ts new file mode 100644 index 00000000000..1c1fd07d5bc --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/storage.ts @@ -0,0 +1,28 @@ +import mkStorage from 'utils/storage' + +const ATHENA_WORKGROUP_KEY = 'ATHENA_WORKGROUP' + +const ATHENA_CATALOG_KEY = 'ATHENA_CATALOG' + +const ATHENA_DATABASE_KEY = 'ATHENA_DATABASE' + +const storage = mkStorage({ + athenaCatalog: ATHENA_CATALOG_KEY, + athenaDatabase: ATHENA_DATABASE_KEY, + athenaWorkgroup: ATHENA_WORKGROUP_KEY, +}) + +export const getCatalog = () => storage.get('athenaCatalog') + +export const setCatalog = (catalog: string) => storage.set('athenaCatalog', catalog) + +export const getDatabase = () => storage.get('athenaDatabase') + +export const setDatabase = (database: string) => storage.set('athenaDatabase', database) + +export const clearDatabase = () => storage.remove('athenaDatabase') + +export const getWorkgroup = () => storage.get('athenaWorkgroup') + +export const setWorkgroup = (workgroup: string) => + storage.set('athenaWorkgroup', workgroup) diff --git a/catalog/app/containers/Bucket/Queries/Athena/model/utils.ts b/catalog/app/containers/Bucket/Queries/Athena/model/utils.ts new file mode 100644 index 00000000000..429b8f144d3 --- /dev/null +++ b/catalog/app/containers/Bucket/Queries/Athena/model/utils.ts @@ -0,0 +1,96 @@ +export const Loading = Symbol('loading') + +export type Maybe = T | null + +// `T` is the value +// `undefined` is no data. It is not initialized +// `Loading` is loading +// `Error` is error +export type Data = T | undefined | typeof Loading | Error + +export interface DataController { + data: Data + loadMore: () => void +} + +export function wrapData(data: Data, setPrev: (d: T) => void): DataController { + return { + data, + loadMore: () => hasData(data) && setPrev(data), + } +} + +export interface List { + list: T[] + next?: string +} + +// `T` is the value +// `null` is no value, explicitly set by user +// `undefined` is no value. It is not initialized +// `Loading` is loading +// `Error` is error +export type Value = Maybe> + +export interface ValueController { + value: Value + setValue: (v: T | null) => void +} + +export function wrapValue( + value: Value, + setValue: (d: T | null) => void, +): ValueController { + return { + value, + setValue, + } +} + +/** Data is loaded, or value is set to actual value */ +export function hasData(value: Value): value is T { + if ( + value === undefined || + value === Loading || + value instanceof Error || + value === null + ) { + return false + } + return true +} + +/** No value yet: value or data was just initialized */ +export function isNone(value: Value): value is undefined { + return value === undefined +} + +/** Data is loading, or value is waiting for data */ +export function isLoading(value: Value): value is typeof Loading { + return value === Loading +} + +export function isError(value: Value): value is Error { + return value instanceof Error +} + +/** Value is selected with some or no value, or resolved with error, or data is loaded (successfully or not) */ +export function isReady(value: Value): value is T | null | Error { + if (value === undefined || value === Loading) { + return false + } + return true +} + +/** Value is selected with some or no value, or data is loaded successfully */ +export function hasValue(value: Value): value is T | null { + if (value === undefined || value === Loading || value instanceof Error) { + return false + } + return true +} + +/** User explicitly set no value */ +export function isNoneSelected(value: Value): value is null { + return value === null +} diff --git a/catalog/app/containers/Bucket/Queries/ElasticSearch.tsx b/catalog/app/containers/Bucket/Queries/ElasticSearch.tsx index c9d6d23d071..03b793435fa 100644 --- a/catalog/app/containers/Bucket/Queries/ElasticSearch.tsx +++ b/catalog/app/containers/Bucket/Queries/ElasticSearch.tsx @@ -24,9 +24,6 @@ const useStyles = M.makeStyles((t) => ({ form: { margin: t.spacing(0, 0, 4), }, - sectionHeader: { - margin: t.spacing(0, 0, 1), - }, select: { margin: t.spacing(3, 0), }, @@ -62,7 +59,7 @@ interface QueriesStateRenderProps { error: Error | null handleError: (error: Error | null) => void handleQueryBodyChange: (q: requests.ElasticSearchQuery | null) => void - handleQueryMetaChange: (q: requests.Query | requests.athena.AthenaQuery | null) => void + handleQueryMetaChange: (q: requests.Query | requests.athena.Query | null) => void handleSubmit: (q: requests.ElasticSearchQuery) => () => void queries: requests.Query[] queryData: requests.AsyncData @@ -99,7 +96,7 @@ function QueriesState({ bucket, children }: QueriesStateProps) { ) const handleQueryMetaChange = React.useCallback( - (q: requests.athena.AthenaQuery | requests.Query | null) => { + (q: requests.athena.Query | requests.Query | null) => { setQueryMeta(q as requests.Query | null) setCustomQueryBody(null) }, @@ -212,10 +209,8 @@ export default function ElastiSearch() { ElasticSearch queries
- - Select query - + label="Select query" queries={queries} onChange={handleQueryMetaChange} value={customQueryBody ? null : queryMeta} diff --git a/catalog/app/containers/Bucket/Queries/QuerySelect.spec.tsx b/catalog/app/containers/Bucket/Queries/QuerySelect.spec.tsx index 61425326cd4..f681ed82b50 100644 --- a/catalog/app/containers/Bucket/Queries/QuerySelect.spec.tsx +++ b/catalog/app/containers/Bucket/Queries/QuerySelect.spec.tsx @@ -6,7 +6,7 @@ import QuerySelect from './QuerySelect' describe('containers/Bucket/Queries/QuerySelect', () => { it('should render', () => { const tree = renderer - .create( {}} value={null} />) + .create( {}} value={null} />) .toJSON() expect(tree).toMatchSnapshot() }) @@ -16,7 +16,14 @@ describe('containers/Bucket/Queries/QuerySelect', () => { { key: 'key2', name: 'name2', url: 'url2' }, ] const tree = renderer - .create( {}} value={queries[1]} />) + .create( + {}} + value={queries[1]} + />, + ) .toJSON() expect(tree).toMatchSnapshot() }) diff --git a/catalog/app/containers/Bucket/Queries/QuerySelect.tsx b/catalog/app/containers/Bucket/Queries/QuerySelect.tsx index 38bb51218bc..d0f30c71684 100644 --- a/catalog/app/containers/Bucket/Queries/QuerySelect.tsx +++ b/catalog/app/containers/Bucket/Queries/QuerySelect.tsx @@ -8,34 +8,26 @@ interface AbstractQuery { } interface QuerySelectProps { + className?: string + disabled?: boolean + label: React.ReactNode onChange: (value: T | null) => void onLoadMore?: () => void queries: T[] value: T | null } -const useStyles = M.makeStyles((t) => ({ - header: { - margin: t.spacing(0, 0, 1), - }, - selectWrapper: { - width: '100%', - }, - select: { - padding: t.spacing(1), - }, -})) - const LOAD_MORE = 'load-more' export default function QuerySelect({ - queries, + className, + disabled, + label, onChange, onLoadMore, + queries, value, }: QuerySelectProps) { - const classes = useStyles() - const handleChange = React.useCallback( (event) => { if (event.target.value === LOAD_MORE && onLoadMore) { @@ -48,31 +40,29 @@ export default function QuerySelect({ ) return ( - - - - - Custom + + {label} + + + Custom + + {queries.map((query) => ( + + + + ))} + {!!onLoadMore && ( + + + Load more + - {queries.map((query) => ( - - - - ))} - {!!onLoadMore && ( - - - Load more - - - )} - - - + )} + + ) } diff --git a/catalog/app/containers/Bucket/Queries/__snapshots__/QuerySelect.spec.tsx.snap b/catalog/app/containers/Bucket/Queries/__snapshots__/QuerySelect.spec.tsx.snap index 03981390857..ff49164ebb1 100644 --- a/catalog/app/containers/Bucket/Queries/__snapshots__/QuerySelect.spec.tsx.snap +++ b/catalog/app/containers/Bucket/Queries/__snapshots__/QuerySelect.spec.tsx.snap @@ -2,111 +2,115 @@ exports[`containers/Bucket/Queries/QuerySelect should render 1`] = `
+
-
- - Custom - -
+ Custom +
- - - -
+ + + +
`; exports[`containers/Bucket/Queries/QuerySelect should render with selected value 1`] = `
+
-
- - name2 - -
+ name2 +
- - - -
+ + + +
`; diff --git a/catalog/app/containers/Bucket/Queries/requests/athena.ts b/catalog/app/containers/Bucket/Queries/requests/athena.ts deleted file mode 100644 index 135976b574a..00000000000 --- a/catalog/app/containers/Bucket/Queries/requests/athena.ts +++ /dev/null @@ -1,582 +0,0 @@ -import Athena from 'aws-sdk/clients/athena' -import * as React from 'react' - -import * as AWS from 'utils/AWS' -import * as BucketPreferences from 'utils/BucketPreferences' -import { useData } from 'utils/Data' -import wait from 'utils/wait' - -import * as storage from './storage' - -import { AsyncData } from './requests' - -// TODO: rename to requests.athena.Query -export interface AthenaQuery { - body: string - description?: string - key: string - name: string -} - -export interface QueriesResponse { - list: AthenaQuery[] - next?: string -} - -interface QueriesArgs { - athena: Athena - prev: QueriesResponse | null - workgroup: string -} - -function parseNamedQuery(query: Athena.NamedQuery): AthenaQuery { - return { - body: query.QueryString, - description: query.Description, - key: query.NamedQueryId!, - name: query.Name, - } -} - -async function fetchQueries({ - athena, - prev, - workgroup, -}: QueriesArgs): Promise { - try { - const queryIdsOutput = await athena - ?.listNamedQueries({ WorkGroup: workgroup, NextToken: prev?.next }) - .promise() - if (!queryIdsOutput.NamedQueryIds || !queryIdsOutput.NamedQueryIds.length) - return { - list: prev?.list || [], - next: queryIdsOutput.NextToken, - } - - const queriesOutput = await athena - ?.batchGetNamedQuery({ - NamedQueryIds: queryIdsOutput.NamedQueryIds, - }) - .promise() - const parsed = (queriesOutput.NamedQueries || []).map(parseNamedQuery) - const list = (prev?.list || []).concat(parsed) - return { - list, - next: queryIdsOutput.NextToken, - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to fetch') - // eslint-disable-next-line no-console - console.error(e) - throw e - } -} - -export function useQueries( - workgroup: string, - prev: QueriesResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - return useData(fetchQueries, { athena, prev, workgroup }, { noAutoFetch: !workgroup }) -} - -export type Workgroup = string - -function getDefaultWorkgroup( - list: Workgroup[], - preferences?: BucketPreferences.AthenaPreferences, -): Workgroup { - const workgroupFromConfig = preferences?.defaultWorkgroup - if (workgroupFromConfig && list.includes(workgroupFromConfig)) { - return workgroupFromConfig - } - return storage.getWorkgroup() || list[0] -} - -interface WorkgroupArgs { - athena: Athena - workgroup: Workgroup -} - -async function fetchWorkgroup({ - athena, - workgroup, -}: WorkgroupArgs): Promise { - try { - const workgroupOutput = await athena.getWorkGroup({ WorkGroup: workgroup }).promise() - if ( - workgroupOutput?.WorkGroup?.Configuration?.ResultConfiguration?.OutputLocation && - workgroupOutput?.WorkGroup?.State === 'ENABLED' && - workgroupOutput?.WorkGroup?.Name - ) { - return workgroupOutput.WorkGroup.Name - } - return null - } catch (error) { - return null - } -} - -export interface WorkgroupsResponse { - defaultWorkgroup: Workgroup - list: Workgroup[] - next?: string -} - -interface WorkgroupsArgs { - athena: Athena - prev: WorkgroupsResponse | null - preferences?: BucketPreferences.AthenaPreferences -} - -async function fetchWorkgroups({ - athena, - prev, - preferences, -}: WorkgroupsArgs): Promise { - try { - const workgroupsOutput = await athena - .listWorkGroups({ NextToken: prev?.next }) - .promise() - const parsed = (workgroupsOutput.WorkGroups || []).map( - ({ Name }) => Name || 'Unknown', - ) - const available = ( - await Promise.all(parsed.map((workgroup) => fetchWorkgroup({ athena, workgroup }))) - ).filter(Boolean) - const list = (prev?.list || []).concat(available as Workgroup[]) - return { - defaultWorkgroup: getDefaultWorkgroup(list, preferences), - list, - next: workgroupsOutput.NextToken, - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to fetch') - // eslint-disable-next-line no-console - console.error(e) - throw e - } -} - -export function useWorkgroups( - prev: WorkgroupsResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - const prefs = BucketPreferences.use() - const preferences = React.useMemo( - () => - BucketPreferences.Result.match( - { - Ok: ({ ui }) => ui.athena, - _: () => undefined, - }, - prefs, - ), - [prefs], - ) - return useData(fetchWorkgroups, { athena, prev, preferences }) -} - -export interface QueryExecution { - catalog?: string - completed?: Date - created?: Date - db?: string - error?: Error - id?: string - outputBucket?: string - query?: string - status?: string // 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' - workgroup?: Athena.WorkGroupName -} - -export interface QueryExecutionsResponse { - list: QueryExecution[] - next?: string -} - -interface QueryExecutionsArgs { - athena: Athena - prev: QueryExecutionsResponse | null - workgroup: string -} - -function parseQueryExecution(queryExecution: Athena.QueryExecution): QueryExecution { - return { - catalog: queryExecution?.QueryExecutionContext?.Catalog, - completed: queryExecution?.Status?.CompletionDateTime, - created: queryExecution?.Status?.SubmissionDateTime, - db: queryExecution?.QueryExecutionContext?.Database, - id: queryExecution?.QueryExecutionId, - outputBucket: queryExecution?.ResultConfiguration?.OutputLocation, - query: queryExecution?.Query, - status: queryExecution?.Status?.State, - workgroup: queryExecution?.WorkGroup, - } -} - -function parseQueryExecutionError( - error: Athena.UnprocessedQueryExecutionId, -): QueryExecution { - return { - error: new Error(error?.ErrorMessage || 'Unknown'), - id: error?.QueryExecutionId, - } -} - -async function fetchQueryExecutions({ - athena, - prev, - workgroup, -}: QueryExecutionsArgs): Promise { - try { - const executionIdsOutput = await athena - .listQueryExecutions({ WorkGroup: workgroup, NextToken: prev?.next }) - .promise() - - const ids = executionIdsOutput.QueryExecutionIds - if (!ids || !ids.length) - return { - list: [], - next: executionIdsOutput.NextToken, - } - - const executionsOutput = await athena - ?.batchGetQueryExecution({ QueryExecutionIds: ids }) - .promise() - const parsed = (executionsOutput.QueryExecutions || []) - .map(parseQueryExecution) - .concat( - (executionsOutput.UnprocessedQueryExecutionIds || []).map( - parseQueryExecutionError, - ), - ) - const list = (prev?.list || []).concat(parsed) - return { - list, - next: executionIdsOutput.NextToken, - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to fetch') - // eslint-disable-next-line no-console - console.error(e) - throw e - } -} - -export function useQueryExecutions( - workgroup: string, - prev: QueryExecutionsResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - return useData( - fetchQueryExecutions, - { athena, prev, workgroup }, - { noAutoFetch: !workgroup }, - ) -} - -async function waitForQueryStatus( - athena: Athena, - QueryExecutionId: string, -): Promise { - // eslint-disable-next-line no-constant-condition - while (true) { - // NOTE: await is used to intentionally pause loop and make requests in series - // eslint-disable-next-line no-await-in-loop - const statusData = await athena.getQueryExecution({ QueryExecutionId }).promise() - const status = statusData?.QueryExecution?.Status?.State - const parsed = statusData?.QueryExecution - ? parseQueryExecution(statusData?.QueryExecution) - : { - id: QueryExecutionId, - } - if (status === 'FAILED' || status === 'CANCELLED') { - const reason = statusData?.QueryExecution?.Status?.StateChangeReason || '' - return { - ...parsed, - error: new Error(`${status}: ${reason}`), - } - } - - if (!status) { - return { - ...parsed, - error: new Error('Unknown query execution status'), - } - } - - if (status === 'SUCCEEDED') { - return parsed - } - - // eslint-disable-next-line no-await-in-loop - await wait(1000) - } -} - -export type QueryResultsValue = Athena.datumString - -export interface QueryResultsColumnInfo { - name: Athena.String - type: Athena.String -} - -export type QueryResultsColumns = QueryResultsColumnInfo[] -type Row = QueryResultsValue[] -export type QueryResultsRows = Row[] - -export interface QueryResultsResponse { - columns: QueryResultsColumns - next?: string - queryExecution: QueryExecution - rows: QueryResultsRows -} - -type ManifestKey = 'hash' | 'logical_key' | 'meta' | 'physical_keys' | 'size' - -export interface QueryManifestsResponse extends QueryResultsResponse { - rows: [ManifestKey[], ...string[][]] -} - -interface QueryResultsArgs { - athena: Athena - queryExecutionId: string - prev: QueryResultsResponse | null -} - -const emptyRow: Row = [] -const emptyList: QueryResultsRows = [] -const emptyColumns: QueryResultsColumns = [] - -async function fetchQueryResults({ - athena, - queryExecutionId, - prev, -}: QueryResultsArgs): Promise { - const queryExecution = await waitForQueryStatus(athena, queryExecutionId) - if (queryExecution.error) { - return { - rows: emptyList, - columns: emptyColumns, - queryExecution, - } - } - - try { - const queryResultsOutput = await athena - .getQueryResults({ - QueryExecutionId: queryExecutionId, - NextToken: prev?.next, - }) - .promise() - const parsed = - queryResultsOutput.ResultSet?.Rows?.map( - (row) => row?.Data?.map((item) => item?.VarCharValue || '') || emptyRow, - ) || emptyList - const rows = [...(prev?.rows || emptyList), ...parsed] - const columns = - queryResultsOutput.ResultSet?.ResultSetMetadata?.ColumnInfo?.map( - ({ Name, Type }) => ({ - name: Name, - type: Type, - }), - ) || emptyColumns - const isHeadColumns = columns.every(({ name }, index) => name === rows[0][index]) - return { - rows: isHeadColumns ? rows.slice(1) : rows, - columns, - next: queryResultsOutput.NextToken, - queryExecution, - } - } catch (error) { - return { - rows: emptyList, - columns: emptyColumns, - queryExecution: { - ...queryExecution, - error: error instanceof Error ? error : new Error(`${error}`), - }, - } - } -} - -export function useQueryResults( - queryExecutionId: string | null, - prev: QueryResultsResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - return useData( - fetchQueryResults, - { athena, prev, queryExecutionId }, - { noAutoFetch: !queryExecutionId }, - ) -} - -export interface QueryRunResponse { - id: string -} - -export type CatalogName = string -export interface CatalogNamesResponse { - list: CatalogName[] - next?: string -} - -interface CatalogNamesArgs { - athena: Athena - prev?: CatalogNamesResponse -} - -async function fetchCatalogNames({ - athena, - prev, -}: CatalogNamesArgs): Promise { - const catalogsOutput = await athena - ?.listDataCatalogs({ NextToken: prev?.next }) - .promise() - const list = - catalogsOutput?.DataCatalogsSummary?.map( - ({ CatalogName }) => CatalogName || 'Unknown', - ) || [] - return { - list: (prev?.list || []).concat(list), - next: catalogsOutput.NextToken, - } -} - -export function useCatalogNames( - prev: CatalogNamesResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - return useData(fetchCatalogNames, { athena, prev }) -} - -export type Database = string -export interface DatabasesResponse { - list: CatalogName[] - next?: string -} - -interface DatabasesArgs { - athena: Athena - catalogName: CatalogName - prev?: DatabasesResponse -} - -async function fetchDatabases({ - athena, - catalogName, - prev, -}: DatabasesArgs): Promise { - const databasesOutput = await athena - ?.listDatabases({ CatalogName: catalogName, NextToken: prev?.next }) - .promise() - // TODO: add `Description` besides `Name` - const list = databasesOutput?.DatabaseList?.map(({ Name }) => Name || 'Unknown') || [] - return { - list: (prev?.list || []).concat(list), - next: databasesOutput.NextToken, - } -} - -export function useDatabases( - catalogName: CatalogName | null, - prev: DatabasesResponse | null, -): AsyncData { - const athena = AWS.Athena.use() - return useData( - fetchDatabases, - { athena, catalogName, prev }, - { noAutoFetch: !catalogName }, - ) -} - -interface DefaultDatabaseArgs { - athena: Athena -} - -async function fetchDefaultQueryExecution({ - athena, -}: DefaultDatabaseArgs): Promise { - const catalogNames = await fetchCatalogNames({ athena }) - if (!catalogNames.list.length) { - return null - } - const catalogName = catalogNames.list[0] - const databases = await fetchDatabases({ athena, catalogName }) - if (!databases.list.length) { - return null - } - return { - catalog: catalogName, - db: databases.list[0], - } -} - -export function useDefaultQueryExecution(): AsyncData { - const athena = AWS.Athena.use() - return useData(fetchDefaultQueryExecution, { athena }) -} - -export interface ExecutionContext { - catalogName: CatalogName - database: Database -} - -interface RunQueryArgs { - athena: Athena - queryBody: string - workgroup: string - executionContext: ExecutionContext | null -} - -export async function runQuery({ - athena, - queryBody, - workgroup, - executionContext, -}: RunQueryArgs): Promise { - try { - const options: Athena.Types.StartQueryExecutionInput = { - QueryString: queryBody, - ResultConfiguration: { - EncryptionConfiguration: { - EncryptionOption: 'SSE_S3', - }, - }, - WorkGroup: workgroup, - } - if (executionContext) { - options.QueryExecutionContext = { - Catalog: executionContext.catalogName, - Database: executionContext.database, - } - } - const { QueryExecutionId } = await athena.startQueryExecution(options).promise() - if (!QueryExecutionId) throw new Error('No execution id') - return { - id: QueryExecutionId, - } - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to fetch') - // eslint-disable-next-line no-console - console.error(e) - throw e - } -} - -export function useQueryRun(workgroup: string) { - const athena = AWS.Athena.use() - return React.useCallback( - (queryBody: string, executionContext: ExecutionContext | null) => { - if (!athena) return Promise.reject(new Error('No Athena available')) - return runQuery({ athena, queryBody, workgroup, executionContext }) - }, - [athena, workgroup], - ) -} diff --git a/catalog/app/containers/Bucket/Queries/requests/index.ts b/catalog/app/containers/Bucket/Queries/requests/index.ts index 0dc2f97cd35..b02355f69bb 100644 --- a/catalog/app/containers/Bucket/Queries/requests/index.ts +++ b/catalog/app/containers/Bucket/Queries/requests/index.ts @@ -1,4 +1,4 @@ -export * as athena from './athena' +export type * as athena from '../Athena/model/requests' export * from './queriesConfig' diff --git a/catalog/app/containers/Bucket/Queries/requests/storage.ts b/catalog/app/containers/Bucket/Queries/requests/storage.ts deleted file mode 100644 index 5cbcc8d7b64..00000000000 --- a/catalog/app/containers/Bucket/Queries/requests/storage.ts +++ /dev/null @@ -1,10 +0,0 @@ -import mkStorage from 'utils/storage' - -const ATHENA_WORKGROUP_KEY = 'ATHENA_WORKGROUP' - -const storage = mkStorage({ athenaWorkgroup: ATHENA_WORKGROUP_KEY }) - -export const getWorkgroup = () => storage.get('athenaWorkgroup') - -export const setWorkgroup = (workgroup: string) => - storage.set('athenaWorkgroup', workgroup) diff --git a/catalog/app/utils/AWS/Bedrock/History.spec.ts b/catalog/app/utils/AWS/Bedrock/History.spec.ts index 6ff1fb4773a..d635295b0b9 100644 --- a/catalog/app/utils/AWS/Bedrock/History.spec.ts +++ b/catalog/app/utils/AWS/Bedrock/History.spec.ts @@ -21,7 +21,7 @@ describe('utils/AWS/Bedrock/History', () => { }) describe('foldMessages', () => { - it('Fold same-role messages', async () => { + it('Fold same-role messages', () => { const userFoo = Message.createMessage('foo') const userBar = Message.createMessage('bar') const assistantFoo = Message.createMessage('foo', 'assistant') @@ -32,7 +32,7 @@ describe('utils/AWS/Bedrock/History', () => { expect(list[1].content).toBe('foo\nbaz') }) - it('Fold system and user messages', async () => { + it('Fold system and user messages', () => { const userFoo = Message.createMessage('foo') const userBar = Message.createMessage('bar') const systemFoo = Message.createMessage('foo', 'system') diff --git a/catalog/app/utils/Sentry.ts b/catalog/app/utils/Sentry.ts index 02b99626958..6ca5da74976 100644 --- a/catalog/app/utils/Sentry.ts +++ b/catalog/app/utils/Sentry.ts @@ -60,13 +60,13 @@ export const UserTracker = function SentryUserTracker({ return children } -/** @deprecated */ +/** @deprecated use '@sentry/react' */ async function callSentry(method: string, ...args: $TSFixMe[]) { return (Sentry as $TSFixMe)[method](...args) } -/** @deprecated */ +/** @deprecated use '@sentry/react' */ export const useSentry = () => callSentry -/** @deprecated */ +/** @deprecated use '@sentry/react' */ export const use = useSentry From e4d9590635b1d28021cfafd57c661c9431dbe354 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:13:43 +0400 Subject: [PATCH 2/9] Bump amazonlinux from 2023.6.20241031.0 to 2023.6.20241111.0 in /s3-proxy (#4222) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- s3-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/s3-proxy/Dockerfile b/s3-proxy/Dockerfile index adf17c7a6a8..72fe0690fb6 100644 --- a/s3-proxy/Dockerfile +++ b/s3-proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM amazonlinux:2023.6.20241031.0 +FROM amazonlinux:2023.6.20241111.0 MAINTAINER Quilt Data, Inc. contact@quiltdata.io # Based on: From 139a386a332871894286ca5f2484a18ff5d5723b Mon Sep 17 00:00:00 2001 From: QuiltSimon <116831980+QuiltSimon@users.noreply.github.com> Date: Mon, 18 Nov 2024 09:54:11 -0500 Subject: [PATCH 3/9] Doc reorg summary and listing (#4166) Co-authored-by: Dr. Ernie Prabhakar <19791+drernie@users.noreply.github.com> Co-authored-by: Dr. Ernie Prabhakar --- docs/README.md | 54 ++++++++++++++++++++++---------------- docs/SUMMARY.md | 14 +++++----- docs/examples/benchling.md | 32 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 docs/examples/benchling.md diff --git a/docs/README.md b/docs/README.md index eb5afe0ba28..0582c038cb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,30 @@ data integrity at scale. --- +## How to Get Started + +Quilt consists of three main elements: + +- [Quilt Platform](#quilt-platform-overview) which is a cloud platform for + interacting with, visualizing, searching and querying Quilt Packages, which is + hosted in an organization's AWS Account. +- [Quilt Python SDK](#quilt-python-sdk) which provides the ability to create, + push, install and delete Quilt Packages. +- [Quilt Ecosystem](#quilt-ecosystem-and-integrations) which provide extension + of the core Quilt Capabilities to enable typical elements of life sciences + workflows, such as incorporating orchestration data, and connecting packages + to Electronic Lab Notebooks. + +To dive deeper into the capabilities of Quilt, start with our [Quick Start +Guide](Quickstart.md) or explore the [Installation +Instructions](Installation.md) for setting up your environment. + +If you have any questions or need help, join our [Slack +community](https://slack.quiltdata.com/) or submit a support request to +. + +--- + ## Navigating the Documentation The Quilt documentation is structured to guide users through different layers of @@ -24,8 +48,7 @@ capabilities like embeddable previews and metadata collection. **Core Sections:** - [Architecture](Architecture.md) - Learn how Quilt is architected. -- [Mental Model](MentalModel.md) - Understand the guiding principles behind - Quilt. +- [Mental Model](MentalModel.md) - Understand the guiding principles behind Quilt. - [Metadata Management](Catalog/Metadata.md) - Manage metadata at scale. For users of the Quilt Platform (often referred to as the Catalog): @@ -40,11 +63,9 @@ For users of the Quilt Platform (often referred to as the Catalog): For administrators managing Quilt deployments: -- [Admin Settings UI](Catalog/Admin.md) - Control platform settings and user - access. +- [Admin Settings UI](Catalog/Admin.md) - Control platform settings and user access. - [Catalog Configuration](Catalog/Preferences.md) - Set platform preferences. -- [Cross-Account Access](CrossAccount.md) - Manage multi-account access to S3 - data. +- [Cross-Account Access](CrossAccount.md) - Manage multi-account access to S3 data. ### Quilt Python SDK @@ -58,8 +79,7 @@ flexibility needed for deeper integrations. managing data packages. - [Editing and Uploading Packages](walkthrough/editing-a-package.md) - Learn how to version, edit, and share data. -- [API Reference](api-reference/api.md) - Detailed API documentation for - developers. +- [API Reference](api-reference/api.md) - Detailed API documentation for developers. ### Quilt Ecosystem and Integrations @@ -67,9 +87,8 @@ The **Quilt Ecosystem** extends the platform with integrations and plugins to fit your workflow. Whether you're managing scientific data or automating packaging tasks, Quilt can be tailored to your needs with these tools: -- [Benchling - Packager](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager) - - Package biological data from Benchling. +- [Benchling Packager](examples/benchling.md) - Package electronic lab notebooks + from Benchling. - [Nextflow Plugin](examples/nextflow.md) - Integrate with Nextflow pipelines for bioinformatics. @@ -89,18 +108,7 @@ administrator, Quilt helps streamline your data management workflows. better insights. - **Discover**: Use metadata and search tools to explore data relationships across projects. -- **Model**: Version and manage large data sets that don't fit traditional git - repositories. +- **Model**: Version and manage large data sets that don't fit traditional git repositories. - **Decide**: Empower your team with auditable data for better decision-making. --- - -## How to Get Started - -To dive deeper into the capabilities of Quilt, start with our [Quick Start -Guide](Quickstart.md) or explore the [Installation -Instructions](Installation.md) for setting up your environment. - -If you have any questions or need help, join our [Slack -community](https://slack.quiltdata.com/) or visit our full [documentation -site](https://docs.quiltdata.com/). diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 0f1ca68efd6..43fe43fffa6 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -38,13 +38,7 @@ * [GxP for Security & Compliance](advanced-features/good-practice.md) * [Organizing S3 Buckets](advanced-features/s3-bucket-organization.md) -## Quilt Ecosystem Integrations - -* [Benchling Packager](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager) -* [Event-Driven Packaging](advanced-features/event-driven-packaging.md) -* [Nextflow Plugin](examples/nextflow.md) - -## Quilt Python SDK Developers +## Quilt Python SDK * [Installation](Installation.md) * [Quick Start](Quickstart.md) @@ -73,3 +67,9 @@ * [Contributing](CONTRIBUTING.md) * [Frequently Asked Questions](FAQ.md) * [Troubleshooting](Troubleshooting.md) + +## Quilt Ecosystem Integrations + +* [Benchling Packager](examples/benchling.md) +* [Event-Driven Packaging](advanced-features/event-driven-packaging.md) +* [Nextflow Plugin](examples/nextflow.md) diff --git a/docs/examples/benchling.md b/docs/examples/benchling.md new file mode 100644 index 00000000000..68a0a5a56bb --- /dev/null +++ b/docs/examples/benchling.md @@ -0,0 +1,32 @@ + +The Benchling Packager is a lambda you can deploy in your own AWS private cloud +to process [Benchling](https://benchling.com/) events in order to create (and +link back, if possible) a dedicated [Quilt](https://quiltdata.com/) package for +every Benchling notebook. + +The CloudFormation template is available as a package on +[open.quiltdata.com](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager). + +## Prerequisites + +In order to install the benchling packager, you will need to know, and have +administrative access to: + +- Your Benchling tenant domain (e.g., `` from + `.benchling.com`), for ßconfiguring event subscriptions and + metadata schemas. +- The AWS Account ID (e.g. 12345689123) and AWS Region (e.g., us-west-2) used by + your Quilt stack, for configuring the CloudFormation stack and lambdas. + +## Installation + +Go to the [Benchling Packager +package](https://open.quiltdata.com/b/quilt-example/packages/examples/benchling-packager) +on open.quiltdata.com and follow the instructions in the README. + +## References + +- [AWS CloudFormation templates](https://aws.amazon.com/cloudformation/resources/templates/) +- [AWS Lambda functions](https://aws.amazon.com/lambda/) +- [Benchling EventBridge events](https://docs.benchling.com/docs/events-getting-started#event-types) +- [Benchling Schemas](https://help.benchling.com/hc/en-us/articles/9684227216781) From 6bc770e28e54094df4286282f11f544979bc2cb2 Mon Sep 17 00:00:00 2001 From: Maksim Chervonnyi Date: Mon, 18 Nov 2024 17:12:19 +0100 Subject: [PATCH 4/9] Athena docs: add screenshots and mention `physical_key` and `defaultWorkgroup` (#4217) Co-authored-by: Dr. Ernie Prabhakar --- docs/Catalog/SearchQuery.md | 9 ++++++--- docs/advanced-features/athena.md | 5 ++++- docs/imgs/athena-history.png | Bin 110713 -> 0 bytes docs/imgs/athena-package.png | Bin 0 -> 33013 bytes docs/imgs/athena-ui.png | Bin 158798 -> 28639 bytes 5 files changed, 10 insertions(+), 4 deletions(-) delete mode 100644 docs/imgs/athena-history.png create mode 100644 docs/imgs/athena-package.png diff --git a/docs/Catalog/SearchQuery.md b/docs/Catalog/SearchQuery.md index 4b51655b4a4..1f4aaa7951e 100644 --- a/docs/Catalog/SearchQuery.md +++ b/docs/Catalog/SearchQuery.md @@ -128,10 +128,13 @@ run them. You must first set up you an Athena workgroup and Saved queries per [AWS's Athena documentation](https://docs.aws.amazon.com/athena/latest/ug/getting-started.html). ### Configuration -You can hide the "Queries" tab by setting `ui > nav > queries: false` ([learn more](./Preferences.md)). +You can hide the "Queries" tab by setting `ui > nav > queries: false`. +It is also possible to set the default workgroup in `ui > athena > defaultWorkgroup: 'your-default-workgroup'`. +[Learn more](./Preferences.md). + +The tab will remember the last workgroup, catalog name and database that was selected. ### Basics "Run query" executes the selected query and waits for the result. -![](../imgs/athena-ui.png) -![](../imgs/athena-history.png) +![Athena page](../imgs/athena-ui.png) diff --git a/docs/advanced-features/athena.md b/docs/advanced-features/athena.md index 993e8448a58..cb61b2d9312 100644 --- a/docs/advanced-features/athena.md +++ b/docs/advanced-features/athena.md @@ -1,4 +1,5 @@ + # Querying package metadata with Athena Quilt stores package data and metadata in S3. Metadata lives in a per-package manifest file in a each bucket's `.quilt/` directory. @@ -9,9 +10,11 @@ using predicates based on package or object-level metadata. Packages can be created from the resulting tabular data. To be able to create a package, -the table must contain the columns `logical_key`, `physical_keys` and `size` as shown below. +the table must contain the columns `logical_key`, `physical_keys` (or `physical_key`) and `size`. (See also [Mental Model](https://docs.quiltdata.com/mentalmodel)) +![Athena page with results ready to be packaged](../imgs/athena-package.png) + ## Defining package tables and views in Athena > This step is not required for users of Quilt enterprise, since tables and views diff --git a/docs/imgs/athena-history.png b/docs/imgs/athena-history.png deleted file mode 100644 index 7ef0916506e7775b133d7efd5b6ad5c10e8acde3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110713 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 diff --git a/docs/imgs/athena-package.png b/docs/imgs/athena-package.png new file mode 100644 index 0000000000000000000000000000000000000000..bdae54c277662d2794d34a5f0bab7133efb8d24f GIT binary patch literal 33013 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 PFF;97UA98nEad+IeqH?O literal 0 HcmV?d00001 diff --git a/docs/imgs/athena-ui.png b/docs/imgs/athena-ui.png index 8c417e376cdea1eac618cf2b0d241cc997fd0300..3f185385b8d9c23de93fbc00cc6f4b52cf558769 100644 GIT binary patch literal 28639 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

AL_7>>HWx$fA`LO~(Lc!?%Z-DdaeS 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�R$(S6P9oSSee))f%N26 zVDeQ8gE|nhPqVw0wDtQcy|llhd-&hc(JVZsw}tWMqrYbN_@AgkK|~^x97H8tt0h63 z*(lSb4xkWvtG9OqqKCvv(Qg&w;$t+S8i)CZ0*QQ<9^NurX3}ukF#hWpCg2{6faj6jcnsxhs8oaR;HIniB`0 zy1oohq!IqrS>|Xyo-JPDwQv53f5Ghr10TH&+(zc<1Vf8%YsS?C?4o!IDMrR|zF< zL%#0#)oM)56DuzjHlDS6D|2Yc~_(_N}KqE=FLA61)NYimSKqT$S z<9hx8nRP+xK>qAi%6)5j*%1y{M3`zsv16irZf$;JJ|xe1VcA4Zx7AAh!DR3IB!R6q zV9LyYD93+!bSuSjM?ndk}#{Ko{^(f5Ypr#|BW8!-RYiEqPwctm z(P9ci2^PD=2N`>2H1dcF&<&?IC=&)mn*}`^%ttAOJOB>}C4y;biDTt$haZc?K;#v%@{_-;Y8Onl&T_X%I42 z-5Rv*fA>Mpa4VhyA2C;gKMZQx4JU58*%YK2si~=ti5t$vzD~~9yKWj)v2psq!BBAuFji@7HCfeEEy0zjjQgV10`q3jxQ3U$nFDrLDbBzjcvHsUlZyEv=JrKMV-*XMBipM`>NFv$SxSGhq zPRbZ==g)uSgKrUG514&UMs>BHHtvGzWi8%yEuAx;&+}LO*#NAk4COUZz@4m7J)jI z|0Jb2&=PdJ)!2(*9jMR*R6(F^EumFIdukbt*8sf;_kRw^pKaq_V$V)(PpnK_gaO&N zTmM|$>w|ql&RqIJD`wtUS(#E|-tGW2QH}=G9=A%Z2g2`4`HX=Ug(rbBpB7k#?|Y(q zM=U`%(z4p2+dL_Fcsxm-4N$^n68mc+^(rJ2M zC8QQE(4n5A7Z0-a(RLdOZwF2%KS-Mq_xQyKv@%wTpcOV``*j)l_J>t!s8!Bkd;9R{ z(a)gUZaoJjPE9XUCC>sr;l^x-+Y)lN#;X{Bw>%1+3_0R`BURT|6a`7De4ao*ZOV%Y zMONjb7<_dT7V6+<6>2=uCtZii9&jCI0q*eUx&y{fU>F8BigwgdiXZ3&)TfUj*F(`_ zJ6qc5-xiO$F;Z$gOOLTqmytLmL-No{rt(i`nHwQ`2 z<-u_HctAk}PO-LBGaCB2GU7pNUQEevXF9I54jgAvDxfc(m7Mvk~B2A=k85Y)vKHLmNb9$u>1lp+*oqn9qq8;~ytXkp zqor_zZS{cm#BKg-;`D&G<>vJCJK?FLhhYt$uRre+o&$bico>P@KW928bMzztN+sz5 zEgx?{iOl`zXdaihBYiY;y|xo*x*R;o;HR-r46&(sk^^sCX&LA_J*vuc`dbRNpfh_EzX7N8`FC6#`)UbCC=yTPF^=p6r&9)dClHoTN{ZTdD zpV7GO+xL&#&1BxCr_E{)U(Vb=dPThwqzaU(s{)lvFI04t6Zz~~wALr<$N=#_3Yt@h zEzYH9Ltd>%_6D^OrEG5~pOxgUgwz$E@+*9-TZA zdy6~}JOQk3Y=@ok3zswZ`bt{q^Ku6B?rH-ljBeN%sd_4XB-3doxe`b1b+*(6#lw5( ziL!6++>(Cfuug4XpszUcqNth;7Ku7F)9m5DEN#HDMZ`Ku0fdaNZ=)udHyFfxICtKsIQ9QXJZJVx>v(kHi)Jat z!y_bXz<*VHtBRW;Dy+ayS238j{K?20h97rh(Dxw-^{g%0+sYg08Ez>h6-JwG95~^n4mY zGk95=ohfazMSROI;{4RxmARyF)rrF zu~T|=5Pb=cR_Khv??u12LZ#(W`x%RBDk+}F@@(QP5i7UxNz9Wo@Df8m6iXod5{JEV z0sVk}G)JoUCIne+S`!@oDTsmv_Br311LzhJ4#-sMO4ClW2uNX*hCK1!8Yb8k(cpl+ zo^iBZRy-RFF1Mt@!*i&!w>6H!A$JcmeqE*xWV`IV(0DDKO=#csJgd99Hr->(tL;?A zS2xV0!ECR-MyGLZr=EF2CLXi`*{U^{*u4qXN{zBZ?>`%#1r(CpO7pB=JXX#Ti}hs3eN=1IkL@_CL?I!z z5azOesCS_x|GU>@mxe)YsytgOq9Mq$1%U#S^ShhXyH_WimgcMHyV_+ z*VA>I;Fzq5>M|>aq3P75=));oUHbC=$r^(hP(TLdLwb%xpB!#fc>}^n7%!v2dr4hg zyYM#TU=2Rrn~jP!cpFX)dUE~3byoXP95;30y-m5}Kz{l;2z-N9in2LKt+`}&06?@a z7nb`5%o3#=Mzlg1F??CVvj#X>xTW=Ghq@=XU0~J4HBBKq>ZSzp<=OJHEEkL5YEE9H z7AExC!KAzNDtKz{SBh%2$t4(xP1E6Z+pX}?@%`a73HoV$bE_4L1wfVI7hq3}s^*+o ztOCVeii=EV;~74;P7fJESoyimF`=Cxz!Tq0@*_WgX;l0cmVTmX7qluzhU6B)BYpO3 zKUyQImG2?8NH^lRwc+t#PH~ahd;i~{IBsJ&RHYe;%6+YvvqV{{w?!!`eP{Yt>;N8K ziZBv?n9FPh5v(v*ywJvSUFMtPNjJysaak%Hvmazc?eiOfYpt!H{8pZw z%`=$J0A|qQbk5d961FXG*?J^g)j^$9^?yxvZ=iU_i{O!94*_0*UJXf+X|Hk@1{r+M z>-f|0Ion{ls+y`GCG}WVx))8xLEa`+B!Y9CQ^w|6Ub>Ef%gJj#Y?`U$f*{sT%dwCH zwz%F5wA0d%cA4tfmKKja9*rzIz-_wg(u8*2yC7A+-rZ~}2^<<46X5sWl|Hsu=-+Y7 zKWhN-M6Vi`BE({;bI@Y4D@0#M`kkF@j=9Xrv`q&Wjuj-Q+MAB@KeWSx(>Dt*V&D$J1j@)HQW_KfnW(sCZpM(;-)4LVG(j zP-PAf?Xa;sJEb3vyeNwWdJB;Gy=eJ0tLX%EtCcw4GX#csvY+zS%$o0kTFrAd{^)ps zG^P>WMh(1edSM68GmTVstfx-cyQ<(v4n{VV1eSchAsvAVd;S>Rmh-voLFP(sp-h!N zyw5F<^UiJ^Ab7e;on-2>P15ho(k~D!j9ZsqoL)Q<%PDI!xdmt^?(KL3{^ScSs$&Qk z+zTv>^RVM=6+qESVA7vKfskT4>$AFD8%>Av;w$M31{GUa9QIs&KUB>|6T=_%ep|O# zR-qL{n@Fg>L58!)_(|!?R`_6$Y{Dfpl0l?z{nyb*6qj^w2 zMDqA&%G}^inH7^tqSM^dfl?@x%*5Nisxvr53~0h`4{#0gW+fGcy9wLd?YMV9-7M)9 zLX!ftkrT!k@9oNfF5u8MgxYXJ3x;@jH4~2mU+FU(42+2(7mtwJZ5a!B3%VAA!1X*(?{gMfL>0B4zKdv6I;ItGz}(0{zC<28HEpSJTn_Id1;~4eDlmzp>ptu zmst5%!0Y5(h7is0ED|g51EGRk0dA$@(oT3ON1-VT3$0fB3=!-4)H#`XbmtY3?Jvp{ zL14P!m}aP{`%iBRKfihCtJ65YUsw1T6IB`{E0q>c_Ip;gZ0HYM*r8q2NtD>;?v5Lz zd@KXYb-^fXBDst+5j%(3GR?KY#CNhRNq(SGpBfPBmrYFbTj4yCy-M|H@@<3n;Vd9<+FA?gdAT-3zYO?|!)cs2 z4NcwNNHhaf@?ib=D$vh^$aYu~5R3K3cRXKV!TVN-uA&n_;^Y?$j!^qs@redYyaBvT zhd|3kzZdeK?vN}uTs2$U2=5bfKToZ3Xw(x=FhPf|eJ3&vVcq0SDW$P7#?5tx9?gZQ z@!&|hsr}MDgxG zlx{S=>8Y^XlkUYsv&E9haGN5C)x-aYLbNDX&cZ_3S)jXq>b{n#Ut|yP7oVs*uWbT= z9WccvP+#Fe!s08mWk)X)pd{|*TAUgX*?B4}XnF{+8PCru_? z|8R=ZWvNE;hq3xMbOWJGv}hB55{{6zmdF;lM72Hh4%BA(VsmVNdTfh;)q?XTpm}n^`wOe2;Lj{{ zPHek73Vu!dc|({7$Ravx4GTnB1W=8-CQ7&?-Bu%kW>cOW(+@ziE&-%M96`FOFvA2Lc&cIdiI}pzdTW0M2a(?Kw6zSEoP)2Tso2)eKXvsUQEj+7R z1oE?CfYaeS?cw>hz4NQy%oU}6*Ph+RFUlkvEo|aD@8Vi~e=AfZDEd|f@UIM`oK1Lg{&W}@~K({#G$)?kxbttcf9-ZC&?&0z_ZZvQv)-f%aoU5$8vgfe63i-iB7Mk&T=8 zFo^WN14ex59idv$jEgjaS5gv~7e&tcnjV)4bKs@5#>w?Yxk@2P>^+r+K0!l{fjl-p) zjNp078EQ>sdJxet`upy6;>!ZcTyN4llf@jswC_#E4@h_QjcWAh(@ige%_iXeMk&5I z5a3_9J8q-$OG~D2B!fsY0{oE;#BAx0=v(KQCwvdx0KqnQ?WesLz_{CHYC5nL46gsx zR42Cm`hh@|^zj2BsUsyo#?*mMq?hGp)-*Qr@K*!?#GZgt)LcO}0K@YmFJ77p&;GjY zm3K}xZ8l!1yFFrFJUhBHRkYk}ZhjVhhT7lx{UE*e*#I)V9LNGey)Yq6eYZsUQ)?hA zs5*Uwt)C2iB07v#cGpnz1C*^%#&V@r+|vfuy@)M&PzSs4<>8< z@WJwN8BA&2sz$B*!z0lC*39>JBV|i$<&|CUKmz^oSVrzwy)hi6`bn1Ji>H9P#2mmM z;kN*nJ{H-4ohlqRun&w@-~&CHhD%42Dn2AvVDp;2fo^`3qpJ4Mkr#>&#F3m#HYPwM zB4c>^e0kM`rVdExO=}%9c>cubC0#r(Cl0P1-G%~kPgQe!xz}*VRGr^15>G%V4*yV{ z;d=n7XNdm3efTcxAwA+Z^!fs$??4|ruCJhn%RAEH)|u(1z46t~*YegD3GQ>F*KJgZ zU}Bn|C8dc#+&ZrEq>ZMxQ{3U1O(u+=ot{1^FSYLTP$e~EH+feQI1d%Ss+?fg2KYIb z4h!=_<*xFx-3;mPKVY59m@OiO8}vfdYs1pv(6jmL7x}3FAt-+DNodpCOJ+aL!j$g{ zSU%7x7=agq(f8mxYAKC3fygr~o~yOubzd7NG!LLW)7y7-io9-f>Do5~i@(1rJ^Mwu z)NDbhetR)vy9((W1+;H;x%kIve8X_w*ZW2k(qkIUAfuP{o6H&v$G*!pn3Ggw8Z$bq zQJ_vhp((ffrh$~AykKzux^tw@z4VM=hC8za1l2_b!WqMnq5PsEMIKTB+pN!O+&cD@ zEEH_4u`+?erilogsHbZd4^2#xuf1M3R4jEB&?y=VvPwU`1!%08`v8e;AUOz~yU3mb zJ=s~M&iA-{u*bqjJ7~x4E@5dvrSD)SSkfC1#!(vqs^Y>H488W1wilhw96?QvL}~$R z&%DXOUldFiXHhG)6=#hgqg5hxG-y>zdaUWyd6tUoFlbd99DTlh7_q%AG5;IN4}|(k zk}zeFXzTSHfD`NjF}4@y#o+%RAptFKqD>-~JB0fNRB2=wthvtJUU~^q#&TVzAoo<$ zne*UeF^u3xIFQ=d&yHhTGMf z)6MVHhmmIgZsmY$Yf}uY{0*tyjb5^{_#1$*1yfNf&1$eh66MC*q&{sheQ|R&?D8Fb z^U~7Xs}%G<+j73a^IvQ?n$;F;H{_cxv}JjX+3n|h{5u8mKkj9?&h!C4kRD5IQP7m5 z?A8_;&859P``$VF33cXDxMp}~M8Vopt4WlfY}IVFXSM&xQs2J3ke&*N@^J8nl37E; zry&~rR`~O0n4k>*F%Um@SfoO87B`!j7Wm5XRJ^{GH1}`v1pmAR2Bxqn{(I)X{`|tsk!NnCsIl&Ir})u98H+%`IkXF=lZ7GtJdX)H zWcMQ2%p;_fipAFGc=3{dm?|<)Mgob~0}wIF`$VQh%(VM0 zH15+kT5cs7_j#z}+e>XxWktQ&Wqysh>hMTV`}uoJ8t@n$5v6domW1GJv9?ID=Ga5~ zvU)aYTFC16Kb~NiV(97^j0{the^r`3nW%5PpSiXW5n)qLkw&cpK2OCDUosvYW#&qa z7Hf)6aX;})TYyZG_Q{&uGwbs0_RxM8H~g9;x}k@5;mcFEPgL@0%D)3<;M#Dp^eeYO zAkTbEz#62wK%-*{Hs2fMT;>#Fq_NEU^_$^NH5|Ps0F@6)gjgpK=+ooV9Z5w z)ZY&GS=~VFCV0Jw{LCP`HuBQ}Du$9;D}^e@FLreQK(Mfs9Y2 zkJPp9&PGB4kD|a_u`7J+z->0%zCjdJqD}Wuqme8oQqrbbk zys5t|VP2;BCp+;wzZ&^QKBhENTTz=3wppW1t{%MfL#0snE!THWmbDX45!E=)NPeVf zQq2ZA{cO1Gn|jhwKT_s3!``$afjU24nh-goEGa>9MWxk)e^Z|eTg}_%&$+$L>k~47bah&*iqg9WuZ4(tD$%!Oj08J$$HH+(c z!Jia4%RX~vuMvt?H+J;GBZ}siX7i$AUJtLmWdly?n7Dn>a|fZl_y^XOX1(Z3(khAV z%eU_KmC@Q4KA>+l1mO$R?+?XCvP;F@xpdco3W@s0@a}y zKi(!8uMUwQxJ}Q2XriPx2BDV4Oj7K)tXvIJatE|Gma^9K4UJ(NmCf7QIeIOQD+l1T zbZR7fzCe?SQa#I`_D1ai8(s001c|PW_)B6AyrfPFidtIzgzehY7&;?e3s3v1=4E1v z@{Nk{naR|3y3ts;09a}HZG#(i>ay#k^-lVH7)OaEOf)^@d+6k?42V&ijEHJVktPZL zZ_gj>4VVHa+)xt+Q3Vs>y{k;aAE_t9GBJZ1Xg>Z5%Yz~7U{ps~)biUxvHq@=?ozrM z#cBItk#_swh00c&H{1MMky z@tF?)663M~`5J>nl%k=kg3@x7UE-BkqCbrdhYj?QAsD!%d#}Ploc<&|_X$B3P1g!{ zA>~?mDn{mq20~RrBRP>`Irld}}sJ79Q!hKU65C z0Y>`z54HguU`480h^eM@c7*N!N+7K@#^2C(4FqUdC^sBFaR6t64;U3+sT_W=Rq-@L z;^~i#w_k^*&>XO+Vbbv^D_4U1`LYQ-tG70+UWw~(kH(Ma6^LH#X>$81DNXa_FIa7u zWiNcaU7PK8Xx`_9q7LHjZkq3a$7bA%(>5jC$Xy!G&u|}`eOBJxA*OgRwDxub#b`Vl z>j2<2w^)wp6LC6ImFJMu+=%(mu4jK*x|2gCWwdc$Zu;0t@~Cc2 z+azCEJ^ju8qOxz>)?e(&smGY#noN`0EH0 zv&)8$)c!Y*-zfKaZXbsAW60I1dyrifpr=6fJwdM9L9!9>hG)Y?BC}I5L&Y^-5 zoJ78?gqu2XfHMOf)*Z9OLs?a?vFOXjCC+RsF`hHM_7`@R@gnIty1U6qUZDs!RFo!I@&>yk^W7pke+IOcY3V}X;dPWhF~D`TgVjNxl3 zMtKXOMwrq0h^9GhOIQ28SQ{Q7;`uF^xj>kOX9Ya`aAq_dQOx`runtJR03v-oB#s4v zjLK)MVHX+xCTd?&5Ek`jTp`VkYj03TSJiM|9KZ;2Ha3OCTXkct3vMAK&lkLv8iqGq zwd$#K{jHEXS5Cmx3f4hAIMu+fDbT&C@~uwo47toa?hTPw_M^) zh_WZ5eY=Zab82fFojd_I@_PEClZT&bW0+M7YojI@(%?rVas1m12VkNXEWY2rT+zRp zURze*t)0a3)bjmIDi&B+Zd^#xlZ=QUL0&M9d5s{TZ$1IXtTzI z$c~L`i{pv-;L|5&HgK8zZi;Gdqhe$CK-97#J*B_)4Gv{r5DLb9%!zJA8S3ll=u~|b z#|TY{>&w#y|8YC4t9`lM8I}_}=HOkxLZ>F)WVH;0>GrT4qO+nvfuLbQGGMmR@$mYl(>Ix|$RftsURU^Nw_gq>~N8PPYWE_p7P zKW(T=%S{hUHnZ_zND)iHfVHX7)tniBbmY?+Z+!YfHmaJpe|mW+X%!*TIb-4`30)-X z=c}6C2$;bYP0nRvEcu8A8yfgVB?~xoNi)i43V_d0FQcT23JZzF1uyy??^xuX5 z8B6ccd1V+s<^BnM<{umcaH{)7q@II#OIs3sNvfNkTmC2h)$;EL@`O4>R-wSgf`mjA zOooj-(yB6c4PXtZtao!7)i(})oiFLNv7kYpz2(U{RXJicu9;-usa8qt3*v*}s3~;j%xzNWF@*Do z0|RrfM%_i4D5XoeE8+chI!26~UwbO*Q`~kX43W%ooB!K_{PB$5P`X zf{*eI3iTwS0~bzMhT@`Z)3W#y`_NlDhersHFOI7SM@ z%h+?TXqD176BX)6sVcNLb2cXv=P~=g>E+EU$=K4Y&ODPdf6phXN;Ih3^yX;ok58 z4IM6xJe9pPOqkX)Hv}&&#=C9_av{EON}DFZbgomC5>yHiE~kI-7jYIoUMR;2A83eUOz%->Krv=cVKIk+jJ92EFj5xE@VQ|jAdh|*em z?KXkcgR#u{?FbR08qqwjsr-=k{E%@ww7}dxV*99``!D24L-mf0K+kJ)HGoZ6M^0*H zXf8^+)WYIJrKiUQEX7qHm>_Ki^3)TJlo{ezSiAK)sr8i9pMeoCj` zoJINv!S*-R*FcY>XAud{wPu zcOZY6i`?vf9>dvukRfy$4jQ+9zft}jA@IcDrWrUh%DcEo4XnwvneSaWsyZ|hSS}dW zEVSCs*J3^l_VN~NaBKbjds+a}c+W%QFwB=@ZM!}?pL_OeWTcp{^(pl7)V>m|cw{Ze z0=9B(eX3n-1TB_EcZq$pnQZXMC{KaVJ$0dPTxB0s><A2ALUgxkS6VQb~tCm-N``3imv z6^U9s`dt*N4a1O6^cE9kT}#RPOL3EQ(d|O%f6fnQJXyJBm=(6cE|-GHUWX;ASFG#k zIowHzm*vFV=d$Y?kjmz^kMqQAjPTsFw9t<9?z5W5++?E*-Jw>8Q6%aMB}nRQ(3H0G zkN(S*;uz-0h>U_hrZBXM4oI~g>-yZPixPt3ZY{oYoz+f-whD__jlo1}R@9FoKgxH? zUhqyxNOF2CgQIvL9#O7*2=26+%6(yclYGa_=J>@hb-5dQ$ELJHBO1@;e zO`=F;@_vKr6_G*&Z`d}q=hQpM_9;#ccPf47EHrK)?BdV9eyk8tqL*E?m6W8##)1y- zev;GTJ09Po$MYm7IR??`#jki?h=^J?>34X)AisdAJ<+pMj7T`+9_kO*jQr4MtyEgM zenUg&EZythJT@8v75TN3d{qTXy_I3MI(E0uahdxDLsjJ>#(mjtPx_Tqo5`8_`sjC9aS?hNIs)}lNX>^&%zmRj zJB8tJ>uSq4;69Y2TKX@T3Ja>)$_jpHDepA|=sho4j}V#d03Z(&AjJ{?ijqDJVHm4L z?^qt|Y7qNO!GASUVT1hveK?ESGv|osvc{BFTtU&PVM#7RGg14+b5p@nZT*;@tn709 zqG8^d9eJ2mYujfI7^UxNqEqYoeznWFZM7p>b>i%F<1XYRUK?B4#G}`+3AXcarP3+% zCa^zFG4~s`RkCi{aypd+&0&?MzS~1RF7eP`w>^Q1|72E4t)=1G^jrx!N7A}b-r@GW zaVS}T%T}o2y=ZvlOrR!jJYSfywD}n$=Xtg&@+NfTmsn6#YCzn;HPG_6#f`dHo{62Z zHfck>h^}$>wYBs}$C)vL6uzCG*2try!$k)7sr`|9Utz^m{)XXg={tTnX{)pUZ%<)y z+_*gZp8e!A;QWZA9|SkU$Gk0ATYbe%Zxb`^zFZOP`j0JEY>3{F_BJ+-!2K#+BcwdelN$Eb5jq`FS~;$wTPvXm2$B^wV48V@NS zRe|K^omfRo?y#jwkmydAP*fFoT#Gz^R%%r~j{4?Qrsp@jt5%Eza|~OR49ngG!QzY8u#n?)qzmX%_IaX71L>jtXsFLHe+0F+ zV2)|Uos_Drw)dk1W72aid7qGb>ULgpSL}S&eG8^{x+#v^)7&M6qz>-gnK%(K7^BMK zLEcr?l@33xY7AG;E|%fM1Xyq;BWNRSEwr)z?;gydY=_Z)*8Xc%EAlxsIKZf09;fxt zJD+JYyZG!Hr9!&x9)YP|Zgxe#Y|h@AO})N&w!WPqYIVh-VYenj68n^EARoG3GaM?v z7^-O-g)9AaDjkO$`j0|GoIbYiJ$-3Q{{@NwYxB@H{Z<+}@HMGHBLD1*vxBT5=R(BE#d&}thlgLz`6_0{YLyRCiSYOjx}y>JiR3{ysGugg-SXg1&YJ}pd&zYN?E!R zO4h4W&ANy@K42?-b38vbJ>hoCvdfnomLYiS)`Q}f6P_))p{?MJYODE_>Q{XUpQaD^ z^(s&F3#ymC9tn6EMK-RrW;_#Y-qvDvDZK}c?~oWg%9pp4oA$9g17$!!o8BI5C#1_K z`t!v%lYngl88taGUpR^qdG)Lqn|*u;6~Vx%4G4!e8~|=u`_RsL#qRu+X`pJ9dRf@@4h>PyBniu#4X3frS(x8QyM)3>iKzUyGHU^`OzHu(W^gSzqB~#KTtqte9JR0YKeJ8HQ&f1 z&bL{=_Rylmg8Kvzt$*|5+5JMxiSl))Gl$OjZK0o#w$ZXT0Lp1&F>5AmgIt?IC{@Ri z%InA7U_NN0E%oN~39p8m03m5o$c1gsWQOwL7RrXrkBxgUd+ur`pRFwJp`Zr@W}bUj z+0^oPGQ7Xj*-bUlvPIV3ewt6fqWUQS0Im6QK3!_*YZOrHCg(2&cYvK(p)#d2T-#L2Z*D~+V+Ju#DhDUqMMpZ8bh9vid zF4heI(%a37s(_acc*O$Ksu=Be`*=iVTzf^cxSAH~F(W8ZVo#MYy)V(0=3RJ@a-Mgh zh|Gxq_E}aTQ!{TH0IW_X10>b2fCkmjwYuuvM*7!XJYkW@`Ks9uT$|q~X{Ww57|PcJ zL(p(X;+l{5p;{K>@km_u`PTm*SW4F;b(qVh&dh74^OC-aUwHJ24gDLmb&D~lKJfgl z7=4HU)@sk}WZKBeb)z7B8Ywmct18{01;LX=$K8K%?+JgXW$E#bxv&5`q@F<$4MO0B z?W}qGm+`(w0~YYS%ULKPCUC_!=a#DqPnA{Vp64V(CVyyMKhaZ%wdGXO@cpm4-aD?z zY-<}Hb;NN5Q5Zx(i1ZODf;6R#QUd}ih7M7Bk8~*^DoU3spn!l#5kip~dPGV@dhbLD z9Rh}uKY5gTI=3LYd^!}AoNBphy{wEKmF!n z+*e@30|~tF2EETyJXs+>*xq>e-E6Rhjtw<2>Tx}j2a}YGO7!6TQr2PknWNb3hc(D9 zqc2})2VhbsaqHt6D$5jzCha>d)U6D<*A3y_$Mjg47EV5aIZ={W6sh|ut_ir`$I0qhVEV0o=rKp@fQ}{Ya<}YtsZu*@IiBwx$kPF3gjE?N<%CV5G6()(fp- z3oJ*lm1ECh+}l5K7JiS=w(`qdYdr}_;bmTP^5~biOjyTumvyv(G@6eO6k;sNg}T15 zCy5m0oiS(B+RX;}xE_MjKUZD9C}%C=HUSGdHD_XKH+wi?-=8Nnxz};(;!V3MI&HwB z{-nE7%ggaup)7BrBrrAC(N`Rc-&o%^LytP-E2T@NXjsze|4g`=6kYDXhkk^yl3%|c zVfN}5B$-@GgGcsveFv5~TXBT_AY5O!qn^sHTEwPxJeYtPTab|qIy*P+Q>8nS^R1>6 zM0DgR&kg{4-XGk3=AN1umz3lgVJVZ7d@Sa#JeJOuLi3_1){^ETndlTA$kp*SxwDvq z&94R+?z!tZeE!p{z1gVKX(?@sJzE`n$Z@R!Wu68L7Z%M3o4Cw0HDL9)X(wb24QSb} zLZ_h%4P{hKvhRN3v!fIGeEo$g(lpF$V<@1YQy4}avJf2-qvs&dk67~G@WznMkkqu4 zXZRYs5l&EU$jtyWdI0fBpulAU;44la5<#>bN@U$UL$#NauN)pt<&k^{S1^SW!;gQ;J`y2 zXwl&gq^<*j-#|Wl-k`rPzk&btwZ?kkSeX!$zQDQHka%h3b7jV94(rz%JBYHoNh;K zHq@Ky`_quBR^CVt6Y(T$et(p5jogb&tg^iJX0BGlUR@EMO?R_CT>>|qECRMr*g@|Cd%!KMOEFLo(Km_!Q%FxU zJ6MsPkLg6I6<{>xDxMlRQIGX(2MZj<0i|xU1He&Pr_Hfx5Wa`!kY6J_epssBvC{;| z$b9vr_o$;Be$AE4|0zygpSJ@1fgQ6}u#|q;wf5o9$CWkI-fHL0E(9$ta8TheK&0$~ z-|O@vulh`|vZo(A(KJM=zduK%Cv;SFL5)Qa$vuJvJ?Y%Hsl1j$Gk*rs?$PV6s+$ zvN%Df&D=T`u3Pctr}w?h_O5b(YPk;ue)Yh^E`_p1B-q5}Zg=Wg@UW^no{mVciUme% z+c|Sooj6lC;iY0WLBnq6r;PY(j*VJOi@k+#CBA;~@eb0_Ik84=Cwud^w?l>*qzB8U zc_~-!>4I+fL;TEBq_G$d{Q8qJClRUhzM5DeFZ9aEJ+Q)$MXXJPLgxIJ&$n#!Ec@z% zw^;7vG_U}ZWWn1m-MJc>4FC69fSd-?#tc83RQhhl@c&wT=5|=_ zq;l8wVE`-rh{Ve>zCYrAG^Yu(pH6i@ySAMi-P?ZHIlM!P0~JTzc%_^q4VSxSR;0$4I>s^S0h2aW(7pA*!gx{3J-4d}Z$TTL*` z%6S{vCM@>obFNqE+@6@2AzG6$wAz|t@@}jej6Ci9PQ_|K-LSA@@<<}*EV#c1^>4sV zF$3spSBHV=kwg=;BRqDaQdZF$^9Y9S3=pONplvP;09gUz%p3Ra596p;(p!l|X%z}* zlMel9jQDVGmRGhGN;p`TLLr6U-1ZiQ?-X=o<3Q=f>n075Rt%ndioJ=d=2%}dgYRJ}ZntwDoVj1jdcT-gSw%R-$M7C-^2?fi ztJ$-njJ?az&!WD~1FS8M*Y{+yY4sb!hJBmA=}mbs3ytOV5XPm|NA{B7MJaH@u@67Crg=Bup=%QD8)mRX$Kj?yKwj`a{|oBtUB4igvlAKMwB zP3pDOyJ7yh1JNG}A%|nbYn;Y!{edP&78Kz;3f&+fYUs58!_tBFT_h<%x|!D?f(_^~NYj2i zH3(7LTX{SIq+@OU{yk(QL5U=~Pqxjc0o3d6z>Bgyq~nY*1~!V@RdRZ+sD1FO zi-YUZu9L2aiQ$1^75G;NC1Ja6!WWw)EATF~rCY#(Sg+V^4pHQh#=` zM_?pO#s!m{YK&W+0Tzi=NABxJ_i9qY{Zz)if;p8WLzR~UXH6w-4&}Yjl#@2C+uqvV z!%qTQr6(pUp1w+Gk#Tn5ElNvx6Q^$K>*p5qsFDcvpF3e5wz3;}iQ%H-B&bNgzT)v2 zZWXXlf9#!}rIfOjnAIt^w;yTix%orRwq-Ok8-xZxva^UvdwsE-y}oDm57;lsod@XQ zOYq3{$MvETvl(kkX^ywyk;Mf|W|%OBozC*_A|`#xuA>E2y&iaD%RckQb)Lm<1{>=@ zy@j?YL}3|2RMJ0JV1eeLSCcFf==VGa=oQZPXZcWd~Yh2lsMDUkZJ2 zdz*MhG~eQO#u9ugS1P>t znfuFySH#25SgqLa!z=O%;DThLOxu3-Mekwx-mL0ls9E|SKn43~3cykx^MErRDjCu< zgG!MQpvu~5%Eo*gk;Ysp>igVXWD}EO+;r)KQPZVzY=lDz53Va#^xH^z^C|nTeqhf5 z&F;-s6(!jUWJ2tu7_nqgLC)BI3^( z^w>w=xk2ZYws?6|odaBuFeAw69*y^?TZG znn!!aDeP@uqF9_dPNFxPborT^P9O3%y1fZRGp-+o6|hInck5YXd!Lw+4Y6! zJNDEu$*)N`B^tmBlW?F>SplV;u9B&AfGfP5xkgCDDFbBVe{jDu?v8w)ma7q~y+;{KOVGNmDl4yH0O?KY)!#5lpd~hJ9K0m$3(0 zOpnGI)014$U`qJ4z^}ntQ}s}MQcynK1Y;cyz(qTpeY>L(7!RO*k7q#is4$;pdwpA= z{BG+*sYM@|TxEOa8F84SbZYNH3}>3KBhPg?-ETZ^%kMZWb8ir~C(v=C$m}*+LUZ^T z%jL04Ku={B$m@z~gkwjATDi?99`X1LTLpSicGwM|$#ug6=qB`-YQ)OgS9I;D^Wv)atcq=Gs6thpTLbaFsP^zwyU~*V_?;0WPTAYJLF`)}xA7qDZO> zSD)1E<$2q+4HcIwz@C|wg)dtkv4x`s#owI(<2R)=&zGG?b^OnEV&efV69=XE;sML= z{DJX z-5}chZN&KTgRQ+gfdjLrCeLZJ_*|a+<;t<=YaP$t4+a3Y!0CyymL@*sRH-Q#j6S9! zZ_hZ1X}eKW61+8M!^=838W!GlXTlHiZeT4WCA?(Ix!bX^kp0kE>VVyKIg%G$Dsd316_`{g4(@NY&5MJ)C z_@pDK#&m1?x(37eunf(>K=l~hhua|F*p>qV4wG>7K_iFZtzCFQj)Z3%5Dz@E#@_}zHP-57D@=mYcsK?~pR*2PjQLzlMU74tZDjl*Y>2s&a3 zH)9-lNMi6+ct|nIu#~`^ZWJRkbmtvI8Wc*C30p%jpnD7kT8geIkW=snf2gXO0bG>w zBzC4%zMe3D%VWpH%t8vKxV$!%_C9z&Al0XFVv)ES{Cca2W~(Z@ zB<9%cX?JBwtaC_#XBzcp9~R3{0yeELLTQ;fUt!dD8r0il#!?RLE`5`tLH{z(kRfCL zuha(`ScaW@5L{@4~2tB;*5=5R0^Y;kya7KtbsNHqQ=! zh%*Qb3O(ffE<62@kw`GWPA_2@v0QJlp@(t3GE?h@bl?w!=#4_xUn%kFiJR_8IEIdc z%iJzS>T>xW$$l!~3&BG8P1cbEv2_LH(R#?Gjvwg+k?C(OH2l;~@S zq_rkSktK;r!ouE@#)?36I(yx~aoYC~Dg(!ONjt?ZG*yJ=(i5*)-#C!Ofh&tkV~V36 ze>J3XZ7%Iur{@wwHo1IK%)OE^D~*_WOm7j3E7xSx>yZ#m4Xfih+UY&>`&7Jdy17Af zP68>odS{jEM}Y9pf4Skm{E;s6oUxBe+wMV-cLEntXaZ;vM=@Sv_u#T;1Z-kM=*3i` z2AQ-&?7<$UHZztc=y$_*1L@ZZI^c{7s{%#-GmwsG0p}*DO>e3KSF3&gUZ&hlyx>Z z606Vx=7Lz_S_+H0r{edB|Q|Q)z#q>!hXnl)4>Lt-UfOka=A9yF3|L^a35@L`1L<`M@9(;p_YFVgFNqx6%>3LhjX}Wz$m38LC!Toh0M!xTl|G~t6%$I;*(8JB_O)ReFvx`^^*6L@ZOP{Ow>XusbIx@z?_KzpQ zu+yzziCEF$6uNm*?WtJHLGnZ36j|l;%*B!F)%lR^-TCtBhL}F=GZTd=_a$ub5;t?@ z5ht7-bdY}WTrK(%0(&7>#Nl_Hdy%6B1x2I<1M?43GW=zEB?jtJiT5ZWsmibFYW!;G zL6!MfH=(Nzn}Z64kZ;{76` zi3U@x{rB0~QJZ5kKI!IU$I^lOZbIC6jbJO#?A+zxGO-=$q7jOrQ1z{yHR&rMsR47d zHE+11gNiGIg%<3!Cpmu*&~TZ5>g``1iA6JV`MroA%(Sd7+a~qFQ|l&&M@^vj#zb?- zzYK;YQ2QjR29hyj$>lxfwfOf!?(BT8(yS}!MCG#e_2glPvBlT&rqGOOGKw!r2YsHG zv9@RXAE@y^=?slvw4JXkO*Zy)EiwzTw~#tNkQ{6Dam>tfck#)74K#zdT1?1_u}G^X zf;=1C*a7`2gyLZaCM|eJU=`ayP^d?>cTd?qh-=O)fw}7;7=j_Gil`Wu@ZRy_lN>#E z6Z7~-?;9oMU&StZ*S2lneB?DYqdG6jEAq;{a|wV9*EIR^`}67sl}mY*k{q$3cD}xn zVzKTum2O{}v^ut?r?PW9sjQiNt+sr_mEj_Vn5Y(1j{%^gs#kJYGLYT<(F%> z-X-A5j51+oagD9Jy?*#plaWmJ2-}HrqVT_l!AOP}qY;9Sdap@prYbH+hst%TDmRl)yQQL{epcCvAV!MJ*PBW!-f zSiMkMY3v(eR`da~-P0OXI#){@HjV90F5mrU8u{0Rh;4c}3rhc6_3in2tHGcobJHuh z7QEGf)cGa9>X=Qtv0(_{&Qs}`ywV9bovLI_a=unV5^(jr{UsGa7vSb=J#RvhWirT+F625`m&EY9L;US^Ej_~N2Zy0sVnCQQX5)A;7R zpR3C~y-)524JbGz`>Rii+`LG9tc{8RuUR)N$6e^QTXgRQGsJ5`;ps}@vF)2sVwge$ zvO_gJ$7MaZ!ke1*i#HD|{K@yu+}niW|*t2XccxI)^~ zFd=%B{+@^`DaDg2bQ?5gKX2)bYjUQ=s11(V>1mVxi{1dllfG)Bc&n&NQO~yVFVjrF zR1X9752AnOYbWnlME_hWRP<&CFNq!crN|SzE!JOsoLDmD@#1=vV20Zy^=|MbnyNGs zZ^n0JU?4h3J?6&2(!*^(>EGF{6Uy`(em!AHFw_n|6D0e!4Dukw=&q3xFg|T~RDND` zsh7a6!H*l|pApTqQ zwi(~H6-ljS;n;EO(X1tB=orSZROw42N6k>qXYwT*(_3cUn^G2<78r~3-Pqo+dL zw8yb8@=pVIb3lSpI%{tXlMi{Sm5+SCUb4iA$~f347;U1w;pt-x+8CF z#@b8qsf@5E#Im!Gz0H;J+RRGYjW#mN5wxSInP7mV$7c+_=Hibff5O!iD@c(Iz7e6ySwHDW5#;KHGmjs*5SPls;-)KT0TW9f1Gca|?Mb@u`eJk{7}-Jo z#e}(BGl-uO?!AhGlb2;J_eYVr7TyAWyllxA2nFxg{8G$A6Ivh+u>xnubEQ5G*Yb4P z>jzcp17T+7iH!WvBUD+_TI`33-QY(4<}S+1R48Ce{NA$+TljvYXF11$wfNwA*^gmK zSvR=_*VG42r@fOBd9^qnB|0PyoyhXvEKMCZzf>A&S23;8@1NCo9zR(lrNO9my-+5J z3SwdxfP6t2?`<#oCmL!i_L&yX)ty9UEt@dz_5&~~`mRR5B)Vn&jg7XVdE%9gdCCL% z)BbT8Pl<2s5-b`&JiCv__NTln+EOjFxNqI)%UnZqh{1xw;MH&iO3Tmce1T-{=))4c zV`^xXqOomunGllmrC*u7S28a8PtS&rVjjgStse5z&7MJPjV=A_v0l=AC^q%HtVg@Y6080o zl%2j-NFf-zs{tx>VL#ZMp}6mMQz`;ly}F_U@<$By@#xiBW?qcLwlMBqZM;y*(rmu8 zS7M-2_IpXsjanhT9wArjPEx&lYCw{*LKH($Bo7XoKF)h zXOe4Ofor*CZ0Zu_V`WUnTK3G=><^oT$Fv5dh|}t^GOzaQ_KNM9lQ$p*2+1USzuj|26;Z*TZO==U3GuNGPm|r938ICy zH#fK@z0UGi!wY9Smw_`2J?(QgVBRCGt@Y((TWSQ;G1bUT>9iVQKg#9?sdZ<)Ema%1 zu(VOX#-Xq(N=Auo%{!}M0q5l5$*ySpNj(!dy-Jg)zpbg-@;=@mX1Fl|QsA3wsdVNl&t#lSX`zW*q}F7^5*|K%1Hh4FYwz`ZPXHYh zOlPDyhHC}Xjh@I7_fzY7fb)yh>fGOrpivv}EU)Ix9PYON#u2a|=s~)3;xGfvjqG5n zvzD9(7Px$45M^vbeThtO^t7H)m+69g$Y|v7Gj%q!HA65Ba@%5`YvV(!#h>IL3z2x< z#{y>H4>gr{Rl4& zXM`%_(amuPYtc9&OJODd6&QvY(>SUWBHuWjHgz0ZccYYZjpJd0loL0c32Jyj!$wv4 znX0kKN=18eP%sUJ!Hv0(^bj=ZteB#A-27*l3P2%(*4tH~YUOi9+q&qliRF2;@?n8X z(@?Cy@ZofQyR)@QsGH=RKk@Pi#vPiuJ$8{Qm`7{B!yM18fMPn1J*UFxHELx@;*%g$ z8|&tmBxuYrB4Sz4;u3z#_eY$EBabIo0{LCY@Hzs7ZL_EVD=&BF*vr* z|7{=__Mfb{VS#0fgxc&=A%8hv!b@p(l8T@r9pKxeVBc{i-j^Qsufv+dsI?UJ3G8iG z_7jH0zwW9*_t~`75gsrV{p_C~SG=u791s^>RO)=|o-nU}V2oSHlC-Cm&uFE)m~FF{ z`Cz-oajaD*kzj7i!>A1at<*N)Rr>-sxud}YwRRM;xoibHy2a@W^9QF!Ui3tVj4r*G zHIfrSBrc1{+BigUM-xgQ6E2jkUCH;o9K+fX%H7{7gk1?)DUM+g&1UnQfR0g#dRfC6 zY)Tk;mVV9nmGg9H#jZu>7G(lXo>}>2Re(xw9K=sC_cb~#@?z0h#qM(ha{0a!-pPYD z*Ym4iC`X$jxQp3;@qakv<~k{|+58r_)WCn3xMOHZx8G)MkFa)4%+3Mi#7f>p_pZ)t!mRT@212gFMBwPzuCrJb0|3 zH*v)wB-bfJG}vFsh#X=e(t(uLEi*8XTAkdsC&}7^?}z>NQ#PLaDQ0)+J)S_l-n9l( zWQwzsfK8L&ALqNgvip)J>unUt^;v!&S4;xS zWT2je3CB;gZT22lr5@*zI;N^PAgyVzR-UX50OvyW$OsyABiqBOHpFO(< zMbo3=Q&`)`GjfBEe9O%2tPi01PbYUa%FB0v=-(cMHErBrtd16%RJuQ2!OFaSX-{BL}ufA9HAoM*w}89ftn zx7MxX+$qoph?{4!ptU7q^m0a)>xrnKa`w|@x((B?xg;ttC647=#5Xg4tq{)SghxtM z@1SlV=IIcNxEKm{Z5#zh=5tMbfEaN-QLskU$oIv>N_~Fg{BpFeu0~!Yq$vsIT=uIw zD|c~^)y8|z*5*-r)>_Ni^5UMmEyhYZO?4ag_DtJFM=db%hrc&I~yb4@)-`gFSE_?<@GFuk(j^;;XmclodXiU;k~ihe;NT}!&zidEJR3*e|X(?N`& zO3j&w$V%!ZrV#*CFY8r(4--RWqwq-yDJMN4!?}>D#67`GL2|hIzy+=J-;m{9esj&W zdRV7L8kbMa_q@4Z;2(<+wxU50RLDV*_->_|HYK~9qX%+(t>2eYZt#$K-V0>Bm!62T zWP1e%nrepC9g}Gx5!YPD6gK=Z%H0pycj5q#wS0aXIlu06kFQz`L&@n(FS{u+3f~Xt z*rK$b*T98-ov54j_kAVCyu754Cd!ul6GCgiQlIRzEHAfKsAoIsrL&wK$CgwGPWcQy zq%6CiOJOBQV)F6bD^>2b$3rXWz;_``?vskMAz$1t%?pT~{^8M$>M*aJqVT7Ps%6!Z zvz(ZRBLMtB8!a1k3sZfG;+U`gKG3N3SN|#rq?q`A^pgUHY)ue3rc_4eRmAwOYGa4- zZItY$jvTyBR+B@PW0djS8@$Ve(S8K;8USdPG!2$yTU;%|JRa9~8O-(HZZ`EzxJ4Sq z_?4|-a>s@zMQ^ju2tx&pWiBhYz8Bq@M4dDqALU5zXXEs1aQ~UfA$B7mdQ=l;+46pE zxHFnL+Xar`5Bg+uHk#Y;Ia|$Xk!-2NV#tH`2b2v>Wz~==CpG5e4Stoy(DjQ9zzPWy41I(;K`vyvy4nBi&DF=7-|FMr*}TEzexnrz<#2G6O?| zv)5HC`@{c>6f@W5!r5L|aO~uth%^;tvmJWH`!I@|r^@i6>Syo?!sRa(5l_aoI%1on zSG?{RI4CP+^+9GdfdzZMfH2VMVAireHO%8A;x9vr2??=GCjqXTxcM|NLu4^RV8MiM z_9m!3WwkZPvtrDA8zhql6R*nQz%~JW1ZWa0SFtV1pcH`U~{{I49B#_(7I#Ydx)kn%b-mA|IQfdm2&OGc88!C0mHzO) zfu)&f7gN^PN;_4S>~>jG$<=ClE!7hFiKRUse7iMj8$v26{lKrJg`5?uXl+GCv+^$* z`qi}Vf5qTAr?62OS@PZI#GCq)-J>T(CcHAt5LVheOhFQOM-=fm&2nw;%y8%P>KGR} zJ;RO>#T)TRPI>E`@0JB@#u>kDYbfW7wsbCrEc%BffX~-q)SIijlmR!9^y1cn;DHE%xHs5|#K)4eR%ZGykajWCo}I3D9Q9J9%X z>GZV`+OSv5B4NdvMR#o66Q&t`_IGTC;R$NYv(>f+>ksP_qtEz0k@BIfCFD1WQsJ$LK!5s0@zZOPcs8B1tVu6Rn`t*s*g6|Y zi+QHqruU8S{2omd%lmQtxe;&gxqhEZ{lV~@C;*kTwG~t?5C)%6ou;m85#7n^-HFn2 zPlQ-z>~m_=MBux$S3Gzi<-%Vr#WqIUGnQKWF~TXU<%zv?0cu!305QbtxXm=!GLL9DWn Date: Mon, 18 Nov 2024 21:08:10 +0400 Subject: [PATCH 5/9] Bump amazonlinux from 2023.6.20241031.0 to 2023.6.20241111.0 in /catalog (#4220) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- catalog/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalog/Dockerfile b/catalog/Dockerfile index a58b9f30e70..ad288b8fe8a 100644 --- a/catalog/Dockerfile +++ b/catalog/Dockerfile @@ -1,4 +1,4 @@ -FROM amazonlinux:2023.6.20241031.0 +FROM amazonlinux:2023.6.20241111.0 MAINTAINER Quilt Data, Inc. contact@quiltdata.io ENV LC_ALL=C.UTF-8 From 40db3b43099a2b87aa1a6b18f0b370f8bdb590c0 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 19 Nov 2024 12:11:02 +0100 Subject: [PATCH 6/9] Stop using S3 Select in indexer (#4212) Co-authored-by: Alexei Mochalov --- lambdas/indexer/CHANGELOG.md | 21 ++++++ lambdas/indexer/index.py | 97 ++++++++++++++++----------- lambdas/indexer/pytest.ini | 4 +- lambdas/indexer/test-requirements.txt | 1 + lambdas/indexer/test/test_index.py | 50 +------------- 5 files changed, 85 insertions(+), 88 deletions(-) create mode 100644 lambdas/indexer/CHANGELOG.md diff --git a/lambdas/indexer/CHANGELOG.md b/lambdas/indexer/CHANGELOG.md new file mode 100644 index 00000000000..c7ea99597d5 --- /dev/null +++ b/lambdas/indexer/CHANGELOG.md @@ -0,0 +1,21 @@ + +# Changelog + +Changes are listed in reverse chronological order (newer entries at the top). +The entry format is + +```markdown +- [Verb] Change description ([#](https://github.com/quiltdata/quilt/pull/)) +``` + +where verb is one of + +- Removed +- Added +- Fixed +- Changed + +## Changes + +- [Changed] Stop using S3 select ([#4212](https://github.com/quiltdata/quilt/pull/4212)) +- [Added] Bootstrap the change log ([#4212](https://github.com/quiltdata/quilt/pull/4212)) diff --git a/lambdas/indexer/index.py b/lambdas/indexer/index.py index 80b6861a11f..bb6a9422229 100644 --- a/lambdas/indexer/index.py +++ b/lambdas/indexer/index.py @@ -47,6 +47,7 @@ import datetime +import functools import json import os import pathlib @@ -92,7 +93,6 @@ POINTER_PREFIX_V1, get_available_memory, get_quilt_logger, - query_manifest_content, separated_env_to_iter, ) @@ -168,12 +168,7 @@ # currently only affects .parquet, TODO: extend to other extensions assert 'SKIP_ROWS_EXTS' in os.environ SKIP_ROWS_EXTS = separated_env_to_iter('SKIP_ROWS_EXTS') -SELECT_PACKAGE_META = "SELECT * from S3Object o WHERE o.version IS NOT MISSING LIMIT 1" -# No WHERE clause needed for aggregations since S3 Select skips missing fields for aggs -SELECT_PACKAGE_STATS = ( - "SELECT COALESCE(SUM(obj['size']), 0) as total_bytes," - " COUNT(obj['size']) as total_files from S3Object obj" -) +DUCKDB_SELECT_LAMBDA_ARN = os.environ["DUCKDB_SELECT_LAMBDA_ARN"] TEST_EVENT = "s3:TestEvent" # we need to filter out GetObject and HeadObject calls generated by the present # lambda in order to display accurate analytics in the Quilt catalog @@ -182,6 +177,7 @@ logger = get_quilt_logger() +s3_client = boto3.client("s3", config=botocore.config.Config(user_agent_extra=USER_AGENT_EXTRA)) def now_like_boto3(): @@ -247,13 +243,10 @@ def select_manifest_meta(s3_client, bucket: str, key: str): wrapper for retry and returning a string """ try: - raw = query_manifest_content( - s3_client, - bucket=bucket, - key=key, - sql_stmt=SELECT_PACKAGE_META - ) - return json.load(raw) + body = s3_client.get_object(Bucket=bucket, Key=key)["Body"] + with body: # this *might* be needed to close the stream ASAP + for line in body.iter_lines(): + return json.loads(line) except (botocore.exceptions.ClientError, json.JSONDecodeError) as cle: print(f"Unable to S3 select manifest: {cle}") @@ -439,7 +432,7 @@ def get_pkg_data(): first = select_manifest_meta(s3_client, bucket, manifest_key) if not first: return - stats = select_package_stats(s3_client, bucket, manifest_key) + stats = select_package_stats(bucket, manifest_key) if not stats: return @@ -472,33 +465,54 @@ def get_pkg_data(): return True -def select_package_stats(s3_client, bucket, manifest_key) -> str: +@functools.lru_cache(maxsize=None) +def get_bucket_region(bucket: str) -> str: + resp = s3_client.head_bucket(Bucket=bucket) + return resp["ResponseMetadata"]["HTTPHeaders"]["x-amz-bucket-region"] + + +@functools.lru_cache(maxsize=None) +def get_presigner_client(bucket: str): + return boto3.client( + "s3", + region_name=get_bucket_region(bucket), + config=botocore.config.Config(signature_version="s3v4"), + ) + + +def select_package_stats(bucket, manifest_key) -> Optional[dict]: """use s3 select to generate file stats for package""" logger_ = get_quilt_logger() - try: - raw_stats = query_manifest_content( - s3_client, - bucket=bucket, - key=manifest_key, - sql_stmt=SELECT_PACKAGE_STATS - ).read() - - if raw_stats: - stats = json.loads(raw_stats) - assert isinstance(stats['total_bytes'], int) - assert isinstance(stats['total_files'], int) - - return stats - - except ( - AssertionError, - botocore.exceptions.ClientError, - json.JSONDecodeError, - KeyError, - ) as err: - logger_.exception("Unable to compute package stats via S3 select") + presigner_client = get_presigner_client(bucket) + url = presigner_client.generate_presigned_url( + ClientMethod="get_object", + Params={ + "Bucket": bucket, + "Key": manifest_key, + }, + ) + lambda_ = make_lambda_client() + q = f""" + SELECT + COALESCE(SUM(size), 0) AS total_bytes, + COUNT(size) AS total_files FROM read_ndjson('{url}', columns={{size: 'UBIGINT'}}) obj + """ + resp = lambda_.invoke( + FunctionName=DUCKDB_SELECT_LAMBDA_ARN, + Payload=json.dumps({"query": q, "user_agent": f"DuckDB Select {USER_AGENT_EXTRA}"}), + ) - return None + payload = resp["Payload"].read() + if "FunctionError" in resp: + logger_.error("DuckDB select unhandled error: %s", payload) + return None + parsed = json.loads(payload) + if "error" in parsed: + logger_.error("DuckDB select error: %s", parsed["error"]) + return None + + rows = parsed["rows"] + return rows[0] if rows else None def extract_pptx(fileobj, max_size: int) -> str: @@ -732,6 +746,11 @@ def make_s3_client(): return boto3.client("s3", config=configuration) +@functools.lru_cache(maxsize=None) +def make_lambda_client(): + return boto3.client("lambda") + + def map_event_name(event: dict): """transform eventbridge names into S3-like ones""" input_ = event["eventName"] diff --git a/lambdas/indexer/pytest.ini b/lambdas/indexer/pytest.ini index dd07825516f..f9355a4fbaf 100644 --- a/lambdas/indexer/pytest.ini +++ b/lambdas/indexer/pytest.ini @@ -1,4 +1,6 @@ [pytest] +env = + DUCKDB_SELECT_LAMBDA_ARN = "arn:aws:lambda:us-west-2:123456789012:function:select-lambda" log_cli = True # This is set above critical to prevent logger events from confusing output in CI -log_level = 51 +log_level = 51 diff --git a/lambdas/indexer/test-requirements.txt b/lambdas/indexer/test-requirements.txt index e75e43e319b..b8fc13134ea 100644 --- a/lambdas/indexer/test-requirements.txt +++ b/lambdas/indexer/test-requirements.txt @@ -5,4 +5,5 @@ pluggy==0.9 py==1.10.0 pytest==4.4.0 pytest-cov==2.6.1 +pytest-env==0.6.2 responses==0.10.14 diff --git a/lambdas/indexer/test/test_index.py b/lambdas/indexer/test/test_index.py index c53e3bfa8de..05cc0c85a1f 100644 --- a/lambdas/indexer/test/test_index.py +++ b/lambdas/indexer/test/test_index.py @@ -23,7 +23,6 @@ import responses from botocore import UNSIGNED from botocore.client import Config -from botocore.exceptions import ParamValidationError from botocore.stub import Stubber from dateutil.tz import tzutc from document_queue import EVENT_PREFIX, RetryError @@ -979,7 +978,7 @@ def test_index_if_package_select_stats_fail(self, append_mock, select_meta_mock, ) select_meta_mock.assert_called_once_with(self.s3_client, bucket, manifest_key) - select_stats_mock.assert_called_once_with(self.s3_client, bucket, manifest_key) + select_stats_mock.assert_called_once_with(bucket, manifest_key) append_mock.assert_called_once_with({ "_index": bucket + PACKAGE_INDEX_SUFFIX, "_id": key, @@ -1023,7 +1022,7 @@ def test_index_if_package(self, append_mock, select_meta_mock, select_stats_mock ) select_meta_mock.assert_called_once_with(self.s3_client, bucket, manifest_key) - select_stats_mock.assert_called_once_with(self.s3_client, bucket, manifest_key) + select_stats_mock.assert_called_once_with(bucket, manifest_key) append_mock.assert_called_once_with({ "_index": bucket + PACKAGE_INDEX_SUFFIX, "_id": key, @@ -1182,51 +1181,6 @@ def test_extension_overrides(self): assert self._get_contents('foo.txt', '.txt') == "" assert self._get_contents('foo.ipynb', '.ipynb') == "" - @pytest.mark.xfail( - raises=ParamValidationError, - reason="boto bug https://github.com/boto/botocore/issues/1621", - strict=True, - ) - def test_stub_select_object_content(self): - """Demonstrate that mocking S3 select with boto3 is broken""" - sha_hash = "50f4d0fc2c22a70893a7f356a4929046ce529b53c1ef87e28378d92b884691a5" - manifest_key = f"{MANIFEST_PREFIX_V1}{sha_hash}" - # this SHOULD work, but due to botocore bugs it does not - self.s3_stubber.add_response( - method="select_object_content", - service_response={ - "ResponseMetadata": ANY, - # it is sadly not possible to mock S3 select responses because - # boto incorrectly believes "Payload"'s value should be a dict - # but it's really an iterable in realworld code - # see https://github.com/boto/botocore/issues/1621 - "Payload": [ - { - "Stats": {} - }, - { - "Records": { - "Payload": json.dumps(MANIFEST_DATA).encode(), - }, - }, - { - "End": {} - }, - ] - }, - expected_params={ - "Bucket": "test-bucket", - "Key": manifest_key, - "Expression": index.SELECT_PACKAGE_META, - "ExpressionType": "SQL", - "InputSerialization": { - 'JSON': {'Type': 'LINES'}, - 'CompressionType': 'NONE' - }, - "OutputSerialization": {'JSON': {'RecordDelimiter': '\n'}} - } - ) - def test_synthetic_copy_event(self): """check synthetic ObjectCreated:Copy event vs organic obtained on 26-May-2020 (bucket versioning on) From c65fc4cbdf0a6be58238037c72b43dc3b83d7f21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:11:55 +0000 Subject: [PATCH 7/9] Bump aiohttp from 3.10.2 to 3.10.11 in /lambdas/tabular_preview (#4227) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lambdas/tabular_preview/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/tabular_preview/requirements.txt b/lambdas/tabular_preview/requirements.txt index c787be65d4d..b10ea779083 100644 --- a/lambdas/tabular_preview/requirements.txt +++ b/lambdas/tabular_preview/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements.txt ../shared/setup.py setup.py # -aiohttp==3.10.2 +aiohttp==3.10.11 # via fsspec aiosignal==1.2.0 # via aiohttp From 8dd6815e8d9e0757e38993dae5809fb97319a13d Mon Sep 17 00:00:00 2001 From: Alexei Mochalov Date: Tue, 19 Nov 2024 17:28:43 +0500 Subject: [PATCH 8/9] Catalog: Replace S3 Select with GQL (#4218) --- catalog/CHANGELOG.md | 1 + .../app/containers/Bucket/File/Analytics.tsx | 92 ++ .../AssistantContext.ts} | 2 +- .../app/containers/Bucket/{ => File}/File.js | 90 +- .../File/gql/ObjectAccessCounts.generated.ts | 100 ++ .../File/gql/ObjectAccessCounts.graphql | 9 + catalog/app/containers/Bucket/File/index.ts | 1 + catalog/app/containers/Bucket/Overview.js | 963 ------------------ .../containers/Bucket/Overview/ColorPool.ts | 16 + .../Bucket/Overview/Downloads.spec.ts | 194 ++++ .../containers/Bucket/Overview/Downloads.tsx | 593 +++++++++++ .../app/containers/Bucket/Overview/Header.tsx | 431 ++++++++ .../Bucket/{ => Overview}/Overview-bg.jpg | Bin .../containers/Bucket/Overview/Overview.tsx | 163 +++ .../gql/BucketAccessCounts.generated.ts | 207 ++++ .../Overview/gql/BucketAccessCounts.graphql | 27 + .../gql/BucketConfig.generated.ts} | 16 +- .../gql/BucketConfig.graphql} | 0 .../app/containers/Bucket/Overview/index.tsx | 1 + catalog/app/containers/Bucket/Summarize.tsx | 6 +- .../Bucket/requests/requestsUntyped.js | 256 +---- catalog/app/embed/File.js | 75 +- catalog/app/model/graphql/schema.generated.ts | 160 ++- catalog/app/model/graphql/types.generated.ts | 29 + catalog/app/utils/AWS/S3.js | 50 +- catalog/app/utils/AWS/Signer.js | 2 +- catalog/app/utils/GraphQL/Provider.tsx | 2 + shared/graphql/schema.graphql | 13 + 28 files changed, 2114 insertions(+), 1385 deletions(-) create mode 100644 catalog/app/containers/Bucket/File/Analytics.tsx rename catalog/app/containers/Bucket/{FileAssistantContext.ts => File/AssistantContext.ts} (98%) rename catalog/app/containers/Bucket/{ => File}/File.js (85%) create mode 100644 catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.generated.ts create mode 100644 catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.graphql create mode 100644 catalog/app/containers/Bucket/File/index.ts delete mode 100644 catalog/app/containers/Bucket/Overview.js create mode 100644 catalog/app/containers/Bucket/Overview/ColorPool.ts create mode 100644 catalog/app/containers/Bucket/Overview/Downloads.spec.ts create mode 100644 catalog/app/containers/Bucket/Overview/Downloads.tsx create mode 100644 catalog/app/containers/Bucket/Overview/Header.tsx rename catalog/app/containers/Bucket/{ => Overview}/Overview-bg.jpg (100%) create mode 100644 catalog/app/containers/Bucket/Overview/Overview.tsx create mode 100644 catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts create mode 100644 catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.graphql rename catalog/app/containers/Bucket/{OverviewBucketConfig.generated.ts => Overview/gql/BucketConfig.generated.ts} (75%) rename catalog/app/containers/Bucket/{OverviewBucketConfig.graphql => Overview/gql/BucketConfig.graphql} (100%) create mode 100644 catalog/app/containers/Bucket/Overview/index.tsx diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index 8758f53dd0a..93888fa202c 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [Changed] S3 Select -> GQL API calls for getting access counts ([#4218](https://github.com/quiltdata/quilt/pull/4218)) - [Changed] Athena: improve loading state and errors visuals; fix minor bugs; alphabetize and persist selection in workgroups, catalog names and databases ([#4208](https://github.com/quiltdata/quilt/pull/4208)) - [Changed] Show stack release version in footer ([#4200](https://github.com/quiltdata/quilt/pull/4200)) - [Added] Selective package downloading ([#4173](https://github.com/quiltdata/quilt/pull/4173)) diff --git a/catalog/app/containers/Bucket/File/Analytics.tsx b/catalog/app/containers/Bucket/File/Analytics.tsx new file mode 100644 index 00000000000..4152b083953 --- /dev/null +++ b/catalog/app/containers/Bucket/File/Analytics.tsx @@ -0,0 +1,92 @@ +import * as dateFns from 'date-fns' +import * as Eff from 'effect' +import * as React from 'react' +import * as M from '@material-ui/core' + +import Sparkline from 'components/Sparkline' +import * as GQL from 'utils/GraphQL' +import log from 'utils/Logging' +import * as SVG from 'utils/SVG' +import { readableQuantity } from 'utils/string' + +import Section from '../Section' + +import ACCESS_COUNTS_QUERY from './gql/ObjectAccessCounts.generated' + +const currentYear = new Date().getFullYear() + +const formatDate = (date: Date) => + dateFns.format(date, currentYear === date.getFullYear() ? 'd MMM' : 'd MMM yyyy') + +interface AnalyticsProps { + bucket: string + path: string +} + +export default function Analytics({ bucket, path }: AnalyticsProps) { + const [cursor, setCursor] = React.useState(null) + + const result = GQL.useQuery(ACCESS_COUNTS_QUERY, { bucket, key: path }) + + const data = React.useMemo(() => { + if (result.fetching) return Eff.Option.none() + if (result.error) log.error('Error fetching object access counts:', result.error) + return Eff.Option.some(Eff.Option.fromNullable(result.data?.objectAccessCounts)) + }, [result.fetching, result.error, result.data]) + + const defaultExpanded = Eff.Option.match(data, { + onNone: () => false, + onSome: Eff.Option.match({ + onNone: () => false, + onSome: ({ total }) => !!total, + }), + }) + + return ( +
+ {Eff.Option.match(data, { + onNone: () => , + onSome: Eff.Option.match({ + onNone: () => No analytics available, + onSome: ({ counts, total }) => + total ? ( + + + Downloads + + {readableQuantity(cursor === null ? total : counts[cursor].value)} + + + {cursor === null + ? `${counts.length} days` + : formatDate(counts[cursor].date)} + + + + c.value)} + onCursor={setCursor} + width={1000} + height={60} + stroke={SVG.Paint.Server( + + + + , + )} + /> + + + ) : ( + No analytics available + ), + }), + })} +
+ ) +} diff --git a/catalog/app/containers/Bucket/FileAssistantContext.ts b/catalog/app/containers/Bucket/File/AssistantContext.ts similarity index 98% rename from catalog/app/containers/Bucket/FileAssistantContext.ts rename to catalog/app/containers/Bucket/File/AssistantContext.ts index 6f5876945f8..46f06c8ccf3 100644 --- a/catalog/app/containers/Bucket/FileAssistantContext.ts +++ b/catalog/app/containers/Bucket/File/AssistantContext.ts @@ -4,7 +4,7 @@ import * as React from 'react' import * as Assistant from 'components/Assistant' import * as XML from 'utils/XML' -import { ObjectExistence } from './requests' +import { ObjectExistence } from '../requests' interface VersionsContextProps { data: $TSFixMe diff --git a/catalog/app/containers/Bucket/File.js b/catalog/app/containers/Bucket/File/File.js similarity index 85% rename from catalog/app/containers/Bucket/File.js rename to catalog/app/containers/Bucket/File/File.js index e91b4ed9fd1..fda4ceb1d40 100644 --- a/catalog/app/containers/Bucket/File.js +++ b/catalog/app/containers/Bucket/File/File.js @@ -1,6 +1,5 @@ import { basename } from 'path' -import * as dateFns from 'date-fns' import * as R from 'ramda' import * as React from 'react' import { Link, useHistory, useLocation, useParams } from 'react-router-dom' @@ -11,7 +10,6 @@ import * as Buttons from 'components/Buttons' import * as FileEditor from 'components/FileEditor' import Message from 'components/Message' import * as Preview from 'components/Preview' -import Sparkline from 'components/Sparkline' import cfg from 'constants/config' import * as Bookmarks from 'containers/Bookmarks' import * as Notifications from 'containers/Notifications' @@ -21,23 +19,24 @@ import * as BucketPreferences from 'utils/BucketPreferences' import { useData } from 'utils/Data' import MetaTitle from 'utils/MetaTitle' import * as NamedRoutes from 'utils/NamedRoutes' -import * as SVG from 'utils/SVG' import { linkStyle } from 'utils/StyledLink' import copyToClipboard from 'utils/clipboard' import * as Format from 'utils/format' import parseSearch from 'utils/parseSearch' import { up, decode, handleToHttpsUri } from 'utils/s3paths' -import { readableBytes, readableQuantity } from 'utils/string' - -import AssistButton from './AssistButton' -import FileCodeSamples from './CodeSamples/File' -import * as AssistantContext from './FileAssistantContext' -import FileProperties from './FileProperties' -import * as FileView from './FileView' -import Section from './Section' -import renderPreview from './renderPreview' -import * as requests from './requests' -import { useViewModes, viewModeToSelectOption } from './viewModes' +import { readableBytes } from 'utils/string' + +import AssistButton from '../AssistButton' +import FileCodeSamples from '../CodeSamples/File' +import FileProperties from '../FileProperties' +import * as FileView from '../FileView' +import Section from '../Section' +import renderPreview from '../renderPreview' +import * as requests from '../requests' +import { useViewModes, viewModeToSelectOption } from '../viewModes' + +import Analytics from './Analytics' +import * as AssistantContext from './AssistantContext' const useVersionInfoStyles = M.makeStyles(({ typography }) => ({ version: { @@ -203,69 +202,6 @@ function VersionInfo({ bucket, path, version }) { ) } -function Analytics({ bucket, path }) { - const [cursor, setCursor] = React.useState(null) - const s3 = AWS.S3.use() - const today = React.useMemo(() => new Date(), []) - const formatDate = (date) => - dateFns.format( - date, - today.getFullYear() === date.getFullYear() ? 'd MMM' : 'd MMM yyyy', - ) - const data = useData(requests.objectAccessCounts, { s3, bucket, path, today }) - - const defaultExpanded = data.case({ - Ok: ({ total }) => !!total, - _: () => false, - }) - - return ( -
- {data.case({ - Ok: ({ counts, total }) => - total ? ( - - - Downloads - - {readableQuantity(cursor === null ? total : counts[cursor].value)} - - - {cursor === null - ? `${counts.length} days` - : formatDate(counts[cursor].date)} - - - - - - - , - )} - /> - - - ) : ( - No analytics available - ), - Err: () => No analytics available, - _: () => , - })} -
- ) -} - function CenteredProgress() { return ( diff --git a/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.generated.ts b/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.generated.ts new file mode 100644 index 00000000000..94875020cfe --- /dev/null +++ b/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.generated.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type containers_Bucket_File_gql_ObjectAccessCountsQueryVariables = Types.Exact<{ + bucket: Types.Scalars['String'] + key: Types.Scalars['String'] +}> + +export type containers_Bucket_File_gql_ObjectAccessCountsQuery = { + readonly __typename: 'Query' +} & { + readonly objectAccessCounts: Types.Maybe< + { readonly __typename: 'AccessCounts' } & Pick & { + readonly counts: ReadonlyArray< + { readonly __typename: 'AccessCountForDate' } & Pick< + Types.AccessCountForDate, + 'date' | 'value' + > + > + } + > +} + +export const containers_Bucket_File_gql_ObjectAccessCountsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'containers_Bucket_File_gql_ObjectAccessCounts' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'key' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'objectAccessCounts' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'bucket' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'key' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'key' } }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'window' }, + value: { kind: 'IntValue', value: '365' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'date' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + containers_Bucket_File_gql_ObjectAccessCountsQuery, + containers_Bucket_File_gql_ObjectAccessCountsQueryVariables +> + +export { containers_Bucket_File_gql_ObjectAccessCountsDocument as default } diff --git a/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.graphql b/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.graphql new file mode 100644 index 00000000000..431f1cb2ee2 --- /dev/null +++ b/catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.graphql @@ -0,0 +1,9 @@ +query ($bucket: String!, $key: String!) { + objectAccessCounts(bucket: $bucket, key: $key, window: 365) { + total + counts { + date + value + } + } +} diff --git a/catalog/app/containers/Bucket/File/index.ts b/catalog/app/containers/Bucket/File/index.ts new file mode 100644 index 00000000000..d1590f6b882 --- /dev/null +++ b/catalog/app/containers/Bucket/File/index.ts @@ -0,0 +1 @@ +export { default } from './File' diff --git a/catalog/app/containers/Bucket/Overview.js b/catalog/app/containers/Bucket/Overview.js deleted file mode 100644 index 3acef4e2ccb..00000000000 --- a/catalog/app/containers/Bucket/Overview.js +++ /dev/null @@ -1,963 +0,0 @@ -import cx from 'classnames' -import * as dateFns from 'date-fns' -import * as R from 'ramda' -import * as React from 'react' -import { Link as RRLink, useParams } from 'react-router-dom' -import * as redux from 'react-redux' -import * as M from '@material-ui/core' -import { fade } from '@material-ui/core/styles' -import useComponentSize from '@rehooks/component-size' - -import Skeleton from 'components/Skeleton' -import StackedAreaChart from 'components/StackedAreaChart' -import cfg from 'constants/config' -import * as authSelectors from 'containers/Auth/selectors' -import * as APIConnector from 'utils/APIConnector' -import * as AWS from 'utils/AWS' -import AsyncResult from 'utils/AsyncResult' -import * as BucketPreferences from 'utils/BucketPreferences' -import Data, { useData } from 'utils/Data' -import { useQueryS } from 'utils/GraphQL' -import * as LinkedData from 'utils/LinkedData' -import * as NamedRoutes from 'utils/NamedRoutes' -import * as SVG from 'utils/SVG' -import { readableBytes, readableQuantity, formatQuantity } from 'utils/string' - -import * as Gallery from './Gallery' -import * as Summarize from './Summarize' -import * as requests from './requests' -import BUCKET_CONFIG_QUERY from './OverviewBucketConfig.generated' - -import bg from './Overview-bg.jpg' - -const RODA_LINK = 'https://registry.opendata.aws' -const RODA_BUCKET = 'quilt-open-data-bucket' -const MAX_EXTS = 7 -// must have length >= MAX_EXTS -const COLOR_MAP = [ - '#8ad3cb', - '#d7ce69', - '#bfbadb', - '#f4806c', - '#83b0d1', - '#b2de67', - '#bc81be', - '#f0b5d3', - '#7ba39f', - '#9894ad', - '#be7265', - '#94ad6b', -] - -function mkKeyedPool(pool) { - const map = {} - let poolIdx = 0 - const get = (key) => { - if (!(key in map)) { - // eslint-disable-next-line no-plusplus - map[key] = pool[poolIdx++ % pool.length] - } - return map[key] - } - return { get } -} - -function useConst(cons) { - const ref = React.useRef(null) - if (!ref.current) ref.current = { value: cons() } - return ref.current.value -} - -const useObjectsByExtStyles = M.makeStyles((t) => ({ - root: { - display: 'grid', - gridAutoRows: 20, - gridColumnGap: t.spacing(1), - gridRowGap: t.spacing(0.25), - gridTemplateAreas: ` - ". heading heading" - `, - gridTemplateColumns: 'minmax(30px, max-content) 1fr minmax(30px, max-content)', - gridTemplateRows: 'auto', - [t.breakpoints.down('sm')]: { - gridTemplateAreas: ` - "heading heading heading" - `, - }, - }, - heading: { - ...t.typography.h6, - gridArea: 'heading', - marginBottom: t.spacing(1), - [t.breakpoints.down('sm')]: { - textAlign: 'center', - }, - }, - ext: { - color: t.palette.text.secondary, - gridColumn: 1, - fontSize: t.typography.overline.fontSize, - fontWeight: t.typography.fontWeightMedium, - letterSpacing: t.typography.subtitle2.letterSpacing, - lineHeight: t.typography.pxToRem(20), - textAlign: 'right', - }, - count: { - color: t.palette.text.secondary, - gridColumn: 3, - fontSize: t.typography.overline.fontSize, - fontWeight: t.typography.fontWeightMedium, - letterSpacing: t.typography.subtitle2.letterSpacing, - lineHeight: t.typography.pxToRem(20), - }, - bar: { - background: t.palette.action.hover, - gridColumn: 2, - }, - gauge: { - height: '100%', - position: 'relative', - }, - flip: {}, - size: { - color: t.palette.common.white, - fontSize: t.typography.overline.fontSize, - fontWeight: t.typography.fontWeightMedium, - letterSpacing: t.typography.subtitle2.letterSpacing, - lineHeight: t.typography.pxToRem(20), - position: 'absolute', - right: t.spacing(1), - '&$flip': { - color: t.palette.text.hint, - left: `calc(100% + ${t.spacing(1)}px)`, - right: 'auto', - }, - }, - skeleton: { - gridColumn: '1 / span 3', - }, - unavail: { - ...t.typography.body2, - alignItems: 'center', - display: 'flex', - gridColumn: '1 / span 3', - gridRow: `2 / span ${MAX_EXTS}`, - justifyContent: 'center', - }, -})) - -function ObjectsByExt({ data, colorPool, ...props }) { - const classes = useObjectsByExtStyles() - return ( - -
Objects by File Extension
- {AsyncResult.case( - { - Ok: (exts) => { - const capped = exts.slice(0, MAX_EXTS) - const maxBytes = capped.reduce((max, e) => Math.max(max, e.bytes), 0) - const max = Math.log(maxBytes + 1) - const scale = (x) => Math.log(x + 1) / max - return capped.map(({ ext, bytes, objects }, i) => { - const color = colorPool.get(ext) - return ( - -
- {ext || 'other'} -
-
-
-
- {readableBytes(bytes)} -
-
-
-
- {readableQuantity(objects)} -
-
- ) - }) - }, - _: (r) => ( - <> - {R.times( - (i) => ( - - ), - MAX_EXTS, - )} - {AsyncResult.Err.is(r) && ( -
Data unavailable
- )} - - ), - }, - data, - )} -
- ) -} - -const skelData = R.times( - R.pipe( - () => R.times(Math.random, 30), - R.scan(R.add, 0), - R.drop(1), - R.map((v) => Math.log(100 * v + 1)), - ), - 8, -) - -const skelColors = [ - [M.colors.grey[300], M.colors.grey[100]], - [M.colors.grey[400], M.colors.grey[200]], -] - -const mkPulsingGradient = ({ colors: [c1, c2], animate = false }) => - SVG.Paint.Server( - - - {animate && ( - - )} - - , - ) - -function ChartSkel({ - height, - width, - lines = skelData.length, - animate = false, - children, -}) { - const data = React.useMemo( - () => R.times((i) => skelData[i % skelData.length], lines), - [lines], - ) - const fills = React.useMemo( - () => - R.times( - (i) => mkPulsingGradient({ colors: skelColors[i % skelColors.length], animate }), - lines, - ), - [lines, animate], - ) - return ( - - - {children} - - ) -} - -const ANALYTICS_WINDOW_OPTIONS = [ - { value: 31, label: 'Last 1 month' }, - { value: 91, label: 'Last 3 months' }, - { value: 182, label: 'Last 6 months' }, - { value: 365, label: 'Last 12 months' }, -] - -function DownloadsRange({ value, onChange, bucket, rawData }) { - const [anchor, setAnchor] = React.useState(null) - - const open = React.useCallback( - (e) => { - setAnchor(e.target) - }, - [setAnchor], - ) - - const close = React.useCallback(() => { - setAnchor(null) - }, [setAnchor]) - - const choose = React.useCallback( - (e) => { - onChange(e.target.value) - close() - }, - [onChange, close], - ) - - const { label } = ANALYTICS_WINDOW_OPTIONS.find((o) => o.value === value) || {} - - return ( - <> - - - {label} expand_more - - - {ANALYTICS_WINDOW_OPTIONS.map((o) => ( - - {o.label} - - ))} - - - Download to file - - - - ) -} - -const useStatsTipStyles = M.makeStyles((t) => ({ - root: { - background: fade(t.palette.grey[700], 0.9), - color: t.palette.common.white, - padding: [[6, 8]], - }, - head: { - display: 'flex', - justifyContent: 'space-between', - marginBottom: 4, - }, - date: {}, - total: {}, - extsContainer: { - alignItems: 'center', - display: 'grid', - gridAutoRows: 'auto', - gridColumnGap: 4, - gridTemplateColumns: 'max-content max-content 1fr', - }, - ext: { - fontSize: 12, - lineHeight: '16px', - maxWidth: 70, - opacity: 0.6, - overflow: 'hidden', - textAlign: 'right', - textOverflow: 'ellipsis', - }, - color: { - borderRadius: '50%', - height: 8, - opacity: 0.6, - width: 8, - }, - number: { - fontSize: 12, - lineHeight: '16px', - opacity: 0.6, - }, - hl: { - opacity: 1, - }, -})) - -function StatsTip({ stats, colorPool, className, ...props }) { - const classes = useStatsTipStyles() - return ( - -
-
{dateFns.format(stats.date, 'd MMM')}
-
- {readableQuantity(stats.combined.sum)} (+ - {readableQuantity(stats.combined.value)}) -
-
-
- {stats.byExt.map((s) => { - const hl = stats.highlighted ? stats.highlighted.ext === s.ext : true - return ( - -
{s.ext || 'other'}
-
-
- {readableQuantity(s.sum)} (+ - {readableQuantity(s.value)}) -
- - ) - })} -
- - ) -} - -const Transition = ({ TransitionComponent = M.Grow, children, ...props }) => { - const contentsRef = React.useRef(null) - if (props.in) contentsRef.current = children() - return ( - contentsRef.current && ( - {contentsRef.current} - ) - ) -} - -// use the same height as the bar chart: 20px per bar with 2px margin -const CHART_H = 22 * MAX_EXTS - 2 - -const useDownloadsStyles = M.makeStyles((t) => ({ - root: { - display: 'grid', - gridRowGap: t.spacing(0.25), - gridTemplateAreas: ` - "heading period" - "chart chart" - `, - gridTemplateColumns: 'min-content 1fr', - gridTemplateRows: 'auto auto', - [t.breakpoints.down('sm')]: { - gridTemplateAreas: ` - "heading" - "chart" - "period" - `, - gridTemplateColumns: '1fr', - gridTemplateRows: 'auto auto auto', - }, - }, - heading: { - ...t.typography.h6, - gridArea: 'heading', - marginBottom: t.spacing(1), - whiteSpace: 'nowrap', - [t.breakpoints.down('sm')]: { - marginBottom: 0, - textAlign: 'center', - }, - }, - ext: { - display: 'inline-block', - maxWidth: 100, - overflow: 'hidden', - textOverflow: 'ellipsis', - verticalAlign: 'bottom', - }, - period: { - display: 'flex', - gridArea: 'period', - justifyContent: 'center', - alignItems: 'center', - [t.breakpoints.down('sm')]: { - paddingBottom: t.spacing(1), - paddingTop: t.spacing(2), - }, - [t.breakpoints.up('md')]: { - height: 37, - justifyContent: 'flex-end', - }, - }, - chart: { - gridArea: 'chart', - position: 'relative', - }, - left: {}, - right: {}, - dateStats: { - maxWidth: 180, - position: 'absolute', - top: 0, - width: 'calc(50% - 8px)', - zIndex: 1, - '&$left': { - left: 0, - }, - '&$right': { - right: 0, - }, - }, - unavail: { - ...t.typography.body2, - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - top: 0, - width: '100%', - }, -})) - -function Downloads({ bucket, colorPool, ...props }) { - const s3 = AWS.S3.use() - const today = React.useMemo(() => new Date(), []) - const classes = useDownloadsStyles() - const ref = React.useRef(null) - const { width } = useComponentSize(ref) - const [window, setWindow] = React.useState(ANALYTICS_WINDOW_OPTIONS[0].value) - const [cursor, setCursor] = React.useState(null) - const cursorStats = (counts) => { - if (!cursor) return null - const { date, ...combined } = counts.combined.counts[cursor.j] - const byExt = counts.byExtCollapsed.map((e) => ({ - ext: e.ext, - ...e.counts[cursor.j], - })) - const highlighted = cursor.i == null ? null : counts.byExtCollapsed[cursor.i] - const firstHalf = cursor.j < counts.combined.counts.length / 2 - return { date, combined, byExt, highlighted, firstHalf } - } - - const mkRawData = AsyncResult.case({ - Ok: (data) => `data:application/json,${JSON.stringify(data)}`, - _: () => null, - }) - - if (!cfg.analyticsBucket) { - return ( - -
Requires CloudTrail
-
- ) - } - - return ( - - {(data) => ( - -
- -
-
- {AsyncResult.case( - { - Ok: (counts) => { - const stats = cursorStats(counts) - const hl = stats && stats.highlighted - const ext = hl ? hl.ext || 'other' : 'total' - const total = hl ? hl.total : counts.combined.total - if (!counts.byExtCollapsed.length) return 'Downloads' - return ( - <> - Downloads ({ext}):{' '} - {readableQuantity(total)} - - ) - }, - _: () => 'Downloads', - }, - data, - )} -
-
- {AsyncResult.case( - { - Ok: (counts) => { - if (!counts.byExtCollapsed.length) { - return ( - -
No Data
-
- ) - } - - const stats = cursorStats(counts) - return ( - <> - - e.counts.map((i) => Math.log(i.sum + 1)), - )} - onCursor={setCursor} - height={CHART_H} - width={width} - areaFills={counts.byExtCollapsed.map((e) => - SVG.Paint.Color(colorPool.get(e.ext)), - )} - lineStroke={SVG.Paint.Color(M.colors.grey[500])} - extendL - extendR - px={10} - /> - - {() => ( - - )} - - - {() => ( - - )} - - - ) - }, - _: () => , - }, - data, - )} -
-
- )} -
- ) -} - -const useStatDisplayStyles = M.makeStyles((t) => ({ - root: { - alignItems: 'baseline', - display: 'flex', - '& + &': { - marginLeft: t.spacing(1.5), - [t.breakpoints.up('sm')]: { - marginLeft: t.spacing(4), - }, - [t.breakpoints.up('md')]: { - marginLeft: t.spacing(6), - }, - }, - }, - value: { - fontSize: t.typography.h6.fontSize, - fontWeight: t.typography.fontWeightBold, - letterSpacing: 0, - lineHeight: '20px', - [t.breakpoints.up('sm')]: { - fontSize: t.typography.h4.fontSize, - lineHeight: '32px', - }, - }, - label: { - ...t.typography.body2, - color: t.palette.grey[300], - lineHeight: 1, - marginLeft: t.spacing(0.5), - [t.breakpoints.up('sm')]: { - marginLeft: t.spacing(1), - }, - }, - skeletonContainer: { - alignItems: 'center', - height: 20, - [t.breakpoints.up('sm')]: { - height: 32, - }, - }, - skeleton: { - borderRadius: t.shape.borderRadius, - height: t.typography.h6.fontSize, - width: 96, - [t.breakpoints.up('sm')]: { - height: t.typography.h4.fontSize, - width: 120, - }, - }, -})) - -function StatDisplay({ value, label, format, fallback }) { - const classes = useStatDisplayStyles() - return R.pipe( - AsyncResult.case({ - Ok: R.pipe(format || R.identity, AsyncResult.Ok), - Err: R.pipe(fallback || R.identity, AsyncResult.Ok), - _: R.identity, - }), - AsyncResult.case({ - Ok: (v) => - v != null && ( - - {v} - {!!label && {label}} - - ), - _: () => ( -
- -
- ), - }), - )(value) -} - -const useHeadStyles = M.makeStyles((t) => ({ - root: { - position: 'relative', - [t.breakpoints.down('xs')]: { - borderRadius: 0, - }, - [t.breakpoints.up('sm')]: { - marginTop: t.spacing(2), - }, - }, - top: { - background: `center / cover url(${bg}) ${t.palette.grey[700]}`, - borderTopLeftRadius: t.shape.borderRadius, - borderTopRightRadius: t.shape.borderRadius, - color: t.palette.common.white, - overflow: 'hidden', - paddingBottom: t.spacing(3), - paddingLeft: t.spacing(2), - paddingRight: t.spacing(2), - paddingTop: t.spacing(4), - position: 'relative', - [t.breakpoints.up('sm')]: { - padding: t.spacing(4), - }, - [t.breakpoints.down('xs')]: { - borderRadius: 0, - }, - }, - settings: { - color: t.palette.common.white, - position: 'absolute', - right: t.spacing(2), - top: t.spacing(2), - }, -})) - -function Head({ s3, overviewUrl, bucket, description }) { - const classes = useHeadStyles() - const req = APIConnector.use() - const isRODA = !!overviewUrl && overviewUrl.includes(`/${RODA_BUCKET}/`) - const colorPool = useConst(() => mkKeyedPool(COLOR_MAP)) - const statsData = useData(requests.bucketStats, { req, s3, bucket, overviewUrl }) - const pkgCountData = useData(requests.countPackageRevisions, { req, bucket }) - const { urls } = NamedRoutes.use() - const isAdmin = redux.useSelector(authSelectors.isAdmin) - return ( - - - {bucket} - {!!description && ( - - {description} - - )} - {isRODA && ( - - - From the{' '} - - Registry of Open Data on AWS - - - - )} - - '? B'} - /> - '?'} - /> - null} - /> - - {isAdmin && ( - - - settings - - - )} - - - - - - - - - - - - ) -} - -function Readmes({ s3, overviewUrl, bucket }) { - return ( - - {AsyncResult.case({ - Ok: (rs) => - (rs.discovered.length > 0 || !!rs.forced) && ( - <> - {!!rs.forced && ( - - )} - {rs.discovered.map((h) => ( - - ))} - - ), - _: () => , - })} - - ) -} - -function Imgs({ s3, overviewUrl, inStack, bucket }) { - const req = APIConnector.use() - return ( - - {AsyncResult.case({ - Ok: (images) => (images.length ? : null), - _: () => , - })} - - ) -} - -function ThumbnailsWrapper({ - s3, - overviewUrl, - inStack, - bucket, - preferences: galleryPrefs, -}) { - if (cfg.noOverviewImages || !galleryPrefs) return null - if (!galleryPrefs.overview) return null - return ( - - {AsyncResult.case({ - Ok: (h) => - (!h || galleryPrefs.summarize) && ( - - ), - Err: () => , - Pending: () => , - _: () => null, - })} - - ) -} - -export default function Overview() { - const { bucket } = useParams() - - const s3 = AWS.S3.use() - const { bucketConfig } = useQueryS(BUCKET_CONFIG_QUERY, { bucket }) - const inStack = !!bucketConfig - const overviewUrl = bucketConfig?.overviewUrl - const description = bucketConfig?.description - const prefs = BucketPreferences.use() - return ( - - {inStack && ( - - - - )} - {bucketConfig ? ( - - ) : ( - - {bucket} - - )} - - {BucketPreferences.Result.match( - { - Ok: ({ ui: { blocks } }) => ( - - ), - Pending: () => , - Init: R.F, - }, - prefs, - )} - - - ) -} diff --git a/catalog/app/containers/Bucket/Overview/ColorPool.ts b/catalog/app/containers/Bucket/Overview/ColorPool.ts new file mode 100644 index 00000000000..6be8be62756 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/ColorPool.ts @@ -0,0 +1,16 @@ +export interface ColorPool { + get: (key: string) => string +} + +export function makeColorPool(pool: string[]): ColorPool { + const map: Record = {} + let poolIdx = 0 + const get = (key: string): string => { + if (!(key in map)) { + // eslint-disable-next-line no-plusplus + map[key] = pool[poolIdx++ % pool.length] + } + return map[key] + } + return { get } +} diff --git a/catalog/app/containers/Bucket/Overview/Downloads.spec.ts b/catalog/app/containers/Bucket/Overview/Downloads.spec.ts new file mode 100644 index 00000000000..0611d0b7b54 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Downloads.spec.ts @@ -0,0 +1,194 @@ +import { processBucketAccessCounts } from './Downloads' + +jest.mock( + 'constants/config', + jest.fn(() => ({})), +) + +describe('containers/Bucket/Overview/Downloads', () => { + describe('processBucketAccessCounts', () => { + it('should normalize the data received from GQL and compute some missing data', () => { + expect( + processBucketAccessCounts({ + __typename: 'BucketAccessCounts', + byExt: [ + { + __typename: 'AccessCountsGroup', + ext: 'csv', + counts: { + __typename: 'AccessCounts', + total: 10, + counts: [ + { + __typename: 'AccessCountForDate', + value: 1, + date: new Date('2021-08-01'), + }, + { + __typename: 'AccessCountForDate', + value: 2, + date: new Date('2021-08-02'), + }, + { + __typename: 'AccessCountForDate', + value: 3, + date: new Date('2021-08-03'), + }, + { + __typename: 'AccessCountForDate', + value: 4, + date: new Date('2021-08-04'), + }, + ], + }, + }, + ], + byExtCollapsed: [ + { + __typename: 'AccessCountsGroup', + ext: 'csv', + counts: { + __typename: 'AccessCounts', + total: 10, + counts: [ + { + __typename: 'AccessCountForDate', + value: 1, + date: new Date('2021-08-01'), + }, + { + __typename: 'AccessCountForDate', + value: 2, + date: new Date('2021-08-02'), + }, + { + __typename: 'AccessCountForDate', + value: 3, + date: new Date('2021-08-03'), + }, + { + __typename: 'AccessCountForDate', + value: 4, + date: new Date('2021-08-04'), + }, + ], + }, + }, + ], + combined: { + __typename: 'AccessCounts', + total: 10, + counts: [ + { + __typename: 'AccessCountForDate', + value: 1, + date: new Date('2021-08-01'), + }, + { + __typename: 'AccessCountForDate', + value: 2, + date: new Date('2021-08-02'), + }, + { + __typename: 'AccessCountForDate', + value: 3, + date: new Date('2021-08-03'), + }, + { + __typename: 'AccessCountForDate', + value: 4, + date: new Date('2021-08-04'), + }, + ], + }, + }), + ).toEqual({ + byExt: [ + { + ext: '.csv', + counts: { + total: 10, + counts: [ + { + date: new Date('2021-08-01'), + value: 1, + sum: 1, + }, + { + date: new Date('2021-08-02'), + value: 2, + sum: 3, + }, + { + date: new Date('2021-08-03'), + value: 3, + sum: 6, + }, + { + date: new Date('2021-08-04'), + value: 4, + sum: 10, + }, + ], + }, + }, + ], + byExtCollapsed: [ + { + ext: '.csv', + counts: { + total: 10, + counts: [ + { + date: new Date('2021-08-01'), + value: 1, + sum: 1, + }, + { + date: new Date('2021-08-02'), + value: 2, + sum: 3, + }, + { + date: new Date('2021-08-03'), + value: 3, + sum: 6, + }, + { + date: new Date('2021-08-04'), + value: 4, + sum: 10, + }, + ], + }, + }, + ], + combined: { + total: 10, + counts: [ + { + date: new Date('2021-08-01'), + value: 1, + sum: 1, + }, + { + date: new Date('2021-08-02'), + value: 2, + sum: 3, + }, + { + date: new Date('2021-08-03'), + value: 3, + sum: 6, + }, + { + date: new Date('2021-08-04'), + value: 4, + sum: 10, + }, + ], + }, + }) + }) + }) +}) diff --git a/catalog/app/containers/Bucket/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx new file mode 100644 index 00000000000..065519d45b2 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -0,0 +1,593 @@ +import cx from 'classnames' +import * as dateFns from 'date-fns' +import * as Eff from 'effect' +import * as React from 'react' +import * as M from '@material-ui/core' +import { fade } from '@material-ui/core/styles' +import useComponentSize from '@rehooks/component-size' + +import StackedAreaChart from 'components/StackedAreaChart' +import cfg from 'constants/config' +import * as GQL from 'utils/GraphQL' +import log from 'utils/Logging' +import * as SVG from 'utils/SVG' +import { readableQuantity } from 'utils/string' + +import { ColorPool } from './ColorPool' + +import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' + +type GQLBucketAccessCounts = NonNullable< + GQL.DataForDoc['bucketAccessCounts'] +> +type GQLAccessCountsGroup = GQLBucketAccessCounts['byExt'][0] +type GQLAccessCounts = GQLBucketAccessCounts['combined'] +type GQLAccessCountForDate = GQLAccessCounts['counts'][0] + +interface ProcessedAccessCountForDate { + date: Date + value: number + sum: number +} + +interface ProcessedAccessCounts { + total: number + counts: readonly ProcessedAccessCountForDate[] +} + +interface ProcessedAccessCountsGroup { + ext: string + counts: ProcessedAccessCounts +} + +interface ProcessedBucketAccessCounts { + byExt: readonly ProcessedAccessCountsGroup[] + byExtCollapsed: readonly ProcessedAccessCountsGroup[] + combined: ProcessedAccessCounts +} + +const processAccessCountForDateArr = ( + counts: readonly GQLAccessCountForDate[], +): readonly ProcessedAccessCountForDate[] => + // compute running sum + Eff.Array.mapAccum(counts, 0, (acc, { value, date }) => [ + acc + value, + { + value, + date, + sum: acc + value, + }, + ])[1] + +const processAccessCounts = (counts: GQLAccessCounts): ProcessedAccessCounts => ({ + total: counts.total, + counts: processAccessCountForDateArr(counts.counts), +}) + +const processAccessCountsGroup = ( + group: GQLAccessCountsGroup, +): ProcessedAccessCountsGroup => ({ + ext: group.ext && `.${group.ext}`, + counts: processAccessCounts(group.counts), +}) + +export const processBucketAccessCounts = ( + counts: GQLBucketAccessCounts, +): ProcessedBucketAccessCounts => ({ + byExt: counts.byExt.map(processAccessCountsGroup), + byExtCollapsed: counts.byExtCollapsed.map(processAccessCountsGroup), + combined: processAccessCounts(counts.combined), +}) + +interface Cursor { + i: number | null // ext + j: number // date +} + +interface CursorStats { + date: Date + combined: { + sum: number + value: number + } + byExt: { + ext: string + sum: number + value: number + date: Date + }[] + highlighted: { + ext: string + counts: ProcessedAccessCounts + } | null + firstHalf: boolean +} + +function getCursorStats( + counts: ProcessedBucketAccessCounts, + cursor: Cursor | null, +): CursorStats | null { + if (!cursor) return null + + const { date, ...combined } = counts.combined.counts[cursor.j] + const byExt = counts.byExtCollapsed.map((e) => ({ + ext: e.ext, + ...e.counts.counts[cursor.j], + })) + const highlighted = cursor.i == null ? null : counts.byExtCollapsed[cursor.i] + const firstHalf = cursor.j < counts.combined.counts.length / 2 + return { date, combined, byExt, highlighted, firstHalf } +} + +const skelData = Eff.Array.makeBy( + 8, + Eff.flow( + () => Eff.Array.makeBy(30, Math.random), + Eff.Array.scan(0, Eff.Number.sum), + Eff.Array.drop(1), + Eff.Array.map((v) => Math.log(100 * v + 1)), + ), +) + +const skelColors = [ + [M.colors.grey[300], M.colors.grey[100]], + [M.colors.grey[400], M.colors.grey[200]], +] as const + +const mkPulsingGradient = ([c1, c2]: readonly [string, string], animate: boolean) => + SVG.Paint.Server( + + + {animate && ( + + )} + + , + ) + +interface ChartSkelProps { + height: number + width: number + lines?: number + animate?: boolean + children?: React.ReactNode +} + +function ChartSkel({ + height, + width, + lines = skelData.length, + animate = false, + children, +}: ChartSkelProps) { + const data = React.useMemo( + () => Eff.Array.makeBy(lines, (i) => skelData[i % skelData.length]), + [lines], + ) + const fills = React.useMemo( + () => + Eff.Array.makeBy(lines, (i) => + mkPulsingGradient(skelColors[i % skelColors.length], animate), + ), + [lines, animate], + ) + return ( + + {/* @ts-expect-error */} + + {children} + + ) +} + +const ANALYTICS_WINDOW_OPTIONS = [ + { value: 31, label: 'Last 1 month' }, + { value: 91, label: 'Last 3 months' }, + { value: 182, label: 'Last 6 months' }, + { value: 365, label: 'Last 12 months' }, +] + +interface DownloadsRangeProps { + value: number + onChange: (value: number) => void + bucket: string + data: Eff.Option.Option +} + +function DownloadsRange({ value, onChange, bucket, data }: DownloadsRangeProps) { + const [anchor, setAnchor] = React.useState(null) + + const open = React.useCallback( + (e) => { + setAnchor(e.target) + }, + [setAnchor], + ) + + const close = React.useCallback(() => { + setAnchor(null) + }, [setAnchor]) + + const choose = React.useCallback( + (e) => { + onChange(e.target.value) + close() + }, + [onChange, close], + ) + + const { label } = ANALYTICS_WINDOW_OPTIONS.find((o) => o.value === value) || {} + + const jsonData = React.useMemo( + () => + Eff.Option.match(data, { + onNone: () => null, + onSome: (d) => `data:application/json,${JSON.stringify(d)}`, + }), + [data], + ) + + return ( + <> + + + {label} expand_more + + + {ANALYTICS_WINDOW_OPTIONS.map((o) => ( + + {o.label} + + ))} + + + Download to file + + + + ) +} + +const useStatsTipStyles = M.makeStyles((t) => ({ + root: { + background: fade(t.palette.grey[700], 0.9), + color: t.palette.common.white, + padding: '6px 8px', + }, + head: { + display: 'flex', + justifyContent: 'space-between', + marginBottom: 4, + }, + date: {}, + total: {}, + extsContainer: { + alignItems: 'center', + display: 'grid', + gridAutoRows: 'auto', + gridColumnGap: 4, + gridTemplateColumns: 'max-content max-content 1fr', + }, + ext: { + fontSize: 12, + lineHeight: '16px', + maxWidth: 70, + opacity: 0.6, + overflow: 'hidden', + textAlign: 'right', + textOverflow: 'ellipsis', + }, + color: { + borderRadius: '50%', + height: 8, + opacity: 0.6, + width: 8, + }, + number: { + fontSize: 12, + lineHeight: '16px', + opacity: 0.6, + }, + hl: { + opacity: 1, + }, +})) + +interface StatsTipProps { + stats: CursorStats | null + colorPool: ColorPool + className?: string +} + +function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { + const classes = useStatsTipStyles() + if (!stats) return null + return ( + +
+
{dateFns.format(stats.date, 'd MMM')}
+
+ {readableQuantity(stats.combined.sum)} (+ + {readableQuantity(stats.combined.value)}) +
+
+
+ {stats.byExt.map((s) => { + const hl = stats.highlighted ? stats.highlighted.ext === s.ext : true + return ( + +
{s.ext || 'other'}
+
+
+ {readableQuantity(s.sum)} (+ + {readableQuantity(s.value)}) +
+ + ) + })} +
+ + ) +} + +interface TransitionProps { + children: JSX.Element + in: boolean +} + +function Transition({ children, ...props }: TransitionProps) { + const contentsRef = React.useRef(null) + // when `in` is false, we want to keep the last rendered contents + if (props.in) contentsRef.current = children + return contentsRef.current && {contentsRef.current} +} + +const useStyles = M.makeStyles((t) => ({ + root: { + display: 'grid', + gridRowGap: t.spacing(0.25), + gridTemplateAreas: ` + "heading period" + "chart chart" + `, + gridTemplateColumns: 'min-content 1fr', + gridTemplateRows: 'auto auto', + [t.breakpoints.down('sm')]: { + gridTemplateAreas: ` + "heading" + "chart" + "period" + `, + gridTemplateColumns: '1fr', + gridTemplateRows: 'auto auto auto', + }, + }, + heading: { + ...t.typography.h6, + gridArea: 'heading', + marginBottom: t.spacing(1), + whiteSpace: 'nowrap', + [t.breakpoints.down('sm')]: { + marginBottom: 0, + textAlign: 'center', + }, + }, + ext: { + display: 'inline-block', + maxWidth: 100, + overflow: 'hidden', + textOverflow: 'ellipsis', + verticalAlign: 'bottom', + }, + period: { + display: 'flex', + gridArea: 'period', + justifyContent: 'center', + alignItems: 'center', + [t.breakpoints.down('sm')]: { + paddingBottom: t.spacing(1), + paddingTop: t.spacing(2), + }, + [t.breakpoints.up('md')]: { + height: 37, + justifyContent: 'flex-end', + }, + }, + chart: { + gridArea: 'chart', + position: 'relative', + }, + left: {}, + right: {}, + dateStats: { + maxWidth: 180, + position: 'absolute', + top: 0, + width: 'calc(50% - 8px)', + zIndex: 1, + '&$left': { + left: 0, + }, + '&$right': { + right: 0, + }, + }, + unavail: { + ...t.typography.body2, + alignItems: 'center', + display: 'flex', + height: '100%', + justifyContent: 'center', + position: 'absolute', + top: 0, + width: '100%', + }, +})) + +interface DownloadsProps extends M.BoxProps { + bucket: string + colorPool: ColorPool + chartHeight: number +} + +export default function Downloads({ + bucket, + colorPool, + chartHeight, + ...props +}: DownloadsProps) { + const classes = useStyles() + const ref = React.useRef(null) + const { width } = useComponentSize(ref) + const [window, setWindow] = React.useState(ANALYTICS_WINDOW_OPTIONS[0].value) + + const [cursor, setCursor] = React.useState(null) + + const result = GQL.useQuery( + BUCKET_ACCESS_COUNTS_QUERY, + { bucket, window }, + { pause: !cfg.analyticsBucket }, + ) + + const processed = React.useMemo( + () => + Eff.pipe( + result, + ({ fetching, data, error }) => { + if (fetching) return Eff.Option.none() + if (error) log.error('Error fetching bucket access counts:', error) + return Eff.Option.fromNullable(data?.bucketAccessCounts) + }, + Eff.Option.map(processBucketAccessCounts), + ), + [result], + ) + + const processedWithCursor = React.useMemo( + () => + Eff.pipe( + processed, + Eff.Option.map((counts) => ({ + counts, + cursorStats: getCursorStats(counts, cursor), + })), + ), + [processed, cursor], + ) + + if (!cfg.analyticsBucket) { + return ( + +
Requires CloudTrail
+
+ ) + } + + return ( + +
+ +
+
+ {Eff.Option.match(processedWithCursor, { + onSome: ({ counts, cursorStats: stats }) => { + if (!counts?.byExtCollapsed.length) return 'Downloads' + + const hl = stats?.highlighted + const ext = hl ? hl.ext || 'other' : 'total' + const total = hl ? hl.counts.total : counts.combined.total + return ( + <> + Downloads ({ext}):{' '} + {readableQuantity(total)} + + ) + }, + onNone: () => 'Downloads', + })} +
+
+ {Eff.Option.match(processedWithCursor, { + onSome: ({ counts, cursorStats: stats }) => { + if (!counts.byExtCollapsed.length) { + return ( + +
No Data
+
+ ) + } + + return ( + <> + {/* @ts-expect-error */} + + e.counts.counts.map((i) => Math.log(i.sum + 1)), + )} + onCursor={setCursor} + height={chartHeight} + width={width} + areaFills={counts.byExtCollapsed.map((e) => + SVG.Paint.Color(colorPool.get(e.ext)), + )} + lineStroke={SVG.Paint.Color(M.colors.grey[500])} + extendL + extendR + px={10} + /> + + + + + + + + ) + }, + onNone: () => , + })} +
+
+ ) +} diff --git a/catalog/app/containers/Bucket/Overview/Header.tsx b/catalog/app/containers/Bucket/Overview/Header.tsx new file mode 100644 index 00000000000..3c76edd1330 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Header.tsx @@ -0,0 +1,431 @@ +import type AWSSDK from 'aws-sdk' +import cx from 'classnames' +import * as Eff from 'effect' +import * as React from 'react' +import { Link as RRLink } from 'react-router-dom' +import * as redux from 'react-redux' +import * as M from '@material-ui/core' + +import Skeleton from 'components/Skeleton' +import * as authSelectors from 'containers/Auth/selectors' +import * as APIConnector from 'utils/APIConnector' +import AsyncResult from 'utils/AsyncResult' +import { useData } from 'utils/Data' +import * as NamedRoutes from 'utils/NamedRoutes' +import { readableBytes, readableQuantity, formatQuantity } from 'utils/string' +import useConst from 'utils/useConstant' + +import * as requests from '../requests' + +import { ColorPool, makeColorPool } from './ColorPool' +import Downloads from './Downloads' + +import bg from './Overview-bg.jpg' + +// interface StatsData { +// exts: ExtData[] +// totalObjects: number +// totalBytes: number +// } + +interface ExtData { + ext: string + bytes: number + objects: number +} + +const RODA_LINK = 'https://registry.opendata.aws' +const RODA_BUCKET = 'quilt-open-data-bucket' +const MAX_EXTS = 7 +// must have length >= MAX_EXTS +const COLOR_MAP = [ + '#8ad3cb', + '#d7ce69', + '#bfbadb', + '#f4806c', + '#83b0d1', + '#b2de67', + '#bc81be', + '#f0b5d3', + '#7ba39f', + '#9894ad', + '#be7265', + '#94ad6b', +] + +const useObjectsByExtStyles = M.makeStyles((t) => ({ + root: { + display: 'grid', + gridAutoRows: 20, + gridColumnGap: t.spacing(1), + gridRowGap: t.spacing(0.25), + gridTemplateAreas: ` + ". heading heading" + `, + gridTemplateColumns: 'minmax(30px, max-content) 1fr minmax(30px, max-content)', + gridTemplateRows: 'auto', + [t.breakpoints.down('sm')]: { + gridTemplateAreas: ` + "heading heading heading" + `, + }, + }, + heading: { + ...t.typography.h6, + gridArea: 'heading', + marginBottom: t.spacing(1), + [t.breakpoints.down('sm')]: { + textAlign: 'center', + }, + }, + ext: { + color: t.palette.text.secondary, + gridColumn: 1, + fontSize: t.typography.overline.fontSize, + fontWeight: t.typography.fontWeightMedium, + letterSpacing: t.typography.subtitle2.letterSpacing, + lineHeight: t.typography.pxToRem(20), + textAlign: 'right', + }, + count: { + color: t.palette.text.secondary, + gridColumn: 3, + fontSize: t.typography.overline.fontSize, + fontWeight: t.typography.fontWeightMedium, + letterSpacing: t.typography.subtitle2.letterSpacing, + lineHeight: t.typography.pxToRem(20), + }, + bar: { + background: t.palette.action.hover, + gridColumn: 2, + }, + gauge: { + height: '100%', + position: 'relative', + }, + flip: {}, + size: { + color: t.palette.common.white, + fontSize: t.typography.overline.fontSize, + fontWeight: t.typography.fontWeightMedium, + letterSpacing: t.typography.subtitle2.letterSpacing, + lineHeight: t.typography.pxToRem(20), + position: 'absolute', + right: t.spacing(1), + '&$flip': { + color: t.palette.text.hint, + left: `calc(100% + ${t.spacing(1)}px)`, + right: 'auto', + }, + }, + skeleton: { + gridColumn: '1 / span 3', + }, + unavail: { + ...t.typography.body2, + alignItems: 'center', + display: 'flex', + gridColumn: '1 / span 3', + gridRow: `2 / span ${MAX_EXTS}`, + justifyContent: 'center', + }, +})) + +interface ObjectsByExtProps extends M.BoxProps { + data: $TSFixMe // AsyncResult + colorPool: ColorPool +} + +function ObjectsByExt({ data, colorPool, ...props }: ObjectsByExtProps) { + const classes = useObjectsByExtStyles() + return ( + +
Objects by File Extension
+ {AsyncResult.case( + { + Ok: (exts: ExtData[]) => { + const capped = exts.slice(0, MAX_EXTS) + const maxBytes = capped.reduce((max, e) => Math.max(max, e.bytes), 0) + const max = Math.log(maxBytes + 1) + const scale = (x: number) => Math.log(x + 1) / max + return capped.map(({ ext, bytes, objects }, i) => { + const color = colorPool.get(ext) + return ( + +
+ {ext || 'other'} +
+
+
+
+ {readableBytes(bytes)} +
+
+
+
+ {readableQuantity(objects)} +
+
+ ) + }) + }, + _: (r: $TSFixMe) => ( + <> + {Eff.Array.makeBy(MAX_EXTS, (i) => ( + + ))} + {AsyncResult.Err.is(r) && ( +
Data unavailable
+ )} + + ), + }, + data, + )} +
+ ) +} + +const useStatDisplayStyles = M.makeStyles((t) => ({ + root: { + alignItems: 'baseline', + display: 'flex', + '& + &': { + marginLeft: t.spacing(1.5), + [t.breakpoints.up('sm')]: { + marginLeft: t.spacing(4), + }, + [t.breakpoints.up('md')]: { + marginLeft: t.spacing(6), + }, + }, + }, + value: { + fontSize: t.typography.h6.fontSize, + fontWeight: t.typography.fontWeightBold, + letterSpacing: 0, + lineHeight: '20px', + [t.breakpoints.up('sm')]: { + fontSize: t.typography.h4.fontSize, + lineHeight: '32px', + }, + }, + label: { + ...t.typography.body2, + color: t.palette.grey[300], + lineHeight: 1, + marginLeft: t.spacing(0.5), + [t.breakpoints.up('sm')]: { + marginLeft: t.spacing(1), + }, + }, + skeletonContainer: { + alignItems: 'center', + height: 20, + [t.breakpoints.up('sm')]: { + height: 32, + }, + }, + skeleton: { + borderRadius: t.shape.borderRadius, + height: t.typography.h6.fontSize, + width: 96, + [t.breakpoints.up('sm')]: { + height: t.typography.h4.fontSize, + width: 120, + }, + }, +})) + +interface StatDisplayProps { + value: $TSFixMe // AsyncResult + label?: string + format?: (v: any) => any + fallback?: (v: any) => any +} + +function StatDisplay({ value, label, format, fallback }: StatDisplayProps) { + const classes = useStatDisplayStyles() + return Eff.pipe( + value, + AsyncResult.case({ + Ok: Eff.flow(format || Eff.identity, AsyncResult.Ok), + Err: Eff.flow(fallback || Eff.identity, AsyncResult.Ok), + _: Eff.identity, + }), + AsyncResult.case({ + Ok: (v: $TSFixMe) => + v != null && ( + + {v} + {!!label && {label}} + + ), + _: () => ( +
+ +
+ ), + }), + ) as JSX.Element +} + +// use the same height as the bar chart: 20px per bar with 2px margin +const DOWNLOADS_CHART_H = 22 * MAX_EXTS - 2 + +const useStyles = M.makeStyles((t) => ({ + root: { + position: 'relative', + [t.breakpoints.down('xs')]: { + borderRadius: 0, + }, + [t.breakpoints.up('sm')]: { + marginTop: t.spacing(2), + }, + }, + top: { + background: `center / cover url(${bg}) ${t.palette.grey[700]}`, + borderTopLeftRadius: t.shape.borderRadius, + borderTopRightRadius: t.shape.borderRadius, + color: t.palette.common.white, + overflow: 'hidden', + paddingBottom: t.spacing(3), + paddingLeft: t.spacing(2), + paddingRight: t.spacing(2), + paddingTop: t.spacing(4), + position: 'relative', + [t.breakpoints.up('sm')]: { + padding: t.spacing(4), + }, + [t.breakpoints.down('xs')]: { + borderRadius: 0, + }, + }, + settings: { + color: t.palette.common.white, + position: 'absolute', + right: t.spacing(2), + top: t.spacing(2), + }, +})) + +interface HeaderProps { + s3: AWSSDK.S3 + bucket: string + overviewUrl: string | null | undefined + description: string | null | undefined +} + +export default function Header({ s3, overviewUrl, bucket, description }: HeaderProps) { + const classes = useStyles() + const req = APIConnector.use() + const isRODA = !!overviewUrl && overviewUrl.includes(`/${RODA_BUCKET}/`) + const colorPool = useConst(() => makeColorPool(COLOR_MAP)) + const statsData = useData(requests.bucketStats, { req, s3, bucket, overviewUrl }) + const pkgCountData = useData(requests.countPackageRevisions, { req, bucket }) + const { urls } = NamedRoutes.use() + const isAdmin = redux.useSelector(authSelectors.isAdmin) + return ( + + + {bucket} + {!!description && ( + + {description} + + )} + {isRODA && ( + + + From the{' '} + + Registry of Open Data on AWS + + + + )} + + '? B'} + /> + '?'} + /> + null} + /> + + {isAdmin && ( + + + settings + + + )} + + + + + + + + + + + + ) +} diff --git a/catalog/app/containers/Bucket/Overview-bg.jpg b/catalog/app/containers/Bucket/Overview/Overview-bg.jpg similarity index 100% rename from catalog/app/containers/Bucket/Overview-bg.jpg rename to catalog/app/containers/Bucket/Overview/Overview-bg.jpg diff --git a/catalog/app/containers/Bucket/Overview/Overview.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx new file mode 100644 index 00000000000..2eee868fd4f --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -0,0 +1,163 @@ +import type AWSSDK from 'aws-sdk' +import * as React from 'react' +import { useParams } from 'react-router-dom' +import * as M from '@material-ui/core' + +import cfg from 'constants/config' +import type * as Model from 'model' +import * as APIConnector from 'utils/APIConnector' +import * as AWS from 'utils/AWS' +import AsyncResult from 'utils/AsyncResult' +import * as BucketPreferences from 'utils/BucketPreferences' +import Data from 'utils/Data' +import * as GQL from 'utils/GraphQL' +import * as LinkedData from 'utils/LinkedData' + +import * as Gallery from '../Gallery' +import * as Summarize from '../Summarize' +import * as requests from '../requests' + +import Header from './Header' +import BUCKET_CONFIG_QUERY from './gql/BucketConfig.generated' + +interface BucketReadmes { + forced?: Model.S3.S3ObjectLocation + discovered: Model.S3.S3ObjectLocation[] +} + +interface ReadmesProps { + s3: AWSSDK.S3 + bucket: string + overviewUrl: string | undefined | null +} + +function Readmes({ s3, overviewUrl, bucket }: ReadmesProps) { + return ( + // @ts-expect-error + + {AsyncResult.case({ + Ok: (rs: BucketReadmes) => + (rs.discovered.length > 0 || !!rs.forced) && ( + <> + {!!rs.forced && ( + + )} + {rs.discovered.map((h) => ( + + ))} + + ), + _: () => , + })} + + ) +} + +interface ImgsProps { + s3: AWSSDK.S3 + bucket: string + overviewUrl: string | undefined | null + inStack: boolean +} + +function Imgs({ s3, overviewUrl, inStack, bucket }: ImgsProps) { + const req = APIConnector.use() + return ( + // @ts-expect-error + + {AsyncResult.case({ + Ok: (images: Model.S3.S3ObjectLocation[]) => + images.length ? : null, + _: () => , + })} + + ) +} + +interface ThumbnailsWrapperProps extends ImgsProps { + preferences?: + | false + | { + overview: boolean + summarize: boolean + } +} + +function ThumbnailsWrapper({ + s3, + overviewUrl, + inStack, + bucket, + preferences: galleryPrefs, +}: ThumbnailsWrapperProps) { + if (cfg.noOverviewImages || !galleryPrefs) return null + if (!galleryPrefs.overview) return null + return ( + // @ts-expect-error + + {AsyncResult.case({ + Ok: (h?: Model.S3.S3ObjectLocation) => + (!h || galleryPrefs.summarize) && ( + + ), + Err: () => , + Pending: () => , + _: () => null, + })} + + ) +} + +export default function Overview() { + const { bucket } = useParams<{ bucket: string }>() + + const s3 = AWS.S3.use() + const { bucketConfig } = GQL.useQueryS(BUCKET_CONFIG_QUERY, { bucket }) + const inStack = !!bucketConfig + const overviewUrl = bucketConfig?.overviewUrl + const description = bucketConfig?.description + const prefs = BucketPreferences.use() + return ( + + {inStack && ( + + + + )} + {bucketConfig ? ( +
+ ) : ( + + {bucket} + + )} + + {BucketPreferences.Result.match( + { + Ok: ({ ui: { blocks } }) => ( + + ), + Pending: () => , + Init: () => null, + }, + prefs, + )} + + + ) +} diff --git a/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts new file mode 100644 index 00000000000..f7a763654e1 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type AccessCountsSelectionFragment = { + readonly __typename: 'AccessCounts' +} & Pick & { + readonly counts: ReadonlyArray< + { readonly __typename: 'AccessCountForDate' } & Pick< + Types.AccessCountForDate, + 'date' | 'value' + > + > + } + +export type containers_Bucket_Overview_gql_BucketAccessCountsQueryVariables = + Types.Exact<{ + bucket: Types.Scalars['String'] + window: Types.Scalars['Int'] + }> + +export type containers_Bucket_Overview_gql_BucketAccessCountsQuery = { + readonly __typename: 'Query' +} & { + readonly bucketAccessCounts: Types.Maybe< + { readonly __typename: 'BucketAccessCounts' } & { + readonly byExt: ReadonlyArray< + { readonly __typename: 'AccessCountsGroup' } & Pick< + Types.AccessCountsGroup, + 'ext' + > & { + readonly counts: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } + > + readonly byExtCollapsed: ReadonlyArray< + { readonly __typename: 'AccessCountsGroup' } & Pick< + Types.AccessCountsGroup, + 'ext' + > & { + readonly counts: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } + > + readonly combined: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } + > +} + +export const AccessCountsSelectionFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'AccessCountsSelection' }, + typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'AccessCounts' } }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'date' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode +export const containers_Bucket_Overview_gql_BucketAccessCountsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'containers_Bucket_Overview_gql_BucketAccessCounts' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'window' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'bucketAccessCounts' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'bucket' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'bucket' } }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'window' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'window' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'byExt' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'ext' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AccessCountsSelection' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + alias: { kind: 'Name', value: 'byExtCollapsed' }, + name: { kind: 'Name', value: 'byExt' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'groups' }, + value: { kind: 'IntValue', value: '10' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'ext' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'counts' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AccessCountsSelection' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'combined' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AccessCountsSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...AccessCountsSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Bucket_Overview_gql_BucketAccessCountsQuery, + containers_Bucket_Overview_gql_BucketAccessCountsQueryVariables +> + +export { containers_Bucket_Overview_gql_BucketAccessCountsDocument as default } diff --git a/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.graphql b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.graphql new file mode 100644 index 00000000000..c54990cda53 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.graphql @@ -0,0 +1,27 @@ +fragment AccessCountsSelection on AccessCounts { + total + counts { + date + value + } +} + +query ($bucket: String!, $window: Int!) { + bucketAccessCounts(bucket: $bucket, window: $window) { + byExt { + ext + counts { + ...AccessCountsSelection + } + } + byExtCollapsed: byExt(groups: 10) { + ext + counts { + ...AccessCountsSelection + } + } + combined { + ...AccessCountsSelection + } + } +} diff --git a/catalog/app/containers/Bucket/OverviewBucketConfig.generated.ts b/catalog/app/containers/Bucket/Overview/gql/BucketConfig.generated.ts similarity index 75% rename from catalog/app/containers/Bucket/OverviewBucketConfig.generated.ts rename to catalog/app/containers/Bucket/Overview/gql/BucketConfig.generated.ts index 89a16de328f..293100b338b 100644 --- a/catalog/app/containers/Bucket/OverviewBucketConfig.generated.ts +++ b/catalog/app/containers/Bucket/Overview/gql/BucketConfig.generated.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' -import * as Types from '../../model/graphql/types.generated' +import * as Types from '../../../../model/graphql/types.generated' -export type containers_Bucket_OverviewBucketConfigQueryVariables = Types.Exact<{ +export type containers_Bucket_Overview_gql_BucketConfigQueryVariables = Types.Exact<{ bucket: Types.Scalars['String'] }> -export type containers_Bucket_OverviewBucketConfigQuery = { +export type containers_Bucket_Overview_gql_BucketConfigQuery = { readonly __typename: 'Query' } & { readonly bucketConfig: Types.Maybe< @@ -17,13 +17,13 @@ export type containers_Bucket_OverviewBucketConfigQuery = { > } -export const containers_Bucket_OverviewBucketConfigDocument = { +export const containers_Bucket_Overview_gql_BucketConfigDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: { kind: 'Name', value: 'containers_Bucket_OverviewBucketConfig' }, + name: { kind: 'Name', value: 'containers_Bucket_Overview_gql_BucketConfig' }, variableDefinitions: [ { kind: 'VariableDefinition', @@ -61,8 +61,8 @@ export const containers_Bucket_OverviewBucketConfigDocument = { }, ], } as unknown as DocumentNode< - containers_Bucket_OverviewBucketConfigQuery, - containers_Bucket_OverviewBucketConfigQueryVariables + containers_Bucket_Overview_gql_BucketConfigQuery, + containers_Bucket_Overview_gql_BucketConfigQueryVariables > -export { containers_Bucket_OverviewBucketConfigDocument as default } +export { containers_Bucket_Overview_gql_BucketConfigDocument as default } diff --git a/catalog/app/containers/Bucket/OverviewBucketConfig.graphql b/catalog/app/containers/Bucket/Overview/gql/BucketConfig.graphql similarity index 100% rename from catalog/app/containers/Bucket/OverviewBucketConfig.graphql rename to catalog/app/containers/Bucket/Overview/gql/BucketConfig.graphql diff --git a/catalog/app/containers/Bucket/Overview/index.tsx b/catalog/app/containers/Bucket/Overview/index.tsx new file mode 100644 index 00000000000..1de667af70e --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/index.tsx @@ -0,0 +1 @@ +export { default } from './Overview' diff --git a/catalog/app/containers/Bucket/Summarize.tsx b/catalog/app/containers/Bucket/Summarize.tsx index e644215263c..b88512727dd 100644 --- a/catalog/app/containers/Bucket/Summarize.tsx +++ b/catalog/app/containers/Bucket/Summarize.tsx @@ -258,7 +258,7 @@ interface FilePreviewProps { expanded?: boolean file?: SummarizeFile handle: LogicalKeyResolver.S3SummarizeHandle - headingOverride: React.ReactNode + headingOverride?: React.ReactNode packageHandle?: PackageHandle } @@ -270,7 +270,7 @@ export function FilePreview({ packageHandle, }: FilePreviewProps) { const description = file?.description ? : null - const heading = headingOverride != null ? headingOverride : + const heading = headingOverride ?? const key = handle.logicalKey || handle.key const props = React.useMemo(() => Preview.getRenderProps(key, file), [key, file]) @@ -566,7 +566,7 @@ interface SummaryRootProps { s3: S3 bucket: string inStack: boolean - overviewUrl: string + overviewUrl?: string | null } export function SummaryRoot({ s3, bucket, inStack, overviewUrl }: SummaryRootProps) { diff --git a/catalog/app/containers/Bucket/requests/requestsUntyped.js b/catalog/app/containers/Bucket/requests/requestsUntyped.js index 2ba9722da61..5efb639049f 100644 --- a/catalog/app/containers/Bucket/requests/requestsUntyped.js +++ b/catalog/app/containers/Bucket/requests/requestsUntyped.js @@ -1,7 +1,6 @@ import { join as pathJoin } from 'path' -import * as dateFns from 'date-fns' -import * as FP from 'fp-ts' +import * as Eff from 'effect' import sampleSize from 'lodash/fp/sampleSize' import * as R from 'ramda' @@ -9,7 +8,6 @@ import quiltSummarizeSchema from 'schemas/quilt_summarize.json' import { SUPPORTED_EXTENSIONS as IMG_EXTS } from 'components/Thumbnail' import * as quiltConfigs from 'constants/quiltConfigs' -import cfg from 'constants/config' import * as Resource from 'utils/Resource' import { makeSchemaValidator } from 'utils/json-schema' import mkSearch from 'utils/mkSearch' @@ -24,106 +22,6 @@ import { decodeS3Key } from './utils' const promiseProps = (obj) => Promise.all(Object.values(obj)).then(R.zipObj(Object.keys(obj))) -const MAX_BANDS = 10 - -export const bucketAccessCounts = async ({ s3, bucket, today, window }) => { - if (!cfg.analyticsBucket) - throw new Error('bucketAccessCounts: "analyticsBucket" required') - - const dates = R.unfold( - (daysLeft) => daysLeft >= 0 && [dateFns.subDays(today, daysLeft), daysLeft - 1], - window, - ) - - try { - const result = await s3Select({ - s3, - Bucket: cfg.analyticsBucket, - Key: `${ACCESS_COUNTS_PREFIX}/Exts.csv`, - Expression: ` - SELECT ext, counts FROM s3object - WHERE eventname = 'GetObject' - AND bucket = '${sqlEscape(bucket)}' - `, - InputSerialization: { - CSV: { - FileHeaderInfo: 'Use', - AllowQuotedRecordDelimiter: true, - }, - }, - }) - return FP.function.pipe( - result, - R.map((r) => { - const recordedCounts = JSON.parse(r.counts) - const { counts, total } = dates.reduce( - (acc, date) => { - const value = recordedCounts[dateFns.format(date, 'yyyy-MM-dd')] || 0 - const sum = acc.total + value - return { - total: sum, - counts: acc.counts.concat({ date, value, sum }), - } - }, - { total: 0, counts: [] }, - ) - return { ext: r.ext && `.${r.ext}`, total, counts } - }), - R.filter((i) => i.total), - R.sort(R.descend(R.prop('total'))), - R.applySpec({ - byExt: R.identity, - byExtCollapsed: (bands) => { - if (bands.length <= MAX_BANDS) return bands - const [other, rest] = R.partition((b) => b.ext === '', bands) - const [toKeep, toMerge] = R.splitAt(MAX_BANDS - 1, rest) - const merged = [...other, ...toMerge].reduce((acc, band) => ({ - ext: '', - total: acc.total + band.total, - counts: R.zipWith( - (a, b) => ({ - date: a.date, - value: a.value + b.value, - sum: a.sum + b.sum, - }), - acc.counts, - band.counts, - ), - })) - return R.sort(R.descend(R.prop('total')), toKeep.concat(merged)) - }, - combined: { - total: R.reduce((sum, { total }) => sum + total, 0), - counts: R.pipe( - R.pluck('counts'), - R.transpose, - R.map( - R.reduce( - (acc, { date, value, sum }) => ({ - date, - value: acc.value + value, - sum: acc.sum + sum, - }), - { value: 0, sum: 0 }, - ), - ), - ), - }, - }), - ) - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to fetch bucket access counts:') - // eslint-disable-next-line no-console - console.error(e) - return { - byExt: [], - byExtCollapsed: [], - combined: { total: 0, counts: [] }, - } - } -} - const parseDate = (d) => d && new Date(d) const getOverviewBucket = (url) => s3paths.parseS3Url(url).bucket @@ -373,7 +271,7 @@ export const bucketSummary = async ({ s3, req, bucket, overviewUrl, inStack }) = Key: getOverviewKey(overviewUrl, 'summary.json'), }) .promise() - return FP.function.pipe( + return Eff.pipe( JSON.parse(r.Body.toString('utf-8')), R.pathOr([], ['aggregations', 'other', 'keys', 'buckets']), R.map((b) => ({ @@ -403,7 +301,7 @@ export const bucketSummary = async ({ s3, req, bucket, overviewUrl, inStack }) = try { const qs = mkSearch({ action: 'sample', index: bucket }) const result = await req(`/search${qs}`) - return FP.function.pipe( + return Eff.pipe( result, R.pathOr([], ['aggregations', 'objects', 'buckets']), R.map((h) => { @@ -425,7 +323,7 @@ export const bucketSummary = async ({ s3, req, bucket, overviewUrl, inStack }) = const result = await s3 .listObjectsV2({ Bucket: bucket, EncodingType: 'url' }) .promise() - return FP.function.pipe( + return Eff.pipe( result, R.path(['Contents']), R.map(R.evolve({ Key: decodeS3Key })), @@ -477,7 +375,7 @@ export const bucketImgs = async ({ req, s3, bucket, overviewUrl, inStack }) => { Key: getOverviewKey(overviewUrl, 'summary.json'), }) .promise() - return FP.function.pipe( + return Eff.pipe( JSON.parse(r.Body.toString('utf-8')), R.pathOr([], ['aggregations', 'images', 'keys', 'buckets']), R.map((b) => ({ @@ -498,7 +396,7 @@ export const bucketImgs = async ({ req, s3, bucket, overviewUrl, inStack }) => { try { const qs = mkSearch({ action: 'images', index: bucket }) const result = await req(`/search${qs}`) - return FP.function.pipe( + return Eff.pipe( result, R.pathOr([], ['aggregations', 'objects', 'buckets']), R.map((h) => { @@ -519,7 +417,7 @@ export const bucketImgs = async ({ req, s3, bucket, overviewUrl, inStack }) => { const result = await s3 .listObjectsV2({ Bucket: bucket, EncodingType: 'url' }) .promise() - return FP.function.pipe( + return Eff.pipe( result, R.path(['Contents']), R.map(R.evolve({ Key: decodeS3Key })), @@ -656,8 +554,6 @@ export const summarize = async ({ s3, handle: inputHandle, resolveLogicalKey }) } } -const MANIFESTS_PREFIX = '.quilt/packages/' - const withCalculatedRevisions = (s) => ({ scripted_metric: { init_script: ` @@ -712,113 +608,33 @@ export const countPackageRevisions = ({ req, bucket, name }) => .then(R.path(['aggregations', 'revisions', 'value'])) .catch(errors.catchErrors()) -// TODO: Preview endpoint only allows up to 512 lines right now. Increase it to 1000. -const MAX_PACKAGE_ENTRIES = 500 - -// TODO: remove -export const getRevisionData = async ({ - endpoint, - sign, - bucket, - hash, - maxKeys = MAX_PACKAGE_ENTRIES, -}) => { - const url = sign({ bucket, key: `${MANIFESTS_PREFIX}${hash}` }) - const maxLines = maxKeys + 2 // 1 for the meta and 1 for checking overflow - const r = await fetch( - `${endpoint}/preview?url=${encodeURIComponent(url)}&input=txt&line_count=${maxLines}`, - ) - const [header, ...entries] = await r - .json() - .then((json) => json.info.data.head.map((l) => JSON.parse(l))) - const files = Math.min(maxKeys, entries.length) - const bytes = entries.slice(0, maxKeys).reduce((sum, i) => sum + i.size, 0) - const truncated = entries.length > maxKeys - return { - stats: { files, bytes, truncated }, - message: header.message, - header, - } -} - -const s3Select = ({ - s3, - ExpressionType = 'SQL', - InputSerialization = { JSON: { Type: 'LINES' } }, - ...rest -}) => - s3 - .selectObjectContent({ - ExpressionType, - InputSerialization, - OutputSerialization: { JSON: {} }, - ...rest, - }) - .promise() - .then( - R.pipe( - R.prop('Payload'), - R.reduce((acc, evt) => { - if (!evt.Records) return acc - const s = evt.Records.Payload.toString() - return acc + s - }, ''), - R.trim, - R.ifElse(R.isEmpty, R.always([]), R.pipe(R.split('\n'), R.map(JSON.parse))), - ), - ) - -const sqlEscape = (arg) => arg.replace(/'/g, "''") +// const MANIFESTS_PREFIX = '.quilt/packages/' -const ACCESS_COUNTS_PREFIX = 'AccessCounts' - -const queryAccessCounts = async ({ s3, type, query, today, window = 365 }) => { - try { - const records = await s3Select({ - s3, - Bucket: cfg.analyticsBucket, - Key: `${ACCESS_COUNTS_PREFIX}/${type}.csv`, - Expression: query, - InputSerialization: { - CSV: { - FileHeaderInfo: 'Use', - AllowQuotedRecordDelimiter: true, - }, - }, - }) - - const recordedCounts = records.length ? JSON.parse(records[0].counts) : {} - - const counts = R.times((i) => { - const date = dateFns.subDays(today, window - i - 1) - return { - date, - value: recordedCounts[dateFns.format(date, 'yyyy-MM-dd')] || 0, - } - }, window) - - const total = Object.values(recordedCounts).reduce(R.add, 0) - - return { counts, total } - } catch (e) { - // eslint-disable-next-line no-console - console.log('queryAccessCounts: error caught') - // eslint-disable-next-line no-console - console.error(e) - throw e - } -} - -export const objectAccessCounts = ({ s3, bucket, path, today }) => - queryAccessCounts({ - s3, - type: 'Objects', - query: ` - SELECT counts FROM s3object - WHERE eventname = 'GetObject' - AND bucket = '${sqlEscape(bucket)}' - AND "key" = '${sqlEscape(path)}' - `, - today, - window: 365, - }) +// TODO: Preview endpoint only allows up to 512 lines right now. Increase it to 1000. +// const MAX_PACKAGE_ENTRIES = 500 + +// TODO: remove: used in a comented-out code in PackageList +// export const getRevisionData = async ({ +// endpoint, +// sign, +// bucket, +// hash, +// maxKeys = MAX_PACKAGE_ENTRIES, +// }) => { +// const url = sign({ bucket, key: `${MANIFESTS_PREFIX}${hash}` }) +// const maxLines = maxKeys + 2 // 1 for the meta and 1 for checking overflow +// const r = await fetch( +// `${endpoint}/preview?url=${encodeURIComponent(url)}&input=txt&line_count=${maxLines}`, +// ) +// const [header, ...entries] = await r +// .json() +// .then((json) => json.info.data.head.map((l) => JSON.parse(l))) +// const files = Math.min(maxKeys, entries.length) +// const bytes = entries.slice(0, maxKeys).reduce((sum, i) => sum + i.size, 0) +// const truncated = entries.length > maxKeys +// return { +// stats: { files, bytes, truncated }, +// message: header.message, +// header, +// } +// } diff --git a/catalog/app/embed/File.js b/catalog/app/embed/File.js index bc47739202b..247a19ff163 100644 --- a/catalog/app/embed/File.js +++ b/catalog/app/embed/File.js @@ -1,7 +1,5 @@ import { basename } from 'path' -import * as dateFns from 'date-fns' -import * as R from 'ramda' import * as React from 'react' import { Link, useLocation, useParams } from 'react-router-dom' import * as M from '@material-ui/core' @@ -9,22 +7,21 @@ import * as M from '@material-ui/core' import * as BreadCrumbs from 'components/BreadCrumbs' import Message from 'components/Message' import * as Preview from 'components/Preview' -import Sparkline from 'components/Sparkline' import cfg from 'constants/config' import * as Notifications from 'containers/Notifications' import * as AWS from 'utils/AWS' import AsyncResult from 'utils/AsyncResult' import { useData } from 'utils/Data' import * as NamedRoutes from 'utils/NamedRoutes' -import * as SVG from 'utils/SVG' import { linkStyle } from 'utils/StyledLink' import copyToClipboard from 'utils/clipboard' import * as Format from 'utils/format' import parseSearch from 'utils/parseSearch' import * as s3paths from 'utils/s3paths' -import { readableBytes, readableQuantity } from 'utils/string' +import { readableBytes } from 'utils/string' import FileCodeSamples from 'containers/Bucket/CodeSamples/File' +import Analytics from 'containers/Bucket/File/Analytics' import FileProperties from 'containers/Bucket/FileProperties' import * as FileView from 'containers/Bucket/FileView' import Section from 'containers/Bucket/Section' @@ -229,74 +226,6 @@ function VersionInfo({ bucket, path, version }) { ) } -function Analytics({ bucket, path }) { - const [cursor, setCursor] = React.useState(null) - const s3 = AWS.S3.use() - const today = React.useMemo(() => new Date(), []) - const formatDate = (date) => - dateFns.format( - date, - today.getFullYear() === date.getFullYear() ? 'd MMM' : 'd MMM yyyy', - ) - const data = useData(requests.objectAccessCounts, { - s3, - bucket, - path, - today, - }) - - const defaultExpanded = data.case({ - Ok: ({ total }) => !!total, - _: () => false, - }) - - return ( -
- {data.case({ - Ok: ({ counts, total }) => - total ? ( - - - Downloads - - {readableQuantity(cursor === null ? total : counts[cursor].value)} - - - {cursor === null - ? `${counts.length} days` - : formatDate(counts[cursor].date)} - - - - - - - , - )} - /> - - - ) : ( - No analytics available - ), - Err: () => No analytics available, - _: () => , - })} -
- ) -} - function CenteredProgress() { return ( diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index ba8ed87e3fd..be04791e97a 100644 --- a/catalog/app/model/graphql/schema.generated.ts +++ b/catalog/app/model/graphql/schema.generated.ts @@ -82,6 +82,41 @@ export default { ], interfaces: [], }, + { + kind: 'OBJECT', + name: 'AccessCountsGroup', + fields: [ + { + name: 'ext', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + args: [], + }, + { + name: 'counts', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'AccessCounts', + ofType: null, + }, + }, + args: [], + }, + ], + interfaces: [], + }, + { + kind: 'SCALAR', + name: 'String', + }, { kind: 'OBJECT', name: 'AdminMutations', @@ -208,10 +243,6 @@ export default { ], interfaces: [], }, - { - kind: 'SCALAR', - name: 'String', - }, { kind: 'OBJECT', name: 'AdminQueries', @@ -365,6 +396,52 @@ export default { }, ], }, + { + kind: 'OBJECT', + name: 'BucketAccessCounts', + fields: [ + { + name: 'byExt', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'AccessCountsGroup', + ofType: null, + }, + }, + }, + }, + args: [ + { + name: 'groups', + type: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + }, + ], + }, + { + name: 'combined', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'AccessCounts', + ofType: null, + }, + }, + args: [], + }, + ], + interfaces: [], + }, { kind: 'UNION', name: 'BucketAddResult', @@ -4188,6 +4265,81 @@ export default { }, args: [], }, + { + name: 'bucketAccessCounts', + type: { + kind: 'OBJECT', + name: 'BucketAccessCounts', + ofType: null, + }, + args: [ + { + name: 'bucket', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'window', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + }, + }, + ], + }, + { + name: 'objectAccessCounts', + type: { + kind: 'OBJECT', + name: 'AccessCounts', + ofType: null, + }, + args: [ + { + name: 'bucket', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'key', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + { + name: 'window', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Int', + ofType: null, + }, + }, + }, + ], + }, { name: 'admin', type: { diff --git a/catalog/app/model/graphql/types.generated.ts b/catalog/app/model/graphql/types.generated.ts index 8ad7b159639..fb5d1b2a862 100644 --- a/catalog/app/model/graphql/types.generated.ts +++ b/catalog/app/model/graphql/types.generated.ts @@ -36,6 +36,12 @@ export interface AccessCounts { readonly counts: ReadonlyArray } +export interface AccessCountsGroup { + readonly __typename: 'AccessCountsGroup' + readonly ext: Scalars['String'] + readonly counts: AccessCounts +} + export interface AdminMutations { readonly __typename: 'AdminMutations' readonly user: UserAdminMutations @@ -89,6 +95,16 @@ export type BrowsingSessionDisposeResult = Ok | OperationError export type BrowsingSessionRefreshResult = BrowsingSession | InvalidInput | OperationError +export interface BucketAccessCounts { + readonly __typename: 'BucketAccessCounts' + readonly byExt: ReadonlyArray + readonly combined: AccessCounts +} + +export interface BucketAccessCountsbyExtArgs { + groups: Maybe +} + export interface BucketAddInput { readonly name: Scalars['String'] readonly title: Scalars['String'] @@ -864,6 +880,8 @@ export interface Query { readonly searchMoreObjects: ObjectsSearchMoreResult readonly searchMorePackages: PackagesSearchMoreResult readonly subscription: SubscriptionState + readonly bucketAccessCounts: Maybe + readonly objectAccessCounts: Maybe readonly admin: AdminQueries readonly policies: ReadonlyArray readonly policy: Maybe @@ -910,6 +928,17 @@ export interface QuerysearchMorePackagesArgs { size?: Maybe } +export interface QuerybucketAccessCountsArgs { + bucket: Scalars['String'] + window: Scalars['Int'] +} + +export interface QueryobjectAccessCountsArgs { + bucket: Scalars['String'] + key: Scalars['String'] + window: Scalars['Int'] +} + export interface QuerypolicyArgs { id: Scalars['ID'] } diff --git a/catalog/app/utils/AWS/S3.js b/catalog/app/utils/AWS/S3.js index a51c29b5566..6b052a52927 100644 --- a/catalog/app/utils/AWS/S3.js +++ b/catalog/app/utils/AWS/S3.js @@ -43,44 +43,28 @@ function useSmartS3() { return useConstant(() => { class SmartS3 extends S3 { - getReqType(req) { + shouldSign(req) { const bucket = req.params.Bucket if (cfg.mode === 'LOCAL') { - return 'signed' + return true } - if (isAuthenticated()) { - if ( - // sign if operation is not bucket-specific - // (not sure if there are any such operations that can be used from the browser) - !bucket || - cfg.analyticsBucket === bucket || + if ( + isAuthenticated() && + // sign if operation is not bucket-specific + // (not sure if there are any such operations that can be used from the browser) + (!bucket || cfg.serviceBucket === bucket || statusReportsBucket === bucket || - (cfg.mode !== 'OPEN' && isInStack(bucket)) - ) { - return 'signed' - } - } else if (req.operation === 'selectObjectContent') { - return 'select' + (cfg.mode !== 'OPEN' && isInStack(bucket))) + ) { + return true } - return 'unsigned' - } - - populateURI(req) { - if (req.service.getReqType(req) === 'select') { - return - } - super.populateURI(req) + return false } customRequestHandler(req) { - const b = req.params.Bucket - const type = this.getReqType(req) - - if (b) { - const endpoint = new AWS.Endpoint( - type === 'select' ? `${cfg.apiGatewayEndpoint}/s3select/` : cfg.s3Proxy, - ) + if (req.params.Bucket) { + const endpoint = new AWS.Endpoint(cfg.s3Proxy) req.on('sign', () => { if (req.httpRequest[PRESIGN]) return @@ -96,10 +80,7 @@ function useSmartS3() { const basePath = endpoint.path.replace(/\/$/, '') req.httpRequest.endpoint = endpoint - req.httpRequest.path = - type === 'select' - ? `${basePath}${origPath}` - : `${basePath}/${origEndpoint.host}${origPath}` + req.httpRequest.path = `${basePath}/${origEndpoint.host}${origPath}` }) req.on( 'retry', @@ -138,9 +119,8 @@ function useSmartS3() { if (forceProxy) { req.httpRequest[FORCE_PROXY] = true } - const type = this.getReqType(req) - if (type !== 'signed') { + if (!this.shouldSign(req)) { req.toUnauthenticated() } diff --git a/catalog/app/utils/AWS/Signer.js b/catalog/app/utils/AWS/Signer.js index 0c0c24b1ac8..404fe0f4d73 100644 --- a/catalog/app/utils/AWS/Signer.js +++ b/catalog/app/utils/AWS/Signer.js @@ -25,7 +25,7 @@ export function useS3Signer({ urlExpiration: exp, forceProxy = false } = {}) { const statusReportsBucket = useStatusReportsBucket() const s3 = S3.use() const inStackOrSpecial = React.useCallback( - (b) => isInStack(b) || cfg.analyticsBucket === b || statusReportsBucket === b, + (b) => isInStack(b) || statusReportsBucket === b, [isInStack, statusReportsBucket], ) return React.useCallback( diff --git a/catalog/app/utils/GraphQL/Provider.tsx b/catalog/app/utils/GraphQL/Provider.tsx index 592b71e58e6..05c34cd7238 100644 --- a/catalog/app/utils/GraphQL/Provider.tsx +++ b/catalog/app/utils/GraphQL/Provider.tsx @@ -90,6 +90,8 @@ export default function GraphQLProvider({ children }: React.PropsWithChildren<{} keys: { AccessCountForDate: () => null, AccessCounts: () => null, + AccessCountsGroup: () => null, + BucketAccessCounts: () => null, BucketConfig: (b) => b.name as string, Canary: (c) => c.name as string, Collaborator: (c) => c.username as string, diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index 0bb997e7809..ea342cd5806 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -222,6 +222,16 @@ type AccessCounts { counts: [AccessCountForDate!]! } +type AccessCountsGroup { + ext: String! + counts: AccessCounts! +} + +type BucketAccessCounts { + byExt(groups: Int): [AccessCountsGroup!]! + combined: AccessCounts! +} + type PackageDir { path: String! metadata: JsonRecord @@ -556,6 +566,9 @@ type Query { searchMorePackages(after: String!, size: Int = 30): PackagesSearchMoreResult! subscription: SubscriptionState! + bucketAccessCounts(bucket: String!, window: Int!): BucketAccessCounts + objectAccessCounts(bucket: String!, key: String!, window: Int!): AccessCounts + admin: AdminQueries! @admin policies: [Policy!]! @admin From 2eb3cfc41f6050a158d3e1cd221b1bd304cd80c5 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 19 Nov 2024 14:39:14 +0100 Subject: [PATCH 9/9] Fix some doc URLs in catalog (#4205) Signed-off-by: dependabot[bot] Co-authored-by: Maksim Chervonnyi Co-authored-by: Alexei Mochalov Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: QuiltSimon <116831980+QuiltSimon@users.noreply.github.com> Co-authored-by: Dr. Ernie Prabhakar <19791+drernie@users.noreply.github.com> Co-authored-by: Dr. Ernie Prabhakar --- catalog/CHANGELOG.md | 1 + .../QuiltConfigEditor/BucketPreferences.tsx | 5 ++++- .../FileEditor/QuiltConfigEditor/Workflows.tsx | 2 +- catalog/app/containers/Admin/Status/Status.tsx | 5 ++++- .../containers/Admin/UsersAndRoles/SsoConfig.tsx | 5 ++++- catalog/app/containers/Bucket/CodeSamples/Dir.tsx | 4 ++-- catalog/app/containers/Bucket/CodeSamples/File.tsx | 2 +- .../app/containers/Bucket/CodeSamples/Package.tsx | 14 +++++++------- .../Bucket/PackageDialog/DialogError.tsx | 2 +- .../Bucket/PackageDialog/SelectWorkflow.tsx | 2 +- .../Bucket/Queries/Athena/Workgroups.tsx | 6 ++++-- catalog/app/containers/Bucket/Successors.tsx | 4 ++-- catalog/app/containers/Bucket/Summarize.tsx | 4 +++- catalog/app/containers/Bucket/errors.tsx | 4 ++-- .../containers/NavBar/Suggestions/Suggestions.tsx | 5 ++++- 15 files changed, 41 insertions(+), 24 deletions(-) diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md index 93888fa202c..ab6c3f62946 100644 --- a/catalog/CHANGELOG.md +++ b/catalog/CHANGELOG.md @@ -17,6 +17,7 @@ where verb is one of ## Changes +- [Fixed] Fix some doc URLs in catalog ([#4205](https://github.com/quiltdata/quilt/pull/4205)) - [Changed] S3 Select -> GQL API calls for getting access counts ([#4218](https://github.com/quiltdata/quilt/pull/4218)) - [Changed] Athena: improve loading state and errors visuals; fix minor bugs; alphabetize and persist selection in workgroups, catalog names and databases ([#4208](https://github.com/quiltdata/quilt/pull/4208)) - [Changed] Show stack release version in footer ([#4200](https://github.com/quiltdata/quilt/pull/4200)) diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/BucketPreferences.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/BucketPreferences.tsx index 67737f681f5..9abbd53a7a4 100644 --- a/catalog/app/components/FileEditor/QuiltConfigEditor/BucketPreferences.tsx +++ b/catalog/app/components/FileEditor/QuiltConfigEditor/BucketPreferences.tsx @@ -12,7 +12,10 @@ function Header() { return ( Configuration for Catalog UI: show and hide features, set default values. See{' '} - + the docs diff --git a/catalog/app/components/FileEditor/QuiltConfigEditor/Workflows.tsx b/catalog/app/components/FileEditor/QuiltConfigEditor/Workflows.tsx index 23175e61171..5288698cdeb 100644 --- a/catalog/app/components/FileEditor/QuiltConfigEditor/Workflows.tsx +++ b/catalog/app/components/FileEditor/QuiltConfigEditor/Workflows.tsx @@ -18,7 +18,7 @@ function Header() { return ( Configuration for data quality workflows. See{' '} - + the docs diff --git a/catalog/app/containers/Admin/Status/Status.tsx b/catalog/app/containers/Admin/Status/Status.tsx index 79cd6360655..c91d6b74d2c 100644 --- a/catalog/app/containers/Admin/Status/Status.tsx +++ b/catalog/app/containers/Admin/Status/Status.tsx @@ -54,7 +54,10 @@ export default function Status() { GxP and other compliance regimes. - + Learn more {' '} or contact sales. diff --git a/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx b/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx index b740034202e..26f292af45f 100644 --- a/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx +++ b/catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx @@ -121,7 +121,10 @@ function Form({ Learn more about{' '} - + SSO permissions mapping . diff --git a/catalog/app/containers/Bucket/CodeSamples/Dir.tsx b/catalog/app/containers/Bucket/CodeSamples/Dir.tsx index bfd77c042be..5b3491691d1 100644 --- a/catalog/app/containers/Bucket/CodeSamples/Dir.tsx +++ b/catalog/app/containers/Bucket/CodeSamples/Dir.tsx @@ -14,9 +14,9 @@ const TEMPLATES = { dedent` import quilt3 as q3 b = q3.Bucket("s3://${bucket}") - # List files [[${docs}/api-reference/bucket#bucket.ls]] + # List files [[${docs}/quilt-python-sdk-developers/api-reference/bucket#bucket.ls]] b.ls("${path}") - # Download [[${docs}/api-reference/bucket#bucket.fetch]] + # Download [[${docs}/quilt-python-sdk-developers/api-reference/bucket#bucket.fetch]] b.fetch("${path}", "./${dest}") `, CLI: (bucket: string, path: string, dest: string) => diff --git a/catalog/app/containers/Bucket/CodeSamples/File.tsx b/catalog/app/containers/Bucket/CodeSamples/File.tsx index f1eea186809..006609ad393 100644 --- a/catalog/app/containers/Bucket/CodeSamples/File.tsx +++ b/catalog/app/containers/Bucket/CodeSamples/File.tsx @@ -14,7 +14,7 @@ const TEMPLATES = { dedent` import quilt3 as q3 b = q3.Bucket("s3://${bucket}") - # Download [[${docs}/api-reference/bucket#bucket.fetch]] + # Download [[${docs}/quilt-python-sdk-developers/api-reference/bucket#bucket.fetch]] b.fetch("${path}", "./${basename(path)}") `, CLI: (bucket: string, path: string) => diff --git a/catalog/app/containers/Bucket/CodeSamples/Package.tsx b/catalog/app/containers/Bucket/CodeSamples/Package.tsx index 7dbce6e97ee..c2763b3d750 100644 --- a/catalog/app/containers/Bucket/CodeSamples/Package.tsx +++ b/catalog/app/containers/Bucket/CodeSamples/Package.tsx @@ -19,16 +19,16 @@ const TEMPLATES = { const hashPy = hashDisplay && `, top_hash="${hashDisplay}"` return dedent` import quilt3 as q3 - # Browse [[${docs}/api-reference/package#package.browse]] + # Browse [[${docs}/quilt-python-sdk-developers/api-reference/package#package.browse]] p = q3.Package.browse("${name}"${hashPy}, registry="s3://${bucket}") - # make changes to package adding individual files [[${docs}/api-reference/package#package.set]] + # make changes to package adding individual files [[${docs}/quilt-python-sdk-developers/api-reference/package#package.set]] p.set("data.csv", "data.csv") - # or whole directories [[${docs}/api-reference/package#package.set_dir]] + # or whole directories [[${docs}/quilt-python-sdk-developers/api-reference/package#package.set_dir]] p.set_dir("subdir", "subdir") - # and push changes [[${docs}/api-reference/package#package.push]] + # and push changes [[${docs}/quilt-python-sdk-developers/api-reference/package#package.push]] p.push("${name}", registry="s3://${bucket}", message="Hello World") - # Download (be mindful of large packages) [[${docs}/api-reference/package#package.push]] + # Download (be mindful of large packages) [[${docs}/quilt-python-sdk-developers/api-reference/package#package.install]] q3.Package.install("${name}"${pathPy}${hashPy}, registry="s3://${bucket}", dest=".") ` }, @@ -36,13 +36,13 @@ const TEMPLATES = { const pathCli = path && ` --path "${s3paths.ensureNoSlash(path)}"` const hashCli = hashDisplay && ` --top-hash ${hashDisplay}` return dedent` - # Download package [[${docs}/api-reference/cli#install]] + # Download package [[${docs}/quilt-python-sdk-developers/api-reference/cli#install]] quilt3 install "${name}"${pathCli}${hashCli} --registry s3://${bucket} --dest . ` }, CLI_UPLOAD: (bucket: string, name: string) => dedent` - # Upload package [[${docs}/api-reference/cli#push]] + # Upload package [[${docs}/quilt-python-sdk-developers/api-reference/cli#push]] echo "Hello World" > README.md quilt3 push "${name}" --registry s3://${bucket} --dir . `, diff --git a/catalog/app/containers/Bucket/PackageDialog/DialogError.tsx b/catalog/app/containers/Bucket/PackageDialog/DialogError.tsx index 291cd6c3a75..3e04966ab87 100644 --- a/catalog/app/containers/Bucket/PackageDialog/DialogError.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/DialogError.tsx @@ -42,7 +42,7 @@ const errorDisplay = R.cond([ Please fix the{' '} workflows config{' '} according to{' '} - + the documentation . diff --git a/catalog/app/containers/Bucket/PackageDialog/SelectWorkflow.tsx b/catalog/app/containers/Bucket/PackageDialog/SelectWorkflow.tsx index 55f14548fcb..d3a1bc8ca4b 100644 --- a/catalog/app/containers/Bucket/PackageDialog/SelectWorkflow.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/SelectWorkflow.tsx @@ -71,7 +71,7 @@ export default function SelectWorkflow({ {!!error && {error}} - + Learn about data quality workflows , or edit{' '} diff --git a/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx b/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx index b9713539063..f0387219d4d 100644 --- a/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx +++ b/catalog/app/containers/Bucket/Queries/Athena/Workgroups.tsx @@ -97,8 +97,10 @@ function WorkgroupsEmpty({ error }: WorkgroupsEmptyProps) { Check{' '} - Athena Queries docs on - setup and correct usage + + Athena Queries docs + {' '} + on setup and correct usage diff --git a/catalog/app/containers/Bucket/Successors.tsx b/catalog/app/containers/Bucket/Successors.tsx index 3a0e5449b34..5fc1a51cc40 100644 --- a/catalog/app/containers/Bucket/Successors.tsx +++ b/catalog/app/containers/Bucket/Successors.tsx @@ -28,7 +28,7 @@ function EmptySlot({ bucket }: EmptySlotProps) { Learn more @@ -52,7 +52,7 @@ function ErrorSlot({ error }: ErrorSlotProps) { {error instanceof ERRORS.WorkflowsConfigInvalid && ( Please fix the workflows config according to{' '} - + the documentation diff --git a/catalog/app/containers/Bucket/Summarize.tsx b/catalog/app/containers/Bucket/Summarize.tsx index b88512727dd..ebc2116b441 100644 --- a/catalog/app/containers/Bucket/Summarize.tsx +++ b/catalog/app/containers/Bucket/Summarize.tsx @@ -618,7 +618,9 @@ function SummaryFailed({ error }: SummaryFailedProps) { Check your quilt_summarize.json file for errors. See the{' '} - + summarize docs {' '} for more. diff --git a/catalog/app/containers/Bucket/errors.tsx b/catalog/app/containers/Bucket/errors.tsx index fd45d3399ca..5a91f621431 100644 --- a/catalog/app/containers/Bucket/errors.tsx +++ b/catalog/app/containers/Bucket/errors.tsx @@ -124,7 +124,7 @@ const defaultHandlers: ErrorHandler[] = [
Learn how to configure the bucket for Quilt @@ -167,7 +167,7 @@ const defaultHandlers: ErrorHandler[] = [
Learn about access control in Quilt diff --git a/catalog/app/containers/NavBar/Suggestions/Suggestions.tsx b/catalog/app/containers/NavBar/Suggestions/Suggestions.tsx index 4fa912fe30d..d459995f009 100644 --- a/catalog/app/containers/NavBar/Suggestions/Suggestions.tsx +++ b/catalog/app/containers/NavBar/Suggestions/Suggestions.tsx @@ -61,7 +61,10 @@ function SuggestionsList({ items, selected }: SuggestionsProps) { ))}
Learn the{' '} - + advanced search syntax {' '} for query string queries in ElasticSearch {ES_V}.