From 773e91ef397d8527def5ef19388817250d292f14 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 8 Nov 2024 15:37:23 +0100 Subject: [PATCH 01/32] gql schema: add access counts queries for buckets and objects --- catalog/app/model/graphql/schema.generated.ts | 154 +++++++++++++++++- catalog/app/model/graphql/types.generated.ts | 29 ++++ shared/graphql/schema.graphql | 14 ++ 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index ba8ed87e3fd..79e0ad12f6c 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,46 @@ export default { }, ], }, + { + kind: 'OBJECT', + name: 'BucketAccessCounts', + fields: [ + { + name: 'byExt', + type: { + 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: 'OBJECT', + name: 'AccessCounts', + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, { kind: 'UNION', name: 'BucketAddResult', @@ -4188,6 +4259,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..7b05c04d954 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: Maybe> + readonly combined: Maybe +} + +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/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index 0bb997e7809..b79689cda9f 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -215,6 +215,7 @@ type User { type AccessCountForDate { date: Datetime! value: Int! + # sum: Int! # running sum } type AccessCounts { @@ -222,6 +223,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 +567,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 ca318e830ac268db7d5f18142b5c569419ec0724 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 8 Nov 2024 15:43:04 +0100 Subject: [PATCH 02/32] overview: js -> tsx --- catalog/app/containers/Bucket/{Overview.js => Overview.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename catalog/app/containers/Bucket/{Overview.js => Overview.tsx} (100%) diff --git a/catalog/app/containers/Bucket/Overview.js b/catalog/app/containers/Bucket/Overview.tsx similarity index 100% rename from catalog/app/containers/Bucket/Overview.js rename to catalog/app/containers/Bucket/Overview.tsx From a2a7f93c663919b8191ff8d90dee748fbb67cac4 Mon Sep 17 00:00:00 2001 From: "nl_0 (aider)" Date: Fri, 8 Nov 2024 15:47:50 +0100 Subject: [PATCH 03/32] Overview: TSify --- catalog/app/containers/Bucket/Overview.tsx | 235 ++++++++++++++++---- catalog/app/containers/Bucket/Summarize.tsx | 2 +- 2 files changed, 190 insertions(+), 47 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview.tsx b/catalog/app/containers/Bucket/Overview.tsx index 3acef4e2ccb..44bed3b43e4 100644 --- a/catalog/app/containers/Bucket/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview.tsx @@ -12,6 +12,7 @@ import Skeleton from 'components/Skeleton' import StackedAreaChart from 'components/StackedAreaChart' import cfg from 'constants/config' import * as authSelectors from 'containers/Auth/selectors' +import type * as Model from 'model' import * as APIConnector from 'utils/APIConnector' import * as AWS from 'utils/AWS' import AsyncResult from 'utils/AsyncResult' @@ -22,6 +23,7 @@ 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 useConst from 'utils/useConstant' import * as Gallery from './Gallery' import * as Summarize from './Summarize' @@ -30,6 +32,18 @@ import BUCKET_CONFIG_QUERY from './OverviewBucketConfig.generated' 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 @@ -49,10 +63,14 @@ const COLOR_MAP = [ '#94ad6b', ] -function mkKeyedPool(pool) { - const map = {} +interface ColorPool { + get: (key: string) => string +} + +function mkKeyedPool(pool: string[]): ColorPool { + const map: Record = {} let poolIdx = 0 - const get = (key) => { + const get = (key: string): string => { if (!(key in map)) { // eslint-disable-next-line no-plusplus map[key] = pool[poolIdx++ % pool.length] @@ -62,12 +80,6 @@ function mkKeyedPool(pool) { 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', @@ -146,18 +158,23 @@ const useObjectsByExtStyles = M.makeStyles((t) => ({ }, })) -function ObjectsByExt({ data, colorPool, ...props }) { +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) => { + 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) => Math.log(x + 1) / max + const scale = (x: number) => Math.log(x + 1) / max return capped.map(({ ext, bytes, objects }, i) => { const color = colorPool.get(ext) return ( @@ -189,7 +206,7 @@ function ObjectsByExt({ data, colorPool, ...props }) { ) }) }, - _: (r) => ( + _: (r: $TSFixMe) => ( <> {R.times( (i) => ( @@ -227,9 +244,15 @@ const skelData = R.times( const skelColors = [ [M.colors.grey[300], M.colors.grey[100]], [M.colors.grey[400], M.colors.grey[200]], -] +] as const -const mkPulsingGradient = ({ colors: [c1, c2], animate = false }) => +const mkPulsingGradient = ({ + colors: [c1, c2], + animate = false, +}: { + colors: readonly [string, string] + animate?: boolean +}) => SVG.Paint.Server( @@ -245,13 +268,21 @@ const mkPulsingGradient = ({ colors: [c1, c2], animate = false }) => , ) +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( () => R.times((i) => skelData[i % skelData.length], lines), [lines], @@ -266,6 +297,7 @@ function ChartSkel({ ) return ( + {/* @ts-expect-error */} void + bucket: string + rawData?: string +} + +function DownloadsRange({ value, onChange, bucket, rawData }: DownloadsRangeProps) { const [anchor, setAnchor] = React.useState(null) const open = React.useCallback( @@ -348,7 +387,7 @@ const useStatsTipStyles = M.makeStyles((t) => ({ root: { background: fade(t.palette.grey[700], 0.9), color: t.palette.common.white, - padding: [[6, 8]], + padding: '6px 8px', }, head: { display: 'flex', @@ -389,7 +428,27 @@ const useStatsTipStyles = M.makeStyles((t) => ({ }, })) -function StatsTip({ stats, colorPool, className, ...props }) { +interface StatsTipProps { + stats: { + date: Date + combined: { + sum: number + value: number + } + byExt: Array<{ + ext: string + sum: number + value: number + }> + highlighted?: { + ext: string + } + } + colorPool: ColorPool + className?: string +} + +function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { const classes = useStatsTipStyles() return ( @@ -422,14 +481,15 @@ function StatsTip({ stats, colorPool, className, ...props }) { ) } -const Transition = ({ TransitionComponent = M.Grow, children, ...props }) => { - const contentsRef = React.useRef(null) +interface TransitionProps { + children: () => JSX.Element + in?: boolean +} + +const Transition = ({ children, ...props }: TransitionProps) => { + const contentsRef = React.useRef(null) if (props.in) contentsRef.current = children() - return ( - contentsRef.current && ( - {contentsRef.current} - ) - ) + return contentsRef.current && {contentsRef.current} } // use the same height as the bar chart: 20px per bar with 2px margin @@ -517,15 +577,47 @@ const useDownloadsStyles = M.makeStyles((t) => ({ }, })) -function Downloads({ bucket, colorPool, ...props }) { +interface DownloadsProps extends M.BoxProps { + bucket: string + colorPool: ColorPool +} + +interface Counts { + date: Date + sum: number + value: number +} + +interface BucketAccessCounts { + byExt: Array<{ + ext: string + counts: Counts[] + }> + byExtCollapsed: Array<{ + ext: string + counts: Counts[] + total: number + }> + combined: { + counts: Counts[] + total: number + } +} + +interface Cursor { + i: number | null + j: number +} + +function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { 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) => { + const [cursor, setCursor] = React.useState(null) + const cursorStats = (counts: BucketAccessCounts) => { if (!cursor) return null const { date, ...combined } = counts.combined.counts[cursor.j] const byExt = counts.byExtCollapsed.map((e) => ({ @@ -538,7 +630,7 @@ function Downloads({ bucket, colorPool, ...props }) { } const mkRawData = AsyncResult.case({ - Ok: (data) => `data:application/json,${JSON.stringify(data)}`, + Ok: (data: $TSFixMe) => `data:application/json,${JSON.stringify(data)}`, _: () => null, }) @@ -551,8 +643,9 @@ function Downloads({ bucket, colorPool, ...props }) { } return ( + // @ts-expect-error - {(data) => ( + {(data: $TSFixMe) => (
{AsyncResult.case( { - Ok: (counts) => { + Ok: (counts: BucketAccessCounts) => { const stats = cursorStats(counts) - const hl = stats && stats.highlighted + const hl = stats?.highlighted const ext = hl ? hl.ext || 'other' : 'total' const total = hl ? hl.total : counts.combined.total if (!counts.byExtCollapsed.length) return 'Downloads' @@ -586,7 +679,7 @@ function Downloads({ bucket, colorPool, ...props }) {
{AsyncResult.case( { - Ok: (counts) => { + Ok: (counts: BucketAccessCounts) => { if (!counts.byExtCollapsed.length) { return ( @@ -598,6 +691,7 @@ function Downloads({ bucket, colorPool, ...props }) { const stats = cursorStats(counts) return ( <> + {/* @ts-expect-error */} e.counts.map((i) => Math.log(i.sum + 1)), @@ -616,6 +710,7 @@ function Downloads({ bucket, colorPool, ...props }) { {() => ( {() => ( ({ }, })) -function StatDisplay({ value, label, format, fallback }) { +interface StatsDisplayProps { + value: $TSFixMe // AsyncResult + label?: string + format?: (v: any) => any + fallback?: (v: any) => any +} + +function StatDisplay({ value, label, format, fallback }: StatsDisplayProps) { const classes = useStatDisplayStyles() return R.pipe( AsyncResult.case({ @@ -705,7 +808,7 @@ function StatDisplay({ value, label, format, fallback }) { _: R.identity, }), AsyncResult.case({ - Ok: (v) => + Ok: (v: $TSFixMe) => v != null && ( {v} @@ -718,7 +821,8 @@ function StatDisplay({ value, label, format, fallback }) {
), }), - )(value) + // @ts-expect-error + )(value) as JSX.Element } const useHeadStyles = M.makeStyles((t) => ({ @@ -757,7 +861,14 @@ const useHeadStyles = M.makeStyles((t) => ({ }, })) -function Head({ s3, overviewUrl, bucket, description }) { +interface HeadProps { + s3: $TSFixMe // AWS.S3 + bucket: string + overviewUrl: string | null | undefined + description: string | null | undefined +} + +function Head({ s3, overviewUrl, bucket, description }: HeadProps) { const classes = useHeadStyles() const req = APIConnector.use() const isRODA = !!overviewUrl && overviewUrl.includes(`/${RODA_BUCKET}/`) @@ -850,11 +961,23 @@ function Head({ s3, overviewUrl, bucket, description }) { ) } -function Readmes({ s3, overviewUrl, bucket }) { +interface BucketReadmes { + forced?: Model.S3.S3ObjectLocation + discovered: Model.S3.S3ObjectLocation[] +} + +interface ReadmesProps { + s3: $TSFixMe // AWS.S3 + bucket: string + overviewUrl: string | undefined | null +} + +function Readmes({ s3, overviewUrl, bucket }: ReadmesProps) { return ( + // @ts-expect-error {AsyncResult.case({ - Ok: (rs) => + Ok: (rs: BucketReadmes) => (rs.discovered.length > 0 || !!rs.forced) && ( <> {!!rs.forced && ( @@ -866,6 +989,7 @@ function Readmes({ s3, overviewUrl, bucket }) { /> )} {rs.discovered.map((h) => ( + // @ts-expect-error {AsyncResult.case({ - Ok: (images) => (images.length ? : null), + 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) => + Ok: (h?: Model.S3.S3ObjectLocation) => (!h || galleryPrefs.summarize) && ( ), @@ -917,7 +1060,7 @@ function ThumbnailsWrapper({ } export default function Overview() { - const { bucket } = useParams() + const { bucket } = useParams<{ bucket: string }>() const s3 = AWS.S3.use() const { bucketConfig } = useQueryS(BUCKET_CONFIG_QUERY, { bucket }) @@ -953,7 +1096,7 @@ export default function Overview() { /> ), Pending: () => , - Init: R.F, + Init: () => null, }, prefs, )} diff --git a/catalog/app/containers/Bucket/Summarize.tsx b/catalog/app/containers/Bucket/Summarize.tsx index e644215263c..f0f7e6150f8 100644 --- a/catalog/app/containers/Bucket/Summarize.tsx +++ b/catalog/app/containers/Bucket/Summarize.tsx @@ -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) { From 5f07f8fa1a86a40ab9f2cfe16105e67a870d08ff Mon Sep 17 00:00:00 2001 From: nl_0 Date: Sat, 9 Nov 2024 08:28:43 +0100 Subject: [PATCH 04/32] refactor: update GraphQL import to use namespace import in Overview --- catalog/app/containers/Bucket/Overview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview.tsx b/catalog/app/containers/Bucket/Overview.tsx index 44bed3b43e4..acb50d7419c 100644 --- a/catalog/app/containers/Bucket/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview.tsx @@ -18,7 +18,7 @@ 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 GQL from 'utils/GraphQL' import * as LinkedData from 'utils/LinkedData' import * as NamedRoutes from 'utils/NamedRoutes' import * as SVG from 'utils/SVG' @@ -1063,7 +1063,7 @@ export default function Overview() { const { bucket } = useParams<{ bucket: string }>() const s3 = AWS.S3.use() - const { bucketConfig } = useQueryS(BUCKET_CONFIG_QUERY, { bucket }) + const { bucketConfig } = GQL.useQueryS(BUCKET_CONFIG_QUERY, { bucket }) const inStack = !!bucketConfig const overviewUrl = bucketConfig?.overviewUrl const description = bucketConfig?.description From 367569df12803f7295f6f354d02a117b7bd63bf5 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Sat, 9 Nov 2024 08:34:47 +0100 Subject: [PATCH 05/32] move stuff around --- .../Bucket/{ => Overview}/Overview-bg.jpg | Bin .../Bucket/{ => Overview}/Overview.tsx | 9 +++++---- .../gql/BucketConfig.generated.ts} | 16 ++++++++-------- .../gql/BucketConfig.graphql} | 0 .../app/containers/Bucket/Overview/index.tsx | 1 + 5 files changed, 14 insertions(+), 12 deletions(-) rename catalog/app/containers/Bucket/{ => Overview}/Overview-bg.jpg (100%) rename catalog/app/containers/Bucket/{ => Overview}/Overview.tsx (99%) 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/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.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx similarity index 99% rename from catalog/app/containers/Bucket/Overview.tsx rename to catalog/app/containers/Bucket/Overview/Overview.tsx index acb50d7419c..dce676b0138 100644 --- a/catalog/app/containers/Bucket/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -25,10 +25,11 @@ import * as SVG from 'utils/SVG' import { readableBytes, readableQuantity, formatQuantity } from 'utils/string' import useConst from 'utils/useConstant' -import * as Gallery from './Gallery' -import * as Summarize from './Summarize' -import * as requests from './requests' -import BUCKET_CONFIG_QUERY from './OverviewBucketConfig.generated' +import * as Gallery from '../Gallery' +import * as Summarize from '../Summarize' +import * as requests from '../requests' + +import BUCKET_CONFIG_QUERY from './gql/BucketConfig.generated' import bg from './Overview-bg.jpg' 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' From ab067065247f0aa5df77e432a5a5c250dde5a948 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Sat, 9 Nov 2024 08:55:01 +0100 Subject: [PATCH 06/32] feat: add GraphQL query for bucket access counts --- .../containers/Bucket/Overview/Overview.tsx | 5 + .../gql/BucketAccessCounts.generated.ts | 211 ++++++++++++++++++ .../Overview/gql/BucketAccessCounts.graphql | 27 +++ 3 files changed, 243 insertions(+) create mode 100644 catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts create mode 100644 catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.graphql diff --git a/catalog/app/containers/Bucket/Overview/Overview.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx index dce676b0138..17d7cd84a46 100644 --- a/catalog/app/containers/Bucket/Overview/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -30,6 +30,7 @@ import * as Summarize from '../Summarize' import * as requests from '../requests' import BUCKET_CONFIG_QUERY from './gql/BucketConfig.generated' +import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' import bg from './Overview-bg.jpg' @@ -635,6 +636,10 @@ function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { _: () => null, }) + const countsGql = GQL.useQuery(BUCKET_ACCESS_COUNTS_QUERY, { bucket, window }) + + console.log('countsGql', countsGql) + if (!cfg.analyticsBucket) { return ( 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..2be2fec26e9 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts @@ -0,0 +1,211 @@ +/* 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: Types.Maybe< + ReadonlyArray< + { readonly __typename: 'AccessCountsGroup' } & Pick< + Types.AccessCountsGroup, + 'ext' + > & { + readonly counts: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } + > + > + readonly byExtCollapsed: Types.Maybe< + ReadonlyArray< + { readonly __typename: 'AccessCountsGroup' } & Pick< + Types.AccessCountsGroup, + 'ext' + > & { + readonly counts: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } + > + > + readonly combined: Types.Maybe< + { 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 + } + } +} From a524b0dace27ab01c4e0a3de41cd3bb2680d637e Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 11 Nov 2024 17:16:31 +0100 Subject: [PATCH 07/32] refactor: migrate bucket downloads chart to use GraphQL data --- .../containers/Bucket/Overview/Overview.tsx | 258 ++++++++++-------- shared/graphql/schema.graphql | 2 + 2 files changed, 142 insertions(+), 118 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Overview.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx index 17d7cd84a46..034ed6717fe 100644 --- a/catalog/app/containers/Bucket/Overview/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -1,5 +1,6 @@ import cx from 'classnames' import * as dateFns from 'date-fns' +import * as Eff from 'effect' import * as R from 'ramda' import * as React from 'react' import { Link as RRLink, useParams } from 'react-router-dom' @@ -326,10 +327,10 @@ interface DownloadsRangeProps { value: number onChange: (value: number) => void bucket: string - rawData?: string + data: BucketAccessCountsGQL | null } -function DownloadsRange({ value, onChange, bucket, rawData }: DownloadsRangeProps) { +function DownloadsRange({ value, onChange, bucket, data }: DownloadsRangeProps) { const [anchor, setAnchor] = React.useState(null) const open = React.useCallback( @@ -353,6 +354,12 @@ function DownloadsRange({ value, onChange, bucket, rawData }: DownloadsRangeProp const { label } = ANALYTICS_WINDOW_OPTIONS.find((o) => o.value === value) || {} + const jsonData = React.useMemo( + // TODO: remove `__typename`s + () => data && `data:application/json,${JSON.stringify(data)}`, + [data], + ) + return ( <> @@ -374,9 +381,9 @@ function DownloadsRange({ value, onChange, bucket, rawData }: DownloadsRangeProp Download to file @@ -585,25 +592,25 @@ interface DownloadsProps extends M.BoxProps { } interface Counts { - date: Date - sum: number - value: number + total: number + counts: readonly { + date: Date + // TODO: compute sum + // sum: number + value: number + }[] } interface BucketAccessCounts { - byExt: Array<{ + byExt: readonly { ext: string - counts: Counts[] - }> - byExtCollapsed: Array<{ + counts: Counts + }[] + byExtCollapsed: readonly { ext: string - counts: Counts[] - total: number - }> - combined: { - counts: Counts[] - total: number - } + counts: Counts + }[] + combined: Counts } interface Cursor { @@ -611,6 +618,10 @@ interface Cursor { j: number } +type BucketAccessCountsGQL = NonNullable< + GQL.DataForDoc['bucketAccessCounts'] +> + function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { const s3 = AWS.S3.use() const today = React.useMemo(() => new Date(), []) @@ -624,21 +635,42 @@ function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { const { date, ...combined } = counts.combined.counts[cursor.j] const byExt = counts.byExtCollapsed.map((e) => ({ ext: e.ext, - ...e.counts[cursor.j], + ...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 mkRawData = AsyncResult.case({ - Ok: (data: $TSFixMe) => `data:application/json,${JSON.stringify(data)}`, - _: () => null, + const countsGql = GQL.useQuery( + BUCKET_ACCESS_COUNTS_QUERY, + { bucket, window }, + { pause: !cfg.analyticsBucket }, + ) + + const dataGql = GQL.fold(countsGql, { + data: (data) => data.bucketAccessCounts, + fetching: () => null, + error: () => null, + }) + + const dataO = GQL.fold(countsGql, { + data: ({ bucketAccessCounts: counts }) => { + if (!counts) return Eff.Option.none() + const { combined, byExtCollapsed, byExt } = counts + if (!combined || !byExtCollapsed || !byExt) return Eff.Option.none() + if (!byExtCollapsed.length) return Eff.Option.none() + return Eff.Option.some({ combined, byExtCollapsed, byExt }) + }, + fetching: () => Eff.Option.none(), + error: () => Eff.Option.none(), }) - const countsGql = GQL.useQuery(BUCKET_ACCESS_COUNTS_QUERY, { bucket, window }) + // console.log('countsGql', countsGql) - console.log('countsGql', countsGql) + const countsLegacy = useData(requests.bucketAccessCounts, { s3, bucket, today, window }) + const data = countsLegacy.result + console.log('countsLegacy', data) if (!cfg.analyticsBucket) { return ( @@ -649,101 +681,91 @@ function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { } return ( - // @ts-expect-error - - {(data: $TSFixMe) => ( - -
- -
-
- {AsyncResult.case( - { - Ok: (counts: BucketAccessCounts) => { - const stats = cursorStats(counts) - const hl = 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: BucketAccessCounts) => { - if (!counts.byExtCollapsed.length) { - return ( - -
No Data
-
- ) - } + +
+ +
+
+ {Eff.Option.match(dataO, { + onSome: (counts) => { + const stats = cursorStats(counts) + 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(dataO, { + onSome: (counts) => { + if (!counts.byExtCollapsed.length) { + return ( + +
No Data
+
+ ) + } - const stats = cursorStats(counts) - return ( - <> - {/* @ts-expect-error */} - - 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 stats = cursorStats(counts) + return ( + <> + {/* @ts-expect-error */} + + // FIXME + // e.counts.counts.map((i) => Math.log(i.sum + 1)), + e.counts.counts.map(() => Math.log(10)), + )} + 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} + /> + + {() => ( + + )} + + + {() => ( + + )} + + + ) + }, + onNone: () => , + })} +
+
) } diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index b79689cda9f..501876fa3a2 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -229,7 +229,9 @@ type AccessCountsGroup { } type BucketAccessCounts { + # XXX: should be required? byExt(groups: Int): [AccessCountsGroup!] + # XXX: should be required? combined: AccessCounts } From e48e241ba6225e4ddaeef58f7f8d4e253b82b9e5 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Tue, 12 Nov 2024 11:41:05 +0100 Subject: [PATCH 08/32] adjust gql schema --- .../gql/BucketAccessCounts.generated.ts | 46 +++++++++---------- catalog/app/model/graphql/schema.generated.ts | 22 +++++---- catalog/app/model/graphql/types.generated.ts | 4 +- shared/graphql/schema.graphql | 7 +-- 4 files changed, 39 insertions(+), 40 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts index 2be2fec26e9..f7a763654e1 100644 --- a/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts +++ b/catalog/app/containers/Bucket/Overview/gql/BucketAccessCounts.generated.ts @@ -24,33 +24,29 @@ export type containers_Bucket_Overview_gql_BucketAccessCountsQuery = { } & { readonly bucketAccessCounts: Types.Maybe< { readonly __typename: 'BucketAccessCounts' } & { - readonly byExt: Types.Maybe< - ReadonlyArray< - { readonly __typename: 'AccessCountsGroup' } & Pick< - Types.AccessCountsGroup, - 'ext' - > & { - readonly counts: { - readonly __typename: 'AccessCounts' - } & AccessCountsSelectionFragment - } - > + readonly byExt: ReadonlyArray< + { readonly __typename: 'AccessCountsGroup' } & Pick< + Types.AccessCountsGroup, + 'ext' + > & { + readonly counts: { + readonly __typename: 'AccessCounts' + } & AccessCountsSelectionFragment + } > - readonly byExtCollapsed: Types.Maybe< - ReadonlyArray< - { readonly __typename: 'AccessCountsGroup' } & Pick< - Types.AccessCountsGroup, - 'ext' - > & { - readonly counts: { - readonly __typename: 'AccessCounts' - } & AccessCountsSelectionFragment - } - > - > - readonly combined: Types.Maybe< - { 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 } > } diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index 79e0ad12f6c..be04791e97a 100644 --- a/catalog/app/model/graphql/schema.generated.ts +++ b/catalog/app/model/graphql/schema.generated.ts @@ -403,13 +403,16 @@ export default { { name: 'byExt', type: { - kind: 'LIST', + kind: 'NON_NULL', ofType: { - kind: 'NON_NULL', + kind: 'LIST', ofType: { - kind: 'OBJECT', - name: 'AccessCountsGroup', - ofType: null, + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'AccessCountsGroup', + ofType: null, + }, }, }, }, @@ -427,9 +430,12 @@ export default { { name: 'combined', type: { - kind: 'OBJECT', - name: 'AccessCounts', - ofType: null, + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'AccessCounts', + ofType: null, + }, }, args: [], }, diff --git a/catalog/app/model/graphql/types.generated.ts b/catalog/app/model/graphql/types.generated.ts index 7b05c04d954..fb5d1b2a862 100644 --- a/catalog/app/model/graphql/types.generated.ts +++ b/catalog/app/model/graphql/types.generated.ts @@ -97,8 +97,8 @@ export type BrowsingSessionRefreshResult = BrowsingSession | InvalidInput | Oper export interface BucketAccessCounts { readonly __typename: 'BucketAccessCounts' - readonly byExt: Maybe> - readonly combined: Maybe + readonly byExt: ReadonlyArray + readonly combined: AccessCounts } export interface BucketAccessCountsbyExtArgs { diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index 501876fa3a2..ea342cd5806 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -215,7 +215,6 @@ type User { type AccessCountForDate { date: Datetime! value: Int! - # sum: Int! # running sum } type AccessCounts { @@ -229,10 +228,8 @@ type AccessCountsGroup { } type BucketAccessCounts { - # XXX: should be required? - byExt(groups: Int): [AccessCountsGroup!] - # XXX: should be required? - combined: AccessCounts + byExt(groups: Int): [AccessCountsGroup!]! + combined: AccessCounts! } type PackageDir { From 7b2243a82c0ca4593df829bbeefa6126ab21b985 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Tue, 12 Nov 2024 14:07:34 +0100 Subject: [PATCH 09/32] gql: keys for newly added types --- catalog/app/utils/GraphQL/Provider.tsx | 2 ++ 1 file changed, 2 insertions(+) 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, From 1bf1f4f77481001290077e2bf7bffec95cf44f19 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 13 Nov 2024 11:13:39 +0100 Subject: [PATCH 10/32] gql: effect interop --- catalog/app/utils/GraphQL/wrappers.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/catalog/app/utils/GraphQL/wrappers.ts b/catalog/app/utils/GraphQL/wrappers.ts index d445be94383..22c8d7aed8a 100644 --- a/catalog/app/utils/GraphQL/wrappers.ts +++ b/catalog/app/utils/GraphQL/wrappers.ts @@ -1,3 +1,4 @@ +import * as Eff from 'effect' import * as R from 'ramda' import * as React from 'react' import * as urql from 'urql' @@ -130,6 +131,15 @@ export const foldC = (result: ResultForData): OnData | OnFetching | OnError => fold(result, opts) +export const getDataOption = ( + result: ResultForData, +): Eff.Option.Option => + fold(result, { + data: Eff.Option.some, + fetching: Eff.Option.none, + error: Eff.Option.none, + }) + export type DataForDoc> = Doc extends urql.TypedDocumentNode ? Data : never From 81b3f254d78b065097d9b707c6c6c957b2da9804 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 13 Nov 2024 11:37:48 +0100 Subject: [PATCH 11/32] move Header out --- .../app/containers/Bucket/Overview/Header.tsx | 1045 +++++++++++++++++ .../containers/Bucket/Overview/Overview.tsx | 983 +--------------- 2 files changed, 1052 insertions(+), 976 deletions(-) create mode 100644 catalog/app/containers/Bucket/Overview/Header.tsx diff --git a/catalog/app/containers/Bucket/Overview/Header.tsx b/catalog/app/containers/Bucket/Overview/Header.tsx new file mode 100644 index 00000000000..f80dd042c55 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Header.tsx @@ -0,0 +1,1045 @@ +import type AWSSDK from 'aws-sdk' +import cx from 'classnames' +import * as dateFns from 'date-fns' +import * as Eff from 'effect' +import * as R from 'ramda' +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 { 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 AsyncResult from 'utils/AsyncResult' +import { useData } from 'utils/Data' +import * as GQL from 'utils/GraphQL' +import * as NamedRoutes from 'utils/NamedRoutes' +import * as SVG from 'utils/SVG' +import { readableBytes, readableQuantity, formatQuantity } from 'utils/string' +import useConst from 'utils/useConstant' + +import * as requests from '../requests' + +import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' + +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', +] + +interface ColorPool { + get: (key: string) => string +} + +function mkKeyedPool(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 } +} + +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) => ( + <> + {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]], +] as const + +const mkPulsingGradient = ({ + colors: [c1, c2], + animate = false, +}: { + colors: 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( + () => 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 ( + + {/* @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: BucketAccessCountsGQL | null +} + +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( + // TODO: remove `__typename`s + () => data && `data:application/json,${JSON.stringify(data)}`, + [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 CursorStats { + date: Date + combined: { + sum: number + value: number + } + byExt: { + ext: string + sum: number + value: number + date: Date + }[] + highlighted: { + ext: string + counts: AccessCountsWithSum + } | null + firstHalf: boolean +} + +interface StatsTipProps { + stats: CursorStats + colorPool: ColorPool + className?: string +} + +function assertNonNull(x: T): NonNullable { + if (x == null) throw new Error('Unexpected null') + return x as NonNullable +} + +function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { + 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)}) +
+ + ) + })} +
+ + ) +} + +interface TransitionProps { + children: () => JSX.Element + in?: boolean +} + +const Transition = ({ children, ...props }: TransitionProps) => { + 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%', + }, +})) + +interface DownloadsProps extends M.BoxProps { + bucket: string + colorPool: ColorPool +} + +type BucketAccessCountsGQL = NonNullable< + GQL.DataForDoc['bucketAccessCounts'] +> + +type AccessCountsGQL = BucketAccessCountsGQL['combined'] +type AccessCountForDate = Omit + +interface AccessCountForDateWithSum extends Omit { + sum: number +} + +interface AccessCountsWithSum { + total: number + counts: readonly AccessCountForDateWithSum[] +} + +// interface Counts { +// total: number +// counts: readonly AccessCountForDate[] +// } + +// interface CountsWithSum extends Counts { +// counts: readonly AccessCountForDateWithSum[] +// } + +// interface BucketAccessCounts { +// byExt: readonly { +// ext: string +// counts: Counts +// }[] +// byExtCollapsed: readonly { +// ext: string +// counts: Counts +// }[] +// combined: Counts +// } + +// interface BucketAccessCountsWithSum { +// byExt: readonly { +// ext: string +// counts: CountsWithSum +// }[] +// byExtCollapsed: readonly { +// ext: string +// counts: CountsWithSum +// }[] +// combined: CountsWithSum +// } + +const computeSum = ( + counts: readonly AccessCountForDate[], +): readonly AccessCountForDateWithSum[] => + Eff.Array.mapAccum(counts, 0, (acc, { value, date }) => [ + acc + value, + // const sum = acc.total + value + { + value, + date, + sum: acc + value, + }, + ])[1] + +interface Cursor { + // XXX: rename to row/col? date/band? + i: number | null + j: number +} + +function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { + 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 countsGql = GQL.useQuery( + BUCKET_ACCESS_COUNTS_QUERY, + { bucket, window }, + { pause: !cfg.analyticsBucket }, + ) + + const dataGql = GQL.fold(countsGql, { + data: (d) => d.bucketAccessCounts, + fetching: () => null, + error: () => null, + }) + + const dataO = React.useMemo( + () => + Eff.pipe( + countsGql, + GQL.getDataOption, + Eff.Option.flatMap((d) => Eff.Option.fromNullable(d.bucketAccessCounts)), + // TODO: clean typename + Eff.Option.map(({ byExt, byExtCollapsed, combined }) => ({ + byExt: Eff.Array.map(byExt, (e) => + Eff.Struct.evolve(e, { + counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), + }), + ), + byExtCollapsed: Eff.Array.map(byExtCollapsed, (e) => + Eff.Struct.evolve(e, { + counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), + }), + ), + combined: Eff.Struct.evolve(combined, { counts: computeSum }), + })), + ), + [countsGql], + ) + + const computed = React.useMemo( + () => + Eff.pipe( + dataO, + Eff.Option.map((counts) => { + let cursorStats: CursorStats | null = null + if (cursor) { + 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 + cursorStats = { date, combined, byExt, highlighted, firstHalf } + } + return { counts, cursorStats } + }), + ), + [dataO, cursor], + ) + + if (!cfg.analyticsBucket) { + return ( + +
Requires CloudTrail
+
+ ) + } + + return ( + +
+ +
+
+ {Eff.Option.match(computed, { + onSome: ({ counts, cursorStats: stats }) => { + // TODO: use flatmap or smth + 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(computed, { + onSome: ({ counts, cursorStats: stats }) => { + if (!counts.byExtCollapsed.length) { + return ( + +
No Data
+
+ ) + } + + return ( + <> + {/* @ts-expect-error */} + + // FIXME + e.counts.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} + /> + + {() => ( + + )} + + + {() => ( + + )} + + + ) + }, + onNone: () => , + })} +
+
+ ) +} + +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 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: $TSFixMe) => + v != null && ( + + {v} + {!!label && {label}} + + ), + _: () => ( +
+ +
+ ), + }), + // @ts-expect-error + )(value) as JSX.Element +} + +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(() => 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 + + + )} + + + + + + + + + + + + ) +} diff --git a/catalog/app/containers/Bucket/Overview/Overview.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx index 034ed6717fe..beff7220020 100644 --- a/catalog/app/containers/Bucket/Overview/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -1,993 +1,24 @@ -import cx from 'classnames' -import * as dateFns from 'date-fns' -import * as Eff from 'effect' -import * as R from 'ramda' +import type AWSSDK from 'aws-sdk' import * as React from 'react' -import { Link as RRLink, useParams } from 'react-router-dom' -import * as redux from 'react-redux' +import { useParams } from 'react-router-dom' 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 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, { useData } from 'utils/Data' +import Data from 'utils/Data' import * as GQL 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 useConst from 'utils/useConstant' 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' -import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' - -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', -] - -interface ColorPool { - get: (key: string) => string -} - -function mkKeyedPool(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 } -} - -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) => ( - <> - {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]], -] as const - -const mkPulsingGradient = ({ - colors: [c1, c2], - animate = false, -}: { - colors: 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( - () => 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 ( - - {/* @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: BucketAccessCountsGQL | null -} - -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( - // TODO: remove `__typename`s - () => data && `data:application/json,${JSON.stringify(data)}`, - [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: { - date: Date - combined: { - sum: number - value: number - } - byExt: Array<{ - ext: string - sum: number - value: number - }> - highlighted?: { - ext: string - } - } - colorPool: ColorPool - className?: string -} - -function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { - 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)}) -
- - ) - })} -
- - ) -} - -interface TransitionProps { - children: () => JSX.Element - in?: boolean -} - -const Transition = ({ children, ...props }: TransitionProps) => { - 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%', - }, -})) - -interface DownloadsProps extends M.BoxProps { - bucket: string - colorPool: ColorPool -} - -interface Counts { - total: number - counts: readonly { - date: Date - // TODO: compute sum - // sum: number - value: number - }[] -} - -interface BucketAccessCounts { - byExt: readonly { - ext: string - counts: Counts - }[] - byExtCollapsed: readonly { - ext: string - counts: Counts - }[] - combined: Counts -} - -interface Cursor { - i: number | null - j: number -} - -type BucketAccessCountsGQL = NonNullable< - GQL.DataForDoc['bucketAccessCounts'] -> - -function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { - 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: BucketAccessCounts) => { - 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 countsGql = GQL.useQuery( - BUCKET_ACCESS_COUNTS_QUERY, - { bucket, window }, - { pause: !cfg.analyticsBucket }, - ) - - const dataGql = GQL.fold(countsGql, { - data: (data) => data.bucketAccessCounts, - fetching: () => null, - error: () => null, - }) - - const dataO = GQL.fold(countsGql, { - data: ({ bucketAccessCounts: counts }) => { - if (!counts) return Eff.Option.none() - const { combined, byExtCollapsed, byExt } = counts - if (!combined || !byExtCollapsed || !byExt) return Eff.Option.none() - if (!byExtCollapsed.length) return Eff.Option.none() - return Eff.Option.some({ combined, byExtCollapsed, byExt }) - }, - fetching: () => Eff.Option.none(), - error: () => Eff.Option.none(), - }) - - // console.log('countsGql', countsGql) - - const countsLegacy = useData(requests.bucketAccessCounts, { s3, bucket, today, window }) - const data = countsLegacy.result - console.log('countsLegacy', data) - - if (!cfg.analyticsBucket) { - return ( - -
Requires CloudTrail
-
- ) - } - - return ( - -
- -
-
- {Eff.Option.match(dataO, { - onSome: (counts) => { - const stats = cursorStats(counts) - 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(dataO, { - onSome: (counts) => { - if (!counts.byExtCollapsed.length) { - return ( - -
No Data
-
- ) - } - - const stats = cursorStats(counts) - return ( - <> - {/* @ts-expect-error */} - - // FIXME - // e.counts.counts.map((i) => Math.log(i.sum + 1)), - e.counts.counts.map(() => Math.log(10)), - )} - 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} - /> - - {() => ( - - )} - - - {() => ( - - )} - - - ) - }, - onNone: () => , - })} -
-
- ) -} - -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 StatsDisplayProps { - value: $TSFixMe // AsyncResult - label?: string - format?: (v: any) => any - fallback?: (v: any) => any -} - -function StatDisplay({ value, label, format, fallback }: StatsDisplayProps) { - 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: $TSFixMe) => - v != null && ( - - {v} - {!!label && {label}} - - ), - _: () => ( -
- -
- ), - }), - // @ts-expect-error - )(value) as JSX.Element -} - -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), - }, -})) - -interface HeadProps { - s3: $TSFixMe // AWS.S3 - bucket: string - overviewUrl: string | null | undefined - description: string | null | undefined -} - -function Head({ s3, overviewUrl, bucket, description }: HeadProps) { - 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 - - - )} - - - - - - - - - - - - ) -} interface BucketReadmes { forced?: Model.S3.S3ObjectLocation @@ -995,7 +26,7 @@ interface BucketReadmes { } interface ReadmesProps { - s3: $TSFixMe // AWS.S3 + s3: AWSSDK.S3 bucket: string overviewUrl: string | undefined | null } @@ -1033,7 +64,7 @@ function Readmes({ s3, overviewUrl, bucket }: ReadmesProps) { } interface ImgsProps { - s3: $TSFixMe // AWS.S3 + s3: AWSSDK.S3 bucket: string overviewUrl: string | undefined | null inStack: boolean @@ -1104,7 +135,7 @@ export default function Overview() { )} {bucketConfig ? ( - +
) : ( Date: Wed, 13 Nov 2024 11:45:49 +0100 Subject: [PATCH 12/32] move ColorPool out --- .../containers/Bucket/Overview/ColorPool.ts | 16 ++++++++++++++ .../app/containers/Bucket/Overview/Header.tsx | 21 +++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 catalog/app/containers/Bucket/Overview/ColorPool.ts 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/Header.tsx b/catalog/app/containers/Bucket/Overview/Header.tsx index f80dd042c55..db75fac97ae 100644 --- a/catalog/app/containers/Bucket/Overview/Header.tsx +++ b/catalog/app/containers/Bucket/Overview/Header.tsx @@ -25,6 +25,8 @@ import useConst from 'utils/useConstant' import * as requests from '../requests' +import { ColorPool, makeColorPool } from './ColorPool' + import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' import bg from './Overview-bg.jpg' @@ -60,23 +62,6 @@ const COLOR_MAP = [ '#94ad6b', ] -interface ColorPool { - get: (key: string) => string -} - -function mkKeyedPool(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 } -} - const useObjectsByExtStyles = M.makeStyles((t) => ({ root: { display: 'grid', @@ -955,7 +940,7 @@ export default function Header({ s3, overviewUrl, bucket, description }: HeaderP const classes = useStyles() const req = APIConnector.use() const isRODA = !!overviewUrl && overviewUrl.includes(`/${RODA_BUCKET}/`) - const colorPool = useConst(() => mkKeyedPool(COLOR_MAP)) + 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() From 0b54c8dc7ffd8d717cba50d40fd766342432ecdb Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 13 Nov 2024 14:55:30 +0100 Subject: [PATCH 13/32] move Downloads out --- .../containers/Bucket/Overview/Downloads.tsx | 617 +++++++++++++++++ .../app/containers/Bucket/Overview/Header.tsx | 618 +----------------- 2 files changed, 628 insertions(+), 607 deletions(-) create mode 100644 catalog/app/containers/Bucket/Overview/Downloads.tsx diff --git a/catalog/app/containers/Bucket/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx new file mode 100644 index 00000000000..28c1594c2d6 --- /dev/null +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -0,0 +1,617 @@ +import cx from 'classnames' +import * as dateFns from 'date-fns' +import * as Eff from 'effect' +import * as R from 'ramda' +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 * as SVG from 'utils/SVG' +import { readableQuantity } from 'utils/string' + +import { ColorPool } from './ColorPool' + +import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' + +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]], +] as const + +const mkPulsingGradient = ({ + colors: [c1, c2], + animate = false, +}: { + colors: 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( + () => 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 ( + + {/* @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: BucketAccessCountsGQL | null +} + +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( + // TODO: remove `__typename`s + () => data && `data:application/json,${JSON.stringify(data)}`, + [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 CursorStats { + date: Date + combined: { + sum: number + value: number + } + byExt: { + ext: string + sum: number + value: number + date: Date + }[] + highlighted: { + ext: string + counts: AccessCountsWithSum + } | null + firstHalf: boolean +} + +interface StatsTipProps { + stats: CursorStats + colorPool: ColorPool + className?: string +} + +function assertNonNull(x: T): NonNullable { + if (x == null) throw new Error('Unexpected null') + return x as NonNullable +} + +function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { + 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)}) +
+ + ) + })} +
+ + ) +} + +interface TransitionProps { + children: () => JSX.Element + in?: boolean +} + +const Transition = ({ children, ...props }: TransitionProps) => { + const contentsRef = React.useRef(null) + if (props.in) contentsRef.current = children() + return contentsRef.current && {contentsRef.current} +} + +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%', + }, +})) + +type BucketAccessCountsGQL = NonNullable< + GQL.DataForDoc['bucketAccessCounts'] +> + +type AccessCountsGQL = BucketAccessCountsGQL['combined'] +type AccessCountForDate = Omit + +interface AccessCountForDateWithSum extends Omit { + sum: number +} + +interface AccessCountsWithSum { + total: number + counts: readonly AccessCountForDateWithSum[] +} + +// interface Counts { +// total: number +// counts: readonly AccessCountForDate[] +// } + +// interface CountsWithSum extends Counts { +// counts: readonly AccessCountForDateWithSum[] +// } + +// interface BucketAccessCounts { +// byExt: readonly { +// ext: string +// counts: Counts +// }[] +// byExtCollapsed: readonly { +// ext: string +// counts: Counts +// }[] +// combined: Counts +// } + +// interface BucketAccessCountsWithSum { +// byExt: readonly { +// ext: string +// counts: CountsWithSum +// }[] +// byExtCollapsed: readonly { +// ext: string +// counts: CountsWithSum +// }[] +// combined: CountsWithSum +// } + +const computeSum = ( + counts: readonly AccessCountForDate[], +): readonly AccessCountForDateWithSum[] => + Eff.Array.mapAccum(counts, 0, (acc, { value, date }) => [ + acc + value, + // const sum = acc.total + value + { + value, + date, + sum: acc + value, + }, + ])[1] + +interface Cursor { + // XXX: rename to row/col? date/band? + i: number | null + j: number +} + +interface DownloadsProps extends M.BoxProps { + bucket: string + colorPool: ColorPool + chartHeight: number +} + +export default function Downloads({ + bucket, + colorPool, + chartHeight, + ...props +}: DownloadsProps) { + 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 countsGql = GQL.useQuery( + BUCKET_ACCESS_COUNTS_QUERY, + { bucket, window }, + { pause: !cfg.analyticsBucket }, + ) + + const dataGql = GQL.fold(countsGql, { + data: (d) => d.bucketAccessCounts, + fetching: () => null, + error: () => null, + }) + + const dataO = React.useMemo( + () => + Eff.pipe( + countsGql, + GQL.getDataOption, + Eff.Option.flatMap((d) => Eff.Option.fromNullable(d.bucketAccessCounts)), + // TODO: clean typename + Eff.Option.map(({ byExt, byExtCollapsed, combined }) => ({ + byExt: Eff.Array.map(byExt, (e) => + Eff.Struct.evolve(e, { + counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), + }), + ), + byExtCollapsed: Eff.Array.map(byExtCollapsed, (e) => + Eff.Struct.evolve(e, { + counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), + }), + ), + combined: Eff.Struct.evolve(combined, { counts: computeSum }), + })), + ), + [countsGql], + ) + + const computed = React.useMemo( + () => + Eff.pipe( + dataO, + Eff.Option.map((counts) => { + let cursorStats: CursorStats | null = null + if (cursor) { + 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 + cursorStats = { date, combined, byExt, highlighted, firstHalf } + } + return { counts, cursorStats } + }), + ), + [dataO, cursor], + ) + + if (!cfg.analyticsBucket) { + return ( + +
Requires CloudTrail
+
+ ) + } + + return ( + +
+ +
+
+ {Eff.Option.match(computed, { + onSome: ({ counts, cursorStats: stats }) => { + // TODO: use flatmap or smth + 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(computed, { + onSome: ({ counts, cursorStats: stats }) => { + if (!counts.byExtCollapsed.length) { + return ( + +
No Data
+
+ ) + } + + return ( + <> + {/* @ts-expect-error */} + + // FIXME + 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 index db75fac97ae..d92d29b4f43 100644 --- a/catalog/app/containers/Bucket/Overview/Header.tsx +++ b/catalog/app/containers/Bucket/Overview/Header.tsx @@ -1,33 +1,24 @@ import type AWSSDK from 'aws-sdk' import cx from 'classnames' -import * as dateFns from 'date-fns' -import * as Eff from 'effect' import * as R from 'ramda' 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 { 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 AsyncResult from 'utils/AsyncResult' import { useData } from 'utils/Data' -import * as GQL from 'utils/GraphQL' import * as NamedRoutes from 'utils/NamedRoutes' -import * as SVG from 'utils/SVG' import { readableBytes, readableQuantity, formatQuantity } from 'utils/string' import useConst from 'utils/useConstant' import * as requests from '../requests' import { ColorPool, makeColorPool } from './ColorPool' - -import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' +import Downloads from './Downloads' import bg from './Overview-bg.jpg' @@ -213,602 +204,6 @@ function ObjectsByExt({ data, colorPool, ...props }: ObjectsByExtProps) { ) } -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]], -] as const - -const mkPulsingGradient = ({ - colors: [c1, c2], - animate = false, -}: { - colors: 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( - () => 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 ( - - {/* @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: BucketAccessCountsGQL | null -} - -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( - // TODO: remove `__typename`s - () => data && `data:application/json,${JSON.stringify(data)}`, - [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 CursorStats { - date: Date - combined: { - sum: number - value: number - } - byExt: { - ext: string - sum: number - value: number - date: Date - }[] - highlighted: { - ext: string - counts: AccessCountsWithSum - } | null - firstHalf: boolean -} - -interface StatsTipProps { - stats: CursorStats - colorPool: ColorPool - className?: string -} - -function assertNonNull(x: T): NonNullable { - if (x == null) throw new Error('Unexpected null') - return x as NonNullable -} - -function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { - 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)}) -
- - ) - })} -
- - ) -} - -interface TransitionProps { - children: () => JSX.Element - in?: boolean -} - -const Transition = ({ children, ...props }: TransitionProps) => { - 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%', - }, -})) - -interface DownloadsProps extends M.BoxProps { - bucket: string - colorPool: ColorPool -} - -type BucketAccessCountsGQL = NonNullable< - GQL.DataForDoc['bucketAccessCounts'] -> - -type AccessCountsGQL = BucketAccessCountsGQL['combined'] -type AccessCountForDate = Omit - -interface AccessCountForDateWithSum extends Omit { - sum: number -} - -interface AccessCountsWithSum { - total: number - counts: readonly AccessCountForDateWithSum[] -} - -// interface Counts { -// total: number -// counts: readonly AccessCountForDate[] -// } - -// interface CountsWithSum extends Counts { -// counts: readonly AccessCountForDateWithSum[] -// } - -// interface BucketAccessCounts { -// byExt: readonly { -// ext: string -// counts: Counts -// }[] -// byExtCollapsed: readonly { -// ext: string -// counts: Counts -// }[] -// combined: Counts -// } - -// interface BucketAccessCountsWithSum { -// byExt: readonly { -// ext: string -// counts: CountsWithSum -// }[] -// byExtCollapsed: readonly { -// ext: string -// counts: CountsWithSum -// }[] -// combined: CountsWithSum -// } - -const computeSum = ( - counts: readonly AccessCountForDate[], -): readonly AccessCountForDateWithSum[] => - Eff.Array.mapAccum(counts, 0, (acc, { value, date }) => [ - acc + value, - // const sum = acc.total + value - { - value, - date, - sum: acc + value, - }, - ])[1] - -interface Cursor { - // XXX: rename to row/col? date/band? - i: number | null - j: number -} - -function Downloads({ bucket, colorPool, ...props }: DownloadsProps) { - 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 countsGql = GQL.useQuery( - BUCKET_ACCESS_COUNTS_QUERY, - { bucket, window }, - { pause: !cfg.analyticsBucket }, - ) - - const dataGql = GQL.fold(countsGql, { - data: (d) => d.bucketAccessCounts, - fetching: () => null, - error: () => null, - }) - - const dataO = React.useMemo( - () => - Eff.pipe( - countsGql, - GQL.getDataOption, - Eff.Option.flatMap((d) => Eff.Option.fromNullable(d.bucketAccessCounts)), - // TODO: clean typename - Eff.Option.map(({ byExt, byExtCollapsed, combined }) => ({ - byExt: Eff.Array.map(byExt, (e) => - Eff.Struct.evolve(e, { - counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), - }), - ), - byExtCollapsed: Eff.Array.map(byExtCollapsed, (e) => - Eff.Struct.evolve(e, { - counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), - }), - ), - combined: Eff.Struct.evolve(combined, { counts: computeSum }), - })), - ), - [countsGql], - ) - - const computed = React.useMemo( - () => - Eff.pipe( - dataO, - Eff.Option.map((counts) => { - let cursorStats: CursorStats | null = null - if (cursor) { - 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 - cursorStats = { date, combined, byExt, highlighted, firstHalf } - } - return { counts, cursorStats } - }), - ), - [dataO, cursor], - ) - - if (!cfg.analyticsBucket) { - return ( - -
Requires CloudTrail
-
- ) - } - - return ( - -
- -
-
- {Eff.Option.match(computed, { - onSome: ({ counts, cursorStats: stats }) => { - // TODO: use flatmap or smth - 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(computed, { - onSome: ({ counts, cursorStats: stats }) => { - if (!counts.byExtCollapsed.length) { - return ( - -
No Data
-
- ) - } - - return ( - <> - {/* @ts-expect-error */} - - // FIXME - e.counts.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} - /> - - {() => ( - - )} - - - {() => ( - - )} - - - ) - }, - onNone: () => , - })} -
-
- ) -} - const useStatDisplayStyles = M.makeStyles((t) => ({ root: { alignItems: 'baseline', @@ -893,6 +288,9 @@ function StatDisplay({ value, label, format, fallback }: StatDisplayProps) { )(value) 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', @@ -1023,7 +421,13 @@ export default function Header({ s3, overviewUrl, bucket, description }: HeaderP - + ) From e1c1d0e287daefd5ca8db3d0ee0dfed2383871f4 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 13 Nov 2024 15:11:09 +0100 Subject: [PATCH 14/32] Header: ramda -> effect --- .../app/containers/Bucket/Overview/Header.tsx | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Header.tsx b/catalog/app/containers/Bucket/Overview/Header.tsx index d92d29b4f43..3c76edd1330 100644 --- a/catalog/app/containers/Bucket/Overview/Header.tsx +++ b/catalog/app/containers/Bucket/Overview/Header.tsx @@ -1,6 +1,6 @@ import type AWSSDK from 'aws-sdk' import cx from 'classnames' -import * as R from 'ramda' +import * as Eff from 'effect' import * as React from 'react' import { Link as RRLink } from 'react-router-dom' import * as redux from 'react-redux' @@ -181,17 +181,14 @@ function ObjectsByExt({ data, colorPool, ...props }: ObjectsByExtProps) { }, _: (r: $TSFixMe) => ( <> - {R.times( - (i) => ( - - ), - MAX_EXTS, - )} + {Eff.Array.makeBy(MAX_EXTS, (i) => ( + + ))} {AsyncResult.Err.is(r) && (
Data unavailable
)} @@ -264,11 +261,12 @@ interface StatDisplayProps { function StatDisplay({ value, label, format, fallback }: StatDisplayProps) { const classes = useStatDisplayStyles() - return R.pipe( + return Eff.pipe( + value, AsyncResult.case({ - Ok: R.pipe(format || R.identity, AsyncResult.Ok), - Err: R.pipe(fallback || R.identity, AsyncResult.Ok), - _: R.identity, + Ok: Eff.flow(format || Eff.identity, AsyncResult.Ok), + Err: Eff.flow(fallback || Eff.identity, AsyncResult.Ok), + _: Eff.identity, }), AsyncResult.case({ Ok: (v: $TSFixMe) => @@ -284,8 +282,7 @@ function StatDisplay({ value, label, format, fallback }: StatDisplayProps) {
), }), - // @ts-expect-error - )(value) as JSX.Element + ) as JSX.Element } // use the same height as the bar chart: 20px per bar with 2px margin From 4481a56aba92411dac9dab4712a80af99475ae83 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 13 Nov 2024 16:25:22 +0100 Subject: [PATCH 15/32] Downloads: complete the migration / refactoring --- .../containers/Bucket/Overview/Downloads.tsx | 334 ++++++++---------- 1 file changed, 153 insertions(+), 181 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx index 28c1594c2d6..a606a25f9dc 100644 --- a/catalog/app/containers/Bucket/Overview/Downloads.tsx +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -1,7 +1,6 @@ import cx from 'classnames' import * as dateFns from 'date-fns' import * as Eff from 'effect' -import * as R from 'ramda' import * as React from 'react' import * as M from '@material-ui/core' import { fade } from '@material-ui/core/styles' @@ -17,14 +16,116 @@ import { ColorPool } from './ColorPool' import BUCKET_ACCESS_COUNTS_QUERY from './gql/BucketAccessCounts.generated' -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)), - ), +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), +}) + +const processBucketAccessCounts = ( + counts: GQLBucketAccessCounts, +): ProcessedBucketAccessCounts => ({ + byExt: Eff.Array.map(counts.byExt, processAccessCountsGroup), + byExtCollapsed: Eff.Array.map(counts.byExtCollapsed, 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 = [ @@ -32,13 +133,7 @@ const skelColors = [ [M.colors.grey[400], M.colors.grey[200]], ] as const -const mkPulsingGradient = ({ - colors: [c1, c2], - animate = false, -}: { - colors: readonly [string, string] - animate?: boolean -}) => +const mkPulsingGradient = ([c1, c2]: readonly [string, string], animate: boolean) => SVG.Paint.Server( @@ -70,14 +165,13 @@ function ChartSkel({ children, }: ChartSkelProps) { const data = React.useMemo( - () => R.times((i) => skelData[i % skelData.length], lines), + () => Eff.Array.makeBy(lines, (i) => skelData[i % skelData.length]), [lines], ) const fills = React.useMemo( () => - R.times( - (i) => mkPulsingGradient({ colors: skelColors[i % skelColors.length], animate }), - lines, + Eff.Array.makeBy(lines, (i) => + mkPulsingGradient(skelColors[i % skelColors.length], animate), ), [lines, animate], ) @@ -110,7 +204,7 @@ interface DownloadsRangeProps { value: number onChange: (value: number) => void bucket: string - data: BucketAccessCountsGQL | null + data: Eff.Option.Option } function DownloadsRange({ value, onChange, bucket, data }: DownloadsRangeProps) { @@ -138,8 +232,11 @@ function DownloadsRange({ value, onChange, bucket, data }: DownloadsRangeProps) const { label } = ANALYTICS_WINDOW_OPTIONS.find((o) => o.value === value) || {} const jsonData = React.useMemo( - // TODO: remove `__typename`s - () => data && `data:application/json,${JSON.stringify(data)}`, + () => + Eff.Option.match(data, { + onNone: () => null, + onSome: (d) => `data:application/json,${JSON.stringify(d)}`, + }), [data], ) @@ -220,38 +317,15 @@ const useStatsTipStyles = M.makeStyles((t) => ({ }, })) -interface CursorStats { - date: Date - combined: { - sum: number - value: number - } - byExt: { - ext: string - sum: number - value: number - date: Date - }[] - highlighted: { - ext: string - counts: AccessCountsWithSum - } | null - firstHalf: boolean -} - interface StatsTipProps { - stats: CursorStats + stats: CursorStats | null colorPool: ColorPool className?: string } -function assertNonNull(x: T): NonNullable { - if (x == null) throw new Error('Unexpected null') - return x as NonNullable -} - function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { const classes = useStatsTipStyles() + if (!stats) return null return (
@@ -284,17 +358,18 @@ function StatsTip({ stats, colorPool, className, ...props }: StatsTipProps) { } interface TransitionProps { - children: () => JSX.Element - in?: boolean + children: JSX.Element + in: boolean } -const Transition = ({ children, ...props }: TransitionProps) => { +function Transition({ children, ...props }: TransitionProps) { const contentsRef = React.useRef(null) - if (props.in) contentsRef.current = children() + // when `in` is false, we want to keep the last rendered contents + if (props.in) contentsRef.current = children return contentsRef.current && {contentsRef.current} } -const useDownloadsStyles = M.makeStyles((t) => ({ +const useStyles = M.makeStyles((t) => ({ root: { display: 'grid', gridRowGap: t.spacing(0.25), @@ -376,74 +451,6 @@ const useDownloadsStyles = M.makeStyles((t) => ({ }, })) -type BucketAccessCountsGQL = NonNullable< - GQL.DataForDoc['bucketAccessCounts'] -> - -type AccessCountsGQL = BucketAccessCountsGQL['combined'] -type AccessCountForDate = Omit - -interface AccessCountForDateWithSum extends Omit { - sum: number -} - -interface AccessCountsWithSum { - total: number - counts: readonly AccessCountForDateWithSum[] -} - -// interface Counts { -// total: number -// counts: readonly AccessCountForDate[] -// } - -// interface CountsWithSum extends Counts { -// counts: readonly AccessCountForDateWithSum[] -// } - -// interface BucketAccessCounts { -// byExt: readonly { -// ext: string -// counts: Counts -// }[] -// byExtCollapsed: readonly { -// ext: string -// counts: Counts -// }[] -// combined: Counts -// } - -// interface BucketAccessCountsWithSum { -// byExt: readonly { -// ext: string -// counts: CountsWithSum -// }[] -// byExtCollapsed: readonly { -// ext: string -// counts: CountsWithSum -// }[] -// combined: CountsWithSum -// } - -const computeSum = ( - counts: readonly AccessCountForDate[], -): readonly AccessCountForDateWithSum[] => - Eff.Array.mapAccum(counts, 0, (acc, { value, date }) => [ - acc + value, - // const sum = acc.total + value - { - value, - date, - sum: acc + value, - }, - ])[1] - -interface Cursor { - // XXX: rename to row/col? date/band? - i: number | null - j: number -} - interface DownloadsProps extends M.BoxProps { bucket: string colorPool: ColorPool @@ -456,69 +463,40 @@ export default function Downloads({ chartHeight, ...props }: DownloadsProps) { - const classes = useDownloadsStyles() + 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 countsGql = GQL.useQuery( + const result = GQL.useQuery( BUCKET_ACCESS_COUNTS_QUERY, { bucket, window }, { pause: !cfg.analyticsBucket }, ) - const dataGql = GQL.fold(countsGql, { - data: (d) => d.bucketAccessCounts, - fetching: () => null, - error: () => null, - }) - - const dataO = React.useMemo( + const processed = React.useMemo( () => Eff.pipe( - countsGql, + result, GQL.getDataOption, Eff.Option.flatMap((d) => Eff.Option.fromNullable(d.bucketAccessCounts)), - // TODO: clean typename - Eff.Option.map(({ byExt, byExtCollapsed, combined }) => ({ - byExt: Eff.Array.map(byExt, (e) => - Eff.Struct.evolve(e, { - counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), - }), - ), - byExtCollapsed: Eff.Array.map(byExtCollapsed, (e) => - Eff.Struct.evolve(e, { - counts: (c) => Eff.Struct.evolve(c, { counts: computeSum }), - }), - ), - combined: Eff.Struct.evolve(combined, { counts: computeSum }), - })), + Eff.Option.map(processBucketAccessCounts), ), - [countsGql], + [result], ) - const computed = React.useMemo( + const processedWithCursor = React.useMemo( () => Eff.pipe( - dataO, - Eff.Option.map((counts) => { - let cursorStats: CursorStats | null = null - if (cursor) { - 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 - cursorStats = { date, combined, byExt, highlighted, firstHalf } - } - return { counts, cursorStats } - }), + processed, + Eff.Option.map((counts) => ({ + counts, + cursorStats: getCursorStats(counts, cursor), + })), ), - [dataO, cursor], + [processed, cursor], ) if (!cfg.analyticsBucket) { @@ -536,13 +514,12 @@ export default function Downloads({ value={window} onChange={setWindow} bucket={bucket} - data={dataGql} + data={processed} />
- {Eff.Option.match(computed, { + {Eff.Option.match(processedWithCursor, { onSome: ({ counts, cursorStats: stats }) => { - // TODO: use flatmap or smth if (!counts?.byExtCollapsed.length) return 'Downloads' const hl = stats?.highlighted @@ -559,7 +536,7 @@ export default function Downloads({ })}
- {Eff.Option.match(computed, { + {Eff.Option.match(processedWithCursor, { onSome: ({ counts, cursorStats: stats }) => { if (!counts.byExtCollapsed.length) { return ( @@ -574,7 +551,6 @@ export default function Downloads({ {/* @ts-expect-error */} - // FIXME e.counts.counts.map((i) => Math.log(i.sum + 1)), )} onCursor={setCursor} @@ -589,22 +565,18 @@ export default function Downloads({ px={10} /> - {() => ( - - )} + - {() => ( - - )} + ) From b0b5f96e2c430610f120c4dcb45d265a5a50980c Mon Sep 17 00:00:00 2001 From: nl_0 Date: Thu, 14 Nov 2024 12:33:11 +0100 Subject: [PATCH 16/32] requestsUntyped: rm bucketAccessCounts --- .../Bucket/requests/requestsUntyped.js | 100 ------------------ 1 file changed, 100 deletions(-) diff --git a/catalog/app/containers/Bucket/requests/requestsUntyped.js b/catalog/app/containers/Bucket/requests/requestsUntyped.js index 2ba9722da61..e12dbe90126 100644 --- a/catalog/app/containers/Bucket/requests/requestsUntyped.js +++ b/catalog/app/containers/Bucket/requests/requestsUntyped.js @@ -24,106 +24,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 From 2d95c42935f386aeda29a642a991b56f913352de Mon Sep 17 00:00:00 2001 From: nl_0 Date: Thu, 14 Nov 2024 13:47:40 +0100 Subject: [PATCH 17/32] progress when changing window --- catalog/app/containers/Bucket/Overview/Downloads.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx index a606a25f9dc..6806605b0bd 100644 --- a/catalog/app/containers/Bucket/Overview/Downloads.tsx +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -9,6 +9,7 @@ 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' @@ -480,8 +481,11 @@ export default function Downloads({ () => Eff.pipe( result, - GQL.getDataOption, - Eff.Option.flatMap((d) => Eff.Option.fromNullable(d.bucketAccessCounts)), + ({ fetching, data, error }) => { + if (fetching) return Eff.Option.none() + if (error) log.error('Failed to fetch bucket access counts:', error) + return Eff.Option.fromNullable(data?.bucketAccessCounts) + }, Eff.Option.map(processBucketAccessCounts), ), [result], From 012e2a8ca73871d29fe20175b769fd537b4a30b4 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Thu, 14 Nov 2024 14:01:36 +0100 Subject: [PATCH 18/32] move FileAnalytics out --- catalog/app/containers/Bucket/File.js | 69 +-------------- .../app/containers/Bucket/FileAnalytics.tsx | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 67 deletions(-) create mode 100644 catalog/app/containers/Bucket/FileAnalytics.tsx diff --git a/catalog/app/containers/Bucket/File.js b/catalog/app/containers/Bucket/File.js index e91b4ed9fd1..1f922f46f7d 100644 --- a/catalog/app/containers/Bucket/File.js +++ b/catalog/app/containers/Bucket/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,16 +19,16 @@ 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 { readableBytes } from 'utils/string' import AssistButton from './AssistButton' import FileCodeSamples from './CodeSamples/File' +import Analytics from './FileAnalytics' import * as AssistantContext from './FileAssistantContext' import FileProperties from './FileProperties' import * as FileView from './FileView' @@ -203,69 +201,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/FileAnalytics.tsx b/catalog/app/containers/Bucket/FileAnalytics.tsx new file mode 100644 index 00000000000..1e2c0636346 --- /dev/null +++ b/catalog/app/containers/Bucket/FileAnalytics.tsx @@ -0,0 +1,85 @@ +import * as dateFns from 'date-fns' +import * as R from 'ramda' +import * as React from 'react' +import * as M from '@material-ui/core' + +import Sparkline from 'components/Sparkline' +import * as AWS from 'utils/AWS' +import { useData } from 'utils/Data' +import * as SVG from 'utils/SVG' +import { readableQuantity } from 'utils/string' + +import Section from './Section' +import * as requests from './requests' + +interface CountsData { + total: number + counts: { date: Date; value: number }[] +} + +interface AnalyticsProps { + bucket: string + path: string +} +export default function Analytics({ bucket, path }: AnalyticsProps) { + const [cursor, setCursor] = React.useState(null) + const s3 = AWS.S3.use() + const today = React.useMemo(() => new Date(), []) + const formatDate = (date: 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 }: CountsData) => !!total, + _: () => false, + }) + + return ( +
+ {data.case({ + Ok: ({ counts, total }: CountsData) => + 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, + _: () => , + })} +
+ ) +} From cb80994fa1c993aa09b602a84e82c63f64203289 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 11:39:23 +0100 Subject: [PATCH 19/32] move Bucket/File* into a folder --- .../{FileAnalytics.tsx => File/Analytics.tsx} | 5 +++-- .../AssistantContext.ts} | 2 +- .../app/containers/Bucket/{ => File}/File.js | 21 ++++++++++--------- catalog/app/containers/Bucket/File/index.ts | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) rename catalog/app/containers/Bucket/{FileAnalytics.tsx => File/Analytics.tsx} (97%) rename catalog/app/containers/Bucket/{FileAssistantContext.ts => File/AssistantContext.ts} (98%) rename catalog/app/containers/Bucket/{ => File}/File.js (97%) create mode 100644 catalog/app/containers/Bucket/File/index.ts diff --git a/catalog/app/containers/Bucket/FileAnalytics.tsx b/catalog/app/containers/Bucket/File/Analytics.tsx similarity index 97% rename from catalog/app/containers/Bucket/FileAnalytics.tsx rename to catalog/app/containers/Bucket/File/Analytics.tsx index 1e2c0636346..fde83717e68 100644 --- a/catalog/app/containers/Bucket/FileAnalytics.tsx +++ b/catalog/app/containers/Bucket/File/Analytics.tsx @@ -9,8 +9,8 @@ import { useData } from 'utils/Data' import * as SVG from 'utils/SVG' import { readableQuantity } from 'utils/string' -import Section from './Section' -import * as requests from './requests' +import Section from '../Section' +import * as requests from '../requests' interface CountsData { total: number @@ -21,6 +21,7 @@ interface AnalyticsProps { bucket: string path: string } + export default function Analytics({ bucket, path }: AnalyticsProps) { const [cursor, setCursor] = React.useState(null) const s3 = AWS.S3.use() 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 97% rename from catalog/app/containers/Bucket/File.js rename to catalog/app/containers/Bucket/File/File.js index 1f922f46f7d..fda4ceb1d40 100644 --- a/catalog/app/containers/Bucket/File.js +++ b/catalog/app/containers/Bucket/File/File.js @@ -26,16 +26,17 @@ import parseSearch from 'utils/parseSearch' import { up, decode, handleToHttpsUri } from 'utils/s3paths' import { readableBytes } from 'utils/string' -import AssistButton from './AssistButton' -import FileCodeSamples from './CodeSamples/File' -import Analytics from './FileAnalytics' -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 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: { 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' From 7f934fbfde4fdb47649daa70c6b15d290fa41b73 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:22:13 +0100 Subject: [PATCH 20/32] migrate file analytics to GraphQL and Effect --- .../app/containers/Bucket/File/Analytics.tsx | 122 +++++++++--------- .../File/gql/ObjectAccessCounts.generated.ts | 100 ++++++++++++++ .../File/gql/ObjectAccessCounts.graphql | 9 ++ .../containers/Bucket/Overview/Downloads.tsx | 2 +- 4 files changed, 174 insertions(+), 59 deletions(-) create mode 100644 catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.generated.ts create mode 100644 catalog/app/containers/Bucket/File/gql/ObjectAccessCounts.graphql diff --git a/catalog/app/containers/Bucket/File/Analytics.tsx b/catalog/app/containers/Bucket/File/Analytics.tsx index fde83717e68..4152b083953 100644 --- a/catalog/app/containers/Bucket/File/Analytics.tsx +++ b/catalog/app/containers/Bucket/File/Analytics.tsx @@ -1,21 +1,22 @@ import * as dateFns from 'date-fns' -import * as R from 'ramda' +import * as Eff from 'effect' import * as React from 'react' import * as M from '@material-ui/core' import Sparkline from 'components/Sparkline' -import * as AWS from 'utils/AWS' -import { useData } from 'utils/Data' +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 * as requests from '../requests' -interface CountsData { - total: number - counts: { date: Date; value: number }[] -} +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 @@ -24,62 +25,67 @@ interface AnalyticsProps { export default function Analytics({ bucket, path }: AnalyticsProps) { const [cursor, setCursor] = React.useState(null) - const s3 = AWS.S3.use() - const today = React.useMemo(() => new Date(), []) - const formatDate = (date: 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 }: CountsData) => !!total, - _: () => false, + 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 (
- {data.case({ - Ok: ({ counts, total }: CountsData) => - total ? ( - - - Downloads - - {readableQuantity(cursor === null ? total : counts[cursor].value)} - - - {cursor === null - ? `${counts.length} days` - : formatDate(counts[cursor].date)} - - - - - - - , - )} - /> + {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 - ), - Err: () => No analytics available, - _: () => , + ) : ( + No analytics available + ), + }), })}
) 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/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx index 6806605b0bd..3f294d9b26b 100644 --- a/catalog/app/containers/Bucket/Overview/Downloads.tsx +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -483,7 +483,7 @@ export default function Downloads({ result, ({ fetching, data, error }) => { if (fetching) return Eff.Option.none() - if (error) log.error('Failed to fetch bucket access counts:', error) + if (error) log.error('Error fetching bucket access counts:', error) return Eff.Option.fromNullable(data?.bucketAccessCounts) }, Eff.Option.map(processBucketAccessCounts), From 664342a930bd3dcf0053ce9641b0b1f3f59bfc31 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:27:46 +0100 Subject: [PATCH 21/32] requestsUntyped: rm obsolete code --- .../Bucket/requests/requestsUntyped.js | 142 ++++-------------- 1 file changed, 29 insertions(+), 113 deletions(-) diff --git a/catalog/app/containers/Bucket/requests/requestsUntyped.js b/catalog/app/containers/Bucket/requests/requestsUntyped.js index e12dbe90126..8bb998a9cfc 100644 --- a/catalog/app/containers/Bucket/requests/requestsUntyped.js +++ b/catalog/app/containers/Bucket/requests/requestsUntyped.js @@ -1,6 +1,5 @@ import { join as pathJoin } from 'path' -import * as dateFns from 'date-fns' import * as FP from 'fp-ts' 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' @@ -556,8 +554,6 @@ export const summarize = async ({ s3, handle: inputHandle, resolveLogicalKey }) } } -const MANIFESTS_PREFIX = '.quilt/packages/' - const withCalculatedRevisions = (s) => ({ scripted_metric: { init_script: ` @@ -612,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 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 - } -} +// const MANIFESTS_PREFIX = '.quilt/packages/' -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, +// } +// } From 165900fbd37100b1aa2d33be444641560accac48 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:37:07 +0100 Subject: [PATCH 22/32] refactor: migrate analytics to GraphQL and Effect --- catalog/app/embed/File.js | 121 ++++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 56 deletions(-) diff --git a/catalog/app/embed/File.js b/catalog/app/embed/File.js index bc47739202b..804ae68da0a 100644 --- a/catalog/app/embed/File.js +++ b/catalog/app/embed/File.js @@ -1,7 +1,7 @@ import { basename } from 'path' import * as dateFns from 'date-fns' -import * as R from 'ramda' +import * as Eff from 'effect' import * as React from 'react' import { Link, useLocation, useParams } from 'react-router-dom' import * as M from '@material-ui/core' @@ -15,7 +15,9 @@ import * as Notifications from 'containers/Notifications' import * as AWS from 'utils/AWS' import AsyncResult from 'utils/AsyncResult' import { useData } from 'utils/Data' +import * as GQL from 'utils/GraphQL' import * as NamedRoutes from 'utils/NamedRoutes' +import log from 'utils/Logging' import * as SVG from 'utils/SVG' import { linkStyle } from 'utils/StyledLink' import copyToClipboard from 'utils/clipboard' @@ -31,6 +33,8 @@ import Section from 'containers/Bucket/Section' import renderPreview from 'containers/Bucket/renderPreview' import * as requests from 'containers/Bucket/requests' +import ACCESS_COUNTS_QUERY from 'containers/Bucket/File/gql/ObjectAccessCounts.generated' + import * as EmbedConfig from './EmbedConfig' import * as Overrides from './Overrides' import * as ipc from './ipc' @@ -229,69 +233,74 @@ function VersionInfo({ bucket, path, version }) { ) } +const currentYear = new Date().getFullYear() + +const formatDate = (date) => + dateFns.format(date, currentYear === date.getFullYear() ? 'd MMM' : 'd MMM yyyy') + 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, + 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 (
- {data.case({ - Ok: ({ counts, total }) => - total ? ( - - - Downloads - - {readableQuantity(cursor === null ? total : counts[cursor].value)} - - - {cursor === null - ? `${counts.length} days` - : formatDate(counts[cursor].date)} - - - - - - - , - )} - /> + {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 - ), - Err: () => No analytics available, - _: () => , + ) : ( + No analytics available + ), + }), })}
) From bc77833091f76b5fc75f1bade4abee6d28349363 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:40:33 +0100 Subject: [PATCH 23/32] refactor: remove unused getDataOption function and Effect import --- catalog/app/utils/GraphQL/wrappers.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/catalog/app/utils/GraphQL/wrappers.ts b/catalog/app/utils/GraphQL/wrappers.ts index 22c8d7aed8a..d445be94383 100644 --- a/catalog/app/utils/GraphQL/wrappers.ts +++ b/catalog/app/utils/GraphQL/wrappers.ts @@ -1,4 +1,3 @@ -import * as Eff from 'effect' import * as R from 'ramda' import * as React from 'react' import * as urql from 'urql' @@ -131,15 +130,6 @@ export const foldC = (result: ResultForData): OnData | OnFetching | OnError => fold(result, opts) -export const getDataOption = ( - result: ResultForData, -): Eff.Option.Option => - fold(result, { - data: Eff.Option.some, - fetching: Eff.Option.none, - error: Eff.Option.none, - }) - export type DataForDoc> = Doc extends urql.TypedDocumentNode ? Data : never From 6474084935d085fcd28352831747652e3687f724 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:44:16 +0100 Subject: [PATCH 24/32] refactor: replace fp-ts with effect library for pipe operations --- .../containers/Bucket/requests/requestsUntyped.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/catalog/app/containers/Bucket/requests/requestsUntyped.js b/catalog/app/containers/Bucket/requests/requestsUntyped.js index 8bb998a9cfc..5efb639049f 100644 --- a/catalog/app/containers/Bucket/requests/requestsUntyped.js +++ b/catalog/app/containers/Bucket/requests/requestsUntyped.js @@ -1,6 +1,6 @@ import { join as pathJoin } from 'path' -import * as FP from 'fp-ts' +import * as Eff from 'effect' import sampleSize from 'lodash/fp/sampleSize' import * as R from 'ramda' @@ -271,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) => ({ @@ -301,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) => { @@ -323,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 })), @@ -375,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) => ({ @@ -396,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) => { @@ -417,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 })), From c5b050b9ca3d3109ee4612e6d4cd09e13405a25e Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:52:01 +0100 Subject: [PATCH 25/32] refactor: simplify S3 request signing logic and remove analytics bucket --- catalog/app/utils/AWS/S3.js | 50 ++++++++++----------------------- catalog/app/utils/AWS/Signer.js | 2 +- 2 files changed, 16 insertions(+), 36 deletions(-) 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( From 7b57360a467f2afff738f67c10d9c67bacb2a8a0 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 12:56:28 +0100 Subject: [PATCH 26/32] deploy --- .github/workflows/deploy-catalog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-catalog.yaml b/.github/workflows/deploy-catalog.yaml index fc4f8aed0fe..a3cc4bf0d0a 100644 --- a/.github/workflows/deploy-catalog.yaml +++ b/.github/workflows/deploy-catalog.yaml @@ -4,6 +4,7 @@ on: push: branches: - master + - replace-select # FIXME: remove paths: - '.github/workflows/deploy-catalog.yaml' - 'catalog/**' From f59ff750608f59170236ed53a70e5d215b8b8038 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Fri, 15 Nov 2024 13:18:46 +0100 Subject: [PATCH 27/32] cl --- catalog/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)) From 3e45dd5524b4aaf53054b328eaf2820dd09065b4 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 18 Nov 2024 12:13:40 +0100 Subject: [PATCH 28/32] embed/File: reuse Analytics component --- catalog/app/embed/File.js | 84 +-------------------------------------- 1 file changed, 2 insertions(+), 82 deletions(-) diff --git a/catalog/app/embed/File.js b/catalog/app/embed/File.js index 804ae68da0a..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 Eff from 'effect' import * as React from 'react' import { Link, useLocation, useParams } from 'react-router-dom' import * as M from '@material-ui/core' @@ -9,32 +7,27 @@ 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 GQL from 'utils/GraphQL' import * as NamedRoutes from 'utils/NamedRoutes' -import log from 'utils/Logging' -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' import renderPreview from 'containers/Bucket/renderPreview' import * as requests from 'containers/Bucket/requests' -import ACCESS_COUNTS_QUERY from 'containers/Bucket/File/gql/ObjectAccessCounts.generated' - import * as EmbedConfig from './EmbedConfig' import * as Overrides from './Overrides' import * as ipc from './ipc' @@ -233,79 +226,6 @@ function VersionInfo({ bucket, path, version }) { ) } -const currentYear = new Date().getFullYear() - -const formatDate = (date) => - dateFns.format(date, currentYear === date.getFullYear() ? 'd MMM' : 'd MMM yyyy') - -function Analytics({ bucket, path }) { - 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 - ), - }), - })} -
- ) -} - function CenteredProgress() { return ( From 623fb748feeeb431ae6ae12034f55d76c28669cb Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 18 Nov 2024 12:50:24 +0100 Subject: [PATCH 29/32] Effect.Array.map -> array.map --- catalog/app/containers/Bucket/Overview/Downloads.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Downloads.tsx b/catalog/app/containers/Bucket/Overview/Downloads.tsx index 3f294d9b26b..4a6d0a5a80d 100644 --- a/catalog/app/containers/Bucket/Overview/Downloads.tsx +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -74,8 +74,8 @@ const processAccessCountsGroup = ( const processBucketAccessCounts = ( counts: GQLBucketAccessCounts, ): ProcessedBucketAccessCounts => ({ - byExt: Eff.Array.map(counts.byExt, processAccessCountsGroup), - byExtCollapsed: Eff.Array.map(counts.byExtCollapsed, processAccessCountsGroup), + byExt: counts.byExt.map(processAccessCountsGroup), + byExtCollapsed: counts.byExtCollapsed.map(processAccessCountsGroup), combined: processAccessCounts(counts.combined), }) From 3fbcee758775a1529805905f63aa007c055d484c Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 18 Nov 2024 12:53:55 +0100 Subject: [PATCH 30/32] refactor: simplify FilePreview component and fix type errors --- catalog/app/containers/Bucket/Overview/Overview.tsx | 2 -- catalog/app/containers/Bucket/Summarize.tsx | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/catalog/app/containers/Bucket/Overview/Overview.tsx b/catalog/app/containers/Bucket/Overview/Overview.tsx index beff7220020..2eee868fd4f 100644 --- a/catalog/app/containers/Bucket/Overview/Overview.tsx +++ b/catalog/app/containers/Bucket/Overview/Overview.tsx @@ -42,13 +42,11 @@ function Readmes({ s3, overviewUrl, bucket }: ReadmesProps) { {!!rs.forced && ( )} {rs.discovered.map((h) => ( - // @ts-expect-error : 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]) From 68581ffac05df80d01e3b1fa43ec8a72e7d401c1 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 18 Nov 2024 13:09:53 +0100 Subject: [PATCH 31/32] Revert "deploy" This reverts commit 7b57360a467f2afff738f67c10d9c67bacb2a8a0. --- .github/workflows/deploy-catalog.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy-catalog.yaml b/.github/workflows/deploy-catalog.yaml index a3cc4bf0d0a..fc4f8aed0fe 100644 --- a/.github/workflows/deploy-catalog.yaml +++ b/.github/workflows/deploy-catalog.yaml @@ -4,7 +4,6 @@ on: push: branches: - master - - replace-select # FIXME: remove paths: - '.github/workflows/deploy-catalog.yaml' - 'catalog/**' From 09eb8bb48e6c67b5c25a58db57d54a874f2e74f8 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 18 Nov 2024 14:06:11 +0100 Subject: [PATCH 32/32] test: add unit tests for Downloads data processing --- .../Bucket/Overview/Downloads.spec.ts | 194 ++++++++++++++++++ .../containers/Bucket/Overview/Downloads.tsx | 2 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 catalog/app/containers/Bucket/Overview/Downloads.spec.ts 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 index 4a6d0a5a80d..065519d45b2 100644 --- a/catalog/app/containers/Bucket/Overview/Downloads.tsx +++ b/catalog/app/containers/Bucket/Overview/Downloads.tsx @@ -71,7 +71,7 @@ const processAccessCountsGroup = ( counts: processAccessCounts(group.counts), }) -const processBucketAccessCounts = ( +export const processBucketAccessCounts = ( counts: GQLBucketAccessCounts, ): ProcessedBucketAccessCounts => ({ byExt: counts.byExt.map(processAccessCountsGroup),