feature: warehouse (#23714)

* add event collection ui, queries, routing

* fix alignment issue

* renaming

* wip

* feat: initial data hooks and integration with feature flagging

* update with naming, correct mocks, etc

* feat: add source crud integration

* mock data, fix form, add motion to drawer

* feat: optimize warehouse fetch behaviour

* feat: add in valid query

* chore: ermove collection mocks

* feat: optimize tenant fetching

* cleanup + update openapi client + fix fetch calls

* cleanup imporst

* on delete success, redirect to main logs page

* fix dropdown event propagating to link and opening page

* Refactor code to use useParams and useRouter in CreateWarehouseCollection.tsx, WarehouseCollectionDetail.tsx, and LogsLayout.tsx

* clean warehouse query

* warehouse access tokens create, list

* add warehouse access token revokal

* fix access tokens table, fix access tokens revoke feature

* format date in access tokens table

* fix some state issues and warnings in access tokens forms

* warehouse/feat: introduction when creating event collections (#23244)

feat: add introduction for event collections

* warehouse/feat: rename headers and nav items (#23243)

* feat: rename headers

* feat: use feature flag for navigation bar

* move header name flag to component

* fix typeerror in create collection query

---------

Co-authored-by: Jordi Enric <jordi.err@gmail.com>

* ui fixes, rm deprecated icons

* render table with events

* fix typeerror results arr

* add pagination

* some ui fixes

* fix loading states, format dates, test connection dialog

* patch type errors

* Fix access token selection in TestCollectionDialog and update column name in WarehouseAccessTokens

* rm unused querytype

* rm unused querytype in logtable

* patch type errors in warhouse collection mutations

* rm unused component

* mr unused import

* show connection dialog in staging

* fix Run btn now showing up

* fix undef argument

* show pagination only when there are results

* fix issues in connection dialog, improve ux

* Update apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx

Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>

* Update apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx

Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>

* fix infinite render loop, adapt component to render log metadata on log selection

* rm unused import, prevent ui from breaking when some data may be missing from an event

* update WAT create mutation to use template

* follow mutation template in delete access token mutation

* refactor naming, improve code in some areas

* rm unused mocks for msw

* skip tests - this will get fixed on the vitest PR

* Update apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>

* fix loading compo

* rm unused import

* use correct alert title

* rm unnecessary div

* fix layout in projectpaused alert

* fix type err, refactor to new method

* rm observer

* rm observer

* refactor warehouse collections update

* use correct get method, comment out so it doesnt err

* use correct method

* fix formatting issues

* ref imports

* ref imports

* clean up imports

* clean imports

* clean imports

* rename props

* destructure props

* cleanup imports

* Update apps/studio/components/interfaces/DataWarehouse/CreateWarehouseCollection.tsx

Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>

* ref scale class

* cleanup divs

* Update apps/studio/components/interfaces/DataWarehouse/TestCollectionDialog.tsx

Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>

* refactor to rhf

* reuse types, cleanup, refactor to react query hooks instead of el events

* refactor table component to handle empty state

* Update TestCollectionDialog to use unique keys for SelectItem components

* Update TestCollectionDialog to use unique keys for SelectItem components

* Update TestCollectionDialog to use unique keys for SelectItem components

* fix empty state ui

* use destructured queries

* rename query types

* Update apps/studio/components/interfaces/DataWarehouse/WarehouseAccessTokens.tsx

Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>

* chore: Refactor WarehouseCollectionDetail component

* rm unused imports

* rename loadolder

* rm typcast

* rhf in create token

* improve err handling

* refactor to rhf, refactor queries, use cn utils, cleanup

* fix loading logselection

* add correct type to warehouse access tokens query

* use correct types in err & clean unused types

* toggle token in curl exampel

* use product empty state component in warehouse

* add client side validation of collection name, temp fix

* add New badge

* update testcolldialog

* cleanup unnecessary div and rm double border

* code style fixes

* handle err

* code style fix

* add correct type

* add correct err type

* fix type error

* fix type err

* fix missing type

---------

Co-authored-by: TzeYiing <ty@tzeyiing.com>
Co-authored-by: Ziinc <Ziinc@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-05-29 10:32:46 +02:00
1 parent 35645efc6f
commit 9fd85e76fb
39 files changed
+2056 -140

No files matched your search

@@ -0,0 +1,76 @@
import { useState } from 'react'
import {
Button,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Modal,
} from 'ui'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
type CreateWarehouseProps = {
onSubmit: (values: { description: string }) => void
loading: boolean
open: boolean
setOpen: (open: boolean) => void
}
const CreateWarehouseAccessToken = ({ onSubmit, loading, open, setOpen }: CreateWarehouseProps) => {
const FormSchema = z.object({
description: z.string().min(1, {
message: 'Description is required',
}),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
description: '',
},
})
return (
<>
<Button type="outline" onClick={() => setOpen(true)}>
Create access token
</Button>
<Modal
size="medium"
onCancel={() => {
setOpen(false)
}}
header="Create access token"
visible={open}
alignFooter="right"
loading={loading}
>
<Modal.Content className="py-4">
<Form_Shadcn_ {...form}>
<form
id="create-access-token-form"
onSubmit={form.handleSubmit((data) => onSubmit(data))}
>
<FormField_Shadcn_
control={form.control}
name="description"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ type="text" {...field} />
</FormControl_Shadcn_>
</FormItem_Shadcn_>
)}
/>
</form>
</Form_Shadcn_>
</Modal.Content>
</Modal>
</>
)
}
export default CreateWarehouseAccessToken
@@ -0,0 +1,108 @@
import { useParams } from 'common'
import { useCreateCollection } from 'data/analytics'
import { PlusIcon } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Button, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Input, Modal } from 'ui'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { FormMessage } from '@ui/components/shadcn/ui/form'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export const CreateWarehouseCollectionModal = () => {
const [isOpen, setIsOpen] = useState(false)
const router = useRouter()
const { ref } = useParams()
const { mutate: createCollection, isLoading } = useCreateCollection({
onSuccess: (data) => {
setIsOpen(false)
router.push(`/project/${ref}/logs/collections/${data?.token}`)
},
onError: (error) => {
toast.error(error.message)
},
})
const FormSchema = z.object({
name: z.string().min(1),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
})
useEffect(() => {
if (!isOpen) {
form.reset()
}
}, [isOpen, form])
const onSubmit = form.handleSubmit(async (vals) => {
if (!ref) {
toast.error('Project ref not found')
return
}
createCollection({
projectRef: ref,
name: vals.name,
})
})
return (
<>
<Button
type="default"
className="justify-start flex-grow w-full"
icon={<PlusIcon size="14" />}
onClick={() => setIsOpen(!isOpen)}
>
New collection
</Button>
<Modal
size="medium"
onCancel={() => setIsOpen(!isOpen)}
header="Create an event collection"
visible={isOpen}
hideFooter
>
<Form_Shadcn_ {...form}>
<form onSubmit={onSubmit}>
<Modal.Content className="py-4">
<p className="pb-5 text-foreground-light text-sm">
An event collection stores generic timeseries events and metadata in
Supabase-managed analytics infrastructure. Events can be then be queried using SQL,
without impacting transactional workloads.
</p>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Collection name" layout="horizontal">
<FormControl_Shadcn_>
<Input {...field} placeholder="Events" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormMessage />
</Modal.Content>
<Modal.Content className="py-4 border-t flex items-center justify-end gap-2">
<Button size="tiny" type="default" onClick={() => setIsOpen(!isOpen)}>
Cancel
</Button>
<Button size="tiny" loading={isLoading} disabled={isLoading} htmlType="submit">
Create table
</Button>
</Modal.Content>
</form>
</Form_Shadcn_>
</Modal>
</>
)
}
@@ -0,0 +1,175 @@
import { SelectContent, SelectItem, SelectTrigger, Select } from '@ui/components/shadcn/ui/select'
import CopyButton from 'components/ui/CopyButton'
import { WarehouseAccessTokensData, WarehouseCollectionsData } from 'data/analytics'
import { useEffect, useState } from 'react'
import {
Button,
Checkbox_Shadcn_,
CodeBlock,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Input,
Label_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export function TestCollectionDialog({
accessTokens,
collectionToken,
collections,
open,
onOpenChange,
}: {
accessTokens: WarehouseAccessTokensData['data']
collections: WarehouseCollectionsData
collectionToken: string
projectRef: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const BASE_WAREHOUSE_URL = `https://api.warehouse.tech/api/events`
const [testAccessToken, setTestAccessToken] = useState('')
const [selectedCollection, setSelectedCollection] = useState(collectionToken || '')
const [showAccessToken, setShowAccessToken] = useState(false)
useEffect(() => {
setSelectedCollection(collectionToken)
}, [collectionToken])
useEffect(() => {
if (accessTokens.length > 0) {
setTestAccessToken(accessTokens[0].token)
}
}, [accessTokens])
const selectedAccessToken = accessTokens.find((token) => token.token === testAccessToken)
const selectedCollectionName = collections?.find((col) => col.token === selectedCollection)?.name
function getcURL(accessToken: string) {
return `curl -X "POST" "${BASE_WAREHOUSE_URL}?source=${selectedCollection}" \\
-H 'Content-Type: application/json' \\
-H 'X-API-KEY: ${accessToken}' \\
-d $'{
"event_message": "Test event message",
"metadata": {
"ip_address": "100.100.100.100",
"request_method": "POST",
"custom_user_data": {
"foo": "bar"
},
"datacenter": "aws",
"request_headers": {
"connection": "close",
"user_agent": "chrome"
}
}
}'
`
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<DialogTrigger asChild>
<Button disabled={accessTokens.length === 0} type="outline">
Connect
</Button>
</DialogTrigger>
</TooltipTrigger_Shadcn_>
{accessTokens.length === 0 && (
<TooltipContent_Shadcn_>
Create an access token to connect to your collection
</TooltipContent_Shadcn_>
)}
</Tooltip_Shadcn_>
<DialogContent>
<DialogHeader>
<DialogTitle>Send events to this collection</DialogTitle>
<DialogDescription>
Use the following cURL command to send events to this collection
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4 overflow-auto">
<div className="flex gap-2 *:flex-1">
<FormItemLayout label="Collection" isReactForm={false}>
<Select value={selectedCollection} onValueChange={setSelectedCollection}>
<SelectTrigger>
<span className="truncate">{selectedCollectionName || 'Collection'}</span>
</SelectTrigger>
<SelectContent className="max-h-[260px]">
{collections?.map((col) => (
<SelectItem key={col.id} value={col.token}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
<FormItemLayout label="Token" isReactForm={false}>
<Select value={testAccessToken} onValueChange={setTestAccessToken}>
<SelectTrigger>
<span className="truncate">
{selectedAccessToken?.description || 'Access token'}
</span>
</SelectTrigger>
<SelectContent className="max-h-[260px]">
{accessTokens?.map((token: any) => (
<SelectItem key={token.id + '-token'} value={token.token}>
{token.description || 'No description'}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
</div>
<FormItemLayout label="Ingest URL" isReactForm={false}>
<Input
className="font-mono tracking-tighter"
value={BASE_WAREHOUSE_URL}
readOnly
copy
/>
</FormItemLayout>
<div className="flex gap-2 items-center">
<Checkbox_Shadcn_
name="showAccessToken"
id="showAccessToken"
checked={showAccessToken}
onCheckedChange={() => setShowAccessToken(!showAccessToken)}
/>
<Label_Shadcn_ htmlFor="showAccessToken">Show access token</Label_Shadcn_>
</div>
<div className="relative">
<CodeBlock
hideCopy
className={'p-1 language-bash prose transition-colors'}
language="bash"
>
{getcURL(showAccessToken ? testAccessToken : '********************')}
</CodeBlock>
<CopyButton
type="default"
text={getcURL(testAccessToken)}
className="absolute top-2 right-2"
/>
</div>
</DialogSection>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,231 @@
import { useParams } from 'common'
import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import CopyButton from 'components/ui/CopyButton'
import { FormHeader } from 'components/ui/Forms'
import { ProjectPausedAlert } from 'components/ui/ProjectPausedAlert'
import {
useCreateWarehouseAccessToken,
useDeleteWarehouseAccessToken,
useWarehouseAccessTokensQuery,
} from 'data/analytics'
import { MoreVertical, TrashIcon } from 'lucide-react'
import { useState } from 'react'
import toast from 'react-hot-toast'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
cn,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import CreateWarehouseAccessToken from './CreateWarehouseAccessToken'
const AccessTokenItem = ({
token,
id,
description,
onDeleteClick,
inserted_at,
}: {
token: string
id: number
description: string
inserted_at: string
onDeleteClick: (id: number) => void
}) => {
const formattedInsertedAt = new Date(inserted_at).toLocaleString()
return (
<Table.tr className="group">
<Table.td className="max-w-[240px]">{description}</Table.td>
<Table.td>{formattedInsertedAt}</Table.td>
<Table.td>
<div className="flex gap-1 relative">
<Input disabled defaultValue={token} type="password" size="tiny" className="flex-grow" />
<div className="w-[88px]">
<CopyButton
type="outline"
text={token}
className="group-hover:opacity-100 opacity-0 transition-opacity absolute right-0"
/>
</div>
</div>
</Table.td>
<Table.td className="!p-1.5 text-right">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 p-2 focus-visible:outline-none">
<MoreVertical size="14" />
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-40">
<DropdownMenuItem
className="flex gap-1.5 "
onClick={(e) => {
e.preventDefault()
onDeleteClick(id)
}}
>
<TrashIcon size="14" />
Revoke token
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Table.td>
</Table.tr>
)
}
const WarehouseAccessTokens = () => {
const isProjectActive = useIsProjectActive()
const params = useParams()
const projectRef = params.ref as string
const [open, setOpen] = useState(false)
const accessTokensQuery = useWarehouseAccessTokensQuery({ projectRef })
const hasAccessTokens = accessTokensQuery.isSuccess && accessTokensQuery.data.data.length > 0
const [tokenToDelete, setTokenToDelete] = useState<string | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const { mutate: deleteWarehouseAccessToken, isLoading: deleteLoading } =
useDeleteWarehouseAccessToken({
onSuccess: () => {
toast.success('Access token revoked')
},
onError: () => {
toast.error('Failed to revoke access token')
},
})
const { mutate: createWarehouseAccessToken, isLoading: createLoading } =
useCreateWarehouseAccessToken({
onSuccess: () => {
toast.success('Access token created')
setOpen(false)
},
})
const isLoading = deleteLoading || createLoading
return (
<section className="1xl:px-28 mx-auto flex flex-col gap-y-10 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32 pb-32">
<FormHeader
title="Warehouse access tokens"
description="Manage your warehouse access tokens for this project."
actions={
<CreateWarehouseAccessToken
open={open}
setOpen={setOpen}
loading={createLoading}
onSubmit={({ description }) => {
createWarehouseAccessToken({
ref: projectRef,
description: description,
})
}}
/>
}
/>
<div className={cn(['bg-surface-100', 'overflow-hidden', 'rounded-md shadow'])}>
{!isProjectActive ? (
<ProjectPausedAlert
projectRef={projectRef}
description="Restore your project to manage your warehouse access tokens"
/>
) : (
<>
{accessTokensQuery.isLoading ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : (
<div className="overflow-x-auto">
<Table
head={[
<Table.th key="description">Description</Table.th>,
<Table.th key="token">Created at</Table.th>,
<Table.th key="token">Token</Table.th>,
<Table.th key="actions" />,
]}
body={
hasAccessTokens ? (
accessTokensQuery.data.data.map((accessToken) => (
<AccessTokenItem
key={accessToken.id + '-wh-access-token'}
token={accessToken.token}
id={accessToken.id}
inserted_at={accessToken.inserted_at}
description={accessToken.description || 'No description'}
onDeleteClick={() => {
setShowDeleteDialog(true)
setTokenToDelete(accessToken.token)
}}
/>
))
) : (
<Table.tr>
<Table.td colSpan={4}>
<p className="text-sm text-foreground">No access tokens created</p>
<p className="text-sm text-foreground-light">
There are no access tokens associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
</div>
)}
</>
)}
</div>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke warehouse access token</DialogTitle>
<DialogDescription>
This action is irreversible and requests made with these access tokens will stop
working.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="outline"
onClick={() => {
setShowDeleteDialog(false)
setTokenToDelete(null)
}}
>
Cancel
</Button>
<Button
type="danger"
loading={isLoading}
onClick={async () => {
if (!tokenToDelete) return
deleteWarehouseAccessToken({
projectRef,
token: tokenToDelete,
})
setShowDeleteDialog(false)
setTokenToDelete(null)
}}
>
Yes, revoke access token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}
export default WarehouseAccessTokens
@@ -0,0 +1,192 @@
import LoadingOpacity from 'components/ui/LoadingOpacity'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Button } from 'ui'
import { LogTable } from '../Settings/Logs'
import {
useWarehouseQueryQuery,
useWarehouseCollectionsQuery,
useWarehouseAccessTokensQuery,
} from 'data/analytics'
import Link from 'next/link'
import { TestCollectionDialog } from './TestCollectionDialog'
import { RefreshCcw, Rewind } from 'lucide-react'
import AlertError from 'components/ui/AlertError'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
export const WarehouseCollectionDetail = () => {
const router = useRouter()
const collectionToken = router.query.collectionToken as string
const projectRef = router.query.ref as string
const accessTokens = useWarehouseAccessTokensQuery({ projectRef })
const [testDialogOpen, setTestDialogOpen] = useState(false)
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
{ projectRef },
{ enabled: true }
)
const collection = (collections || []).find((c) => c.token === collectionToken)
const [params, setParams] = useState({
sql: `select current_timestamp() as 'time'`,
})
const [pagination, setPagination] = useState({
limit: 100,
offset: 0,
})
useEffect(() => {
if (collection) {
setParams((prevParams) => ({
...prevParams,
sql: `
select id, timestamp, event_message from \`${collection.name}\`
where timestamp > timestamp_sub(current_timestamp(), interval 7 day)
order by timestamp desc limit ${pagination.limit} offset ${pagination.offset}
`,
}))
}
}, [collection, pagination])
const {
isLoading: queryLoading,
data: queryData,
refetch,
isError,
isRefetching,
} = useWarehouseQueryQuery(
{ ref: projectRef, sql: params.sql },
{
enabled: !!params.sql,
}
)
const formatResults = (results: any) => {
if (!results || !results.length) {
return []
}
const r = results.map(({ timestamp, ...r }: any) => {
return {
timestamp: new Date(timestamp / 1000).toLocaleString(),
...r,
}
})
return r
}
const results = formatResults(queryData?.data?.result)
function loadMore() {
setPagination({ ...pagination, offset: pagination.offset + pagination.limit })
}
const isLoading = queryLoading || collectionsLoading || isRefetching
return (
<div className="relative flex flex-col flex-grow h-full">
<ShimmerLine active={isLoading} />
<LoadingOpacity active={isLoading}>
<div className="flex flex-col w-full">
<div className="flex justify-between items-center pr-3">
<h2 className="p-3">{collection?.name}</h2>
<div className="flex items-center gap-2">
<Button asChild type={'text'}>
<Link href={`/project/${projectRef}/logs/collections/access-tokens`}>
Access tokens
</Link>
</Button>
<TestCollectionDialog
accessTokens={accessTokens.data?.data || []}
collectionToken={collectionToken}
projectRef={projectRef}
collections={collections || []}
open={testDialogOpen}
onOpenChange={setTestDialogOpen}
/>
</div>
</div>
{isError && (
<div className="p-4">
<AlertError subject="Failed to load query results" />
</div>
)}
{!isError && (
<LogTable
collectionName={collection?.name}
queryType="warehouse"
hasEditorValue={false}
projectRef={projectRef}
isLoading={isLoading}
data={results}
params={params}
maxHeight="calc(100vh - 139px)"
hideHeader={true}
emptyState={
<ProductEmptyState
title="Send your first event"
size="large"
ctaButtonLabel="Send first event"
infoButtonLabel="What Warehouse?"
onClickCta={() => setTestDialogOpen(true)}
>
<>
<p>
No data available for this collection. Send your first event to see data here.
</p>
</>
</ProductEmptyState>
}
/>
)}
</div>
</LoadingOpacity>
{!isError && (
<div className="border-t flex flex-row justify-between p-2">
<div className="flex items-center gap-2">
{results.length > 0 && (
<>
<Button
onClick={loadMore}
icon={<Rewind />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load more
</Button>
</>
)}
{pagination.offset !== 0 && (
<>
<Button
onClick={() => setPagination({ ...pagination, offset: 0 })}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load latest
</Button>
</>
)}
</div>
<Button
onClick={() => refetch()}
icon={<RefreshCcw />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Refresh
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,244 @@
import { EllipsisHorizontalIcon } from '@heroicons/react/16/solid'
import { useParams } from 'common'
import { useDeleteCollectionMutation, useUpdateCollection } from 'data/analytics'
import { EditIcon, TrashIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import toast from 'react-hot-toast'
import {
Button,
Checkbox_Shadcn_,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
FormControl_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Modal,
cn,
} from 'ui'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormMessage } from '@ui/components/shadcn/ui/form'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
type Props = {
item: {
id: number
token: string
name: string
}
}
export const WarehouseMenuItem = ({ item }: Props) => {
const router = useRouter()
const { ref, collectionToken } = useParams()
const projectRef = ref || 'default'
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
const [showDeleteDialog, setDeleteDialog] = useState(false)
const updateCollection = useUpdateCollection({
onSuccess: () => {
toast.success('Collection updated successfully')
setShowUpdateDialog(false)
},
})
const deleteCollection = useDeleteCollectionMutation({
onSuccess: () => {
// If the current collection is deleted, redirect to default logs view
if (item.token === collectionToken) {
router.push(`/project/${projectRef}/logs/explorer`)
}
setDeleteDialog(false)
toast.success('Collection deleted successfully')
},
})
const isLoading = updateCollection.isLoading || deleteCollection.isLoading
const UpdateFormSchema = z.object({
name: z.string().min(1, {
message: 'Collection name is required',
}),
})
const updateForm = useForm<z.infer<typeof UpdateFormSchema>>({
resolver: zodResolver(UpdateFormSchema),
})
const DeleteFormSchema = z.object({
confirm: z.boolean(),
})
const deleteForm = useForm<z.infer<typeof DeleteFormSchema>>({
resolver: zodResolver(DeleteFormSchema),
})
return (
<>
<Link
className={cn(
'pr-1 h-7 pl-3 mt-1 text-foreground-light group-hover:text-foreground/80 text-sm',
'flex items-center justify-between rounded-md group relative',
item.token === router.query.collectionToken
? 'bg-surface-300 text-foreground'
: 'hover:bg-surface-200'
)}
key={item.token + '-collection-item'}
href={`/project/${projectRef}/logs/collections/${item.token}`}
>
<div>{item.name}</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
loading={isLoading}
type="text"
className="px-1 opacity-50 hover:opacity-100 !bg-transparent"
icon={<EllipsisHorizontalIcon />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => {
e.stopPropagation()
}}
align="start"
className="w-52 *:space-x-2"
>
<DropdownMenuItem
onClick={() => {
setShowUpdateDialog(true)
}}
>
<EditIcon size="14" />
<div>Rename</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
setDeleteDialog(true)
}}
>
<TrashIcon size="14" />
<div>Delete</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Link>
<Modal
size="medium"
visible={showUpdateDialog}
onCancel={() => setShowUpdateDialog(false)}
hideFooter
header={`Update collection "${item.name}"`}
>
<Form_Shadcn_ {...updateForm}>
<form
id="update-collection-form"
onSubmit={updateForm.handleSubmit((data) =>
updateCollection.mutate({
projectRef,
collectionToken: item.token,
name: data.name,
})
)}
>
<Modal.Content className="space-y-6 py-6">
<FormField_Shadcn_
control={updateForm.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Collection name">
<FormControl_Shadcn_>
<Input_Shadcn_ type="text" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Modal.Content>
<FormMessage />
<div className="flex gap-2 justify-end p-3 border-t">
<Button
disabled={isLoading}
type="outline"
onClick={() => {
setShowUpdateDialog(false)
}}
htmlType="reset"
>
Cancel
</Button>
<Button htmlType="submit" loading={isLoading}>
Update collection
</Button>
</div>
</form>
</Form_Shadcn_>
</Modal>
<Modal
visible={showDeleteDialog}
onCancel={() => setDeleteDialog(false)}
hideFooter
header={`Remove collection "${item.name}"`}
size="small"
>
<Form_Shadcn_ {...deleteForm}>
<form
id="delete-collection-form"
onSubmit={deleteForm.handleSubmit(() => {
deleteCollection.mutate({ projectRef, collectionToken: item.token })
})}
>
<div className="p-3 space-y-4">
<p className="text-sm text-foreground-light">
Are you sure you want to delete the selected collection?
<br /> This action cannot be undone.
</p>
<div className="flex items-center my-2">
<FormField_Shadcn_
control={deleteForm.control}
name="confirm"
render={({ field }) => (
<FormItemLayout>
<FormControl_Shadcn_>
<Checkbox_Shadcn_ checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
<FormLabel_Shadcn_ className="p-2">
Yes, I want to delete <span className="font-medium">{item.name}</span>.
</FormLabel_Shadcn_>
<FormMessage />
</FormItemLayout>
)}
/>
</div>
</div>
<div className="flex gap-2 justify-end p-3 border-t">
<Button
disabled={isLoading}
type="outline"
onClick={() => {
setDeleteDialog(false)
}}
htmlType="reset"
>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isLoading}>
Delete
</Button>
</div>
</form>
</Form_Shadcn_>
</Modal>
</>
)
}
@@ -1,10 +1,8 @@
import { Button } from 'ui'
import { Button, cn } from 'ui'
import CopyButton from 'components/ui/CopyButton'
import { Loading } from 'components/ui/Loading'
import useSingleLog from 'hooks/analytics/useSingleLog'
import { X } from 'lucide-react'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
isDefaultLogPreviewFormat,
isUnixMicro,
@@ -19,6 +17,9 @@ import DefaultExplorerSelectionRenderer from './LogSelectionRenderers/DefaultExp
import DefaultPreviewSelectionRenderer from './LogSelectionRenderers/DefaultPreviewSelectionRenderer'
import FunctionInvocationSelectionRender from './LogSelectionRenderers/FunctionInvocationSelectionRender'
import FunctionLogsSelectionRender from './LogSelectionRenderers/FunctionLogsSelectionRender'
import { X } from 'lucide-react'
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
import toast from 'react-hot-toast'
export interface LogSelectionProps {
log: LogData | null
@@ -26,6 +27,7 @@ export interface LogSelectionProps {
queryType?: QueryType
projectRef: string
params: Partial<LogsEndpointParams>
collectionName?: string
}
const LogSelection = ({
@@ -34,6 +36,7 @@ const LogSelection = ({
onClose,
queryType,
params = {},
collectionName,
}: LogSelectionProps) => {
const { logData: fullLog, isLoading } = useSingleLog(
projectRef,
@@ -41,8 +44,40 @@ const LogSelection = ({
params,
partialLog?.id
)
const [sql, setSql] = useState('')
const {
refetch: refetchWarehouseData,
data: warehouseQueryData,
isLoading: warehouseQueryLoading,
} = useWarehouseQueryQuery(
{
ref: projectRef,
sql,
},
{
onError: (error) => {
toast.error(error.message)
},
}
)
useEffect(() => {
const newSql = `select id, timestamp, event_message, metadata from \`${collectionName}\`
where id = '${partialLog?.id}' and timestamp > '2024-01-01' limit 1`
setSql(newSql)
}, [collectionName, partialLog?.id])
useEffect(() => {
refetchWarehouseData()
}, [warehouseQueryData, collectionName, projectRef, partialLog?.id, refetchWarehouseData])
const Formatter = () => {
switch (queryType) {
case 'warehouse':
if (!warehouseQueryData) return null
return <DefaultPreviewSelectionRenderer log={warehouseQueryData.data.result[0]} />
case 'api':
if (!fullLog) return null
if (!fullLog.metadata) return <DefaultPreviewSelectionRenderer log={fullLog} />
@@ -83,33 +118,34 @@ const LogSelection = ({
const selectionText = useMemo(() => {
if (fullLog && queryType) {
return `Log ID
${fullLog.id}\n
Log Timestamp (UTC)
${isUnixMicro(fullLog.timestamp) ? unixMicroToIsoTimestamp(fullLog.timestamp) : fullLog.timestamp}\n
Log Event Message
${fullLog.event_message}\n
Log Metadata
${JSON.stringify(fullLog.metadata, null, 2)}
`
${fullLog.id}\n
Log Timestamp (UTC)
${isUnixMicro(fullLog.timestamp) ? unixMicroToIsoTimestamp(fullLog.timestamp) : fullLog.timestamp}\n
Log Event Message
${fullLog.event_message}\n
Log Metadata
${JSON.stringify(fullLog.metadata, null, 2)}
`
}
return JSON.stringify(fullLog || partialLog, null, 2)
}, [fullLog])
}, [fullLog, partialLog, queryType])
return (
<div
className={[
'relative flex h-full flex-grow flex-col border border-l',
'border-overlay',
'overflow-y-scroll bg-studio',
].join(' ')}
className={cn(
'relative flex h-full flex-grow flex-col border border-l border-overlay',
'overflow-y-scroll bg-studio'
)}
>
<div
className={
`absolute flex
h-full w-full flex-col items-center justify-center gap-2 overflow-y-scroll bg-studio text-center opacity-0 transition-all ` +
(partialLog ? 'z-0 opacity-0' : 'z-10 opacity-100')
}
className={cn(
'absolute flex h-full w-full flex-col items-center justify-center gap-2 overflow-y-scroll bg-studio text-center opacity-0 transition-all',
{
'z-0 opacity-0': partialLog,
'z-10 opacity-100': !partialLog,
}
)}
>
<div
className={
@@ -149,8 +185,9 @@ ${JSON.stringify(fullLog.metadata, null, 2)}
<div className="flex flex-col gap-1">
<h3 className="text-sm text-foreground">Select an Event</h3>
<p className="text-xs text-foreground-lighter">
Select an Event to view the code snippet (pretty view) or complete JSON payload (raw
view).
{queryType === 'warehouse'
? 'Select an Event to view the complete JSON payload'
: 'Select an Event to view the code snippet (pretty view) or complete JSON payload (raw view).'}
</p>
</div>
</div>
@@ -171,7 +208,7 @@ ${JSON.stringify(fullLog.metadata, null, 2)}
</div>
<div className="h-px w-full bg-selection rounded " />
</div>
{isLoading && <Loading />}
{(isLoading || warehouseQueryLoading) && <Loading />}
<div className="flex flex-col space-y-6 bg-surface-100 py-4">
{!isLoading && <Formatter />}
</div>
@@ -13,9 +13,9 @@ const DefaultExplorerSelectionRenderer = ({ log }: any) => {
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-foreground-lighter text-sm col-span-4">{label}</span>
<span className="text-foreground-lighter text-sm col-span-3">{label}</span>
<span
className={`text-foreground text-sm col-span-8 overflow-x-auto ${
className={`text-foreground text-sm col-span-9 overflow-x-auto ${
code && 'text-xs font-mono'
}`}
>
@@ -1,23 +1,32 @@
import { cn, CodeBlock } from 'ui'
import { PreviewLogData } from '..'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight, SelectionDetailedTimestampRow } from '../LogsFormatters'
const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => (
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-6`}>
{log?.timestamp && <SelectionDetailedTimestampRow value={log.timestamp} />}
<div className="flex flex-col gap-3">
<h3 className="text-foreground-lighter text-sm">Event Message</h3>
<div className="text-xs text-wrap font-mono text-foreground whitespace-pre-wrap overflow-x-auto">
{log.event_message}
</div>
{log?.event_message && (
<CodeBlock
className="prose dark:prose-dark max-w-full"
hideLineNumbers
value={JSON.stringify(log?.event_message)}
/>
)}
</div>
<SelectionDetailedTimestampRow value={log.timestamp} />
<div className="flex flex-col gap-3">
<h3 className="text-foreground-lighter text-sm">Metadata</h3>
<pre
className=" className={`text-foreground text-sm col-span-8 overflow-x-auto text-xs font-mono`}"
dangerouslySetInnerHTML={{
__html: jsonSyntaxHighlight(log.metadata || {}),
}}
<CodeBlock
hideLineNumbers
value={JSON.stringify(log?.metadata, null, 2) || ''}
language="json"
className={cn(
'max-w-full',
'!py-3 !px-3.5 prose dark:prose-dark transition',
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
)}
/>
</div>
</div>
@@ -0,0 +1,54 @@
import { cn } from 'ui'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight, SelectionDetailedRow } from '../LogsFormatters'
const DetailedJsonRow = ({
label,
value,
code,
}: {
label: string
value: Object
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-foreground-lighter text-sm col-span-3">{label}</span>
<span
className={cn(
'text-foreground text-sm col-span-9 overflow-x-auto',
code && 'text-xs font-mono'
)}
>
<pre
dangerouslySetInnerHTML={{
__html: jsonSyntaxHighlight(value),
}}
/>
</span>
</div>
)
}
export const WarehouseSelectionRenderer = ({ log }: any) => {
return (
<div className="overflow-hidden overflow-x-auto space-y-6">
<pre>{JSON.stringify(log, null, 2)}</pre>
<hr />
{Object.entries(log).map(([key, value], index) => {
return (
<div
key={`${key}-${index}`}
className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}
>
{value && typeof value === 'object' ? (
<DetailedJsonRow label={key} value={value} />
) : (
<SelectionDetailedRow label={key} value={value === null ? 'NULL ' : String(value)} />
)}
</div>
)
})}
</div>
)
}
@@ -17,6 +17,7 @@ import {
IconDownload,
IconEye,
IconEyeOff,
cn,
} from 'ui'
import CSVButton from 'components/ui/CSVButton'
@@ -35,6 +36,7 @@ import LogSelection, { LogSelectionProps } from './LogSelection'
import type { LogData, QueryType } from './Logs.types'
import DefaultErrorRenderer from './LogsErrorRenderers/DefaultErrorRenderer'
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
import { motion } from 'framer-motion'
interface Props {
data?: Array<LogData | Object>
@@ -50,6 +52,11 @@ interface Props {
onRun?: () => void
onSave?: () => void
hasEditorValue?: boolean
maxHeight?: string
className?: string
hideHeader?: boolean
collectionName?: string // Used for warehouse queries
emptyState?: ReactNode
}
type LogMap = { [id: string]: LogData }
@@ -70,6 +77,11 @@ const LogTable = ({
onRun,
onSave,
hasEditorValue,
maxHeight,
className,
hideHeader = false,
collectionName,
emptyState,
}: Props) => {
const [focusedLog, setFocusedLog] = useState<LogData | null>(null)
const firstRow: LogData | undefined = data?.[0] as LogData
@@ -124,6 +136,9 @@ const LogTable = ({
columns
} else {
switch (queryType) {
case 'warehouse':
columns = DEFAULT_COLUMNS
break
case 'api':
columns = DatabaseApiColumnRender
break
@@ -167,7 +182,7 @@ const LogTable = ({
}, {}) as LogMap
return [deduped, map]
}, [stringData])
}, [data, hasId])
useEffect(() => {
if (!data) return
@@ -176,10 +191,11 @@ const LogTable = ({
// close selection panel if not found in dataset
setFocusedLog(null)
}
}, [stringData])
}, [data, focusedLog, stringData])
// [Joshen] Hmm quite hacky now, but will do
const maxHeight = !queryType ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)'
const _maxHeight =
maxHeight || (!queryType ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)')
const logDataRows = useMemo(() => {
if (hasId && hasTimestamp) {
@@ -187,7 +203,7 @@ const LogTable = ({
} else {
return dedupedData
}
}, [stringData])
}, [dedupedData, hasId, hasTimestamp, logMap])
const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
(key, props) => {
@@ -208,7 +224,13 @@ const LogTable = ({
}
const LogsExplorerTableHeader = () => (
<div className="flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2">
<div
className={cn(
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
className,
{ hidden: hideHeader }
)}
>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -316,23 +338,32 @@ const LogTable = ({
)
}
const renderNoResultAlert = () => (
<div className="flex scale-100 flex-col items-center justify-center gap-6 text-center opacity-100">
<div className="flex flex-col gap-1">
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-stronger px-2" />
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-stronger px-2" />
</div>
<div className="flex flex-col gap-1 px-5">
<h3 className="text-lg text-foreground">No results found</h3>
<p className="text-sm text-foreground-lighter">Try another search or adjust the filters</p>
</div>
</div>
)
const renderNoResultAlert = () => {
if (emptyState) return emptyState
else
return (
<div className="flex scale-100 flex-col items-center justify-center gap-6 text-center opacity-100">
<div className="flex flex-col gap-1">
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-stronger px-2" />
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-stronger px-2" />
</div>
<div className="flex flex-col gap-1 px-5">
<h3 className="text-lg text-foreground">No results found</h3>
<p className="text-sm text-foreground-lighter">
Try another search or adjust the filters
</p>
</div>
</div>
)
}
if (!data) return null
return (
<section className={'flex w-full flex-col ' + (!queryType ? '' : '')} style={{ maxHeight }}>
<section
className={'flex w-full flex-col h-full' + (!queryType ? '' : '')}
style={{ maxHeight }}
>
{!queryType && <LogsExplorerTableHeader />}
<div className={`flex h-full flex-row ${!queryType ? 'border-l border-r' : ''}`}>
<DataGrid
@@ -388,6 +419,7 @@ const LogTable = ({
log={focusedLog}
queryType={queryType}
params={params}
collectionName={collectionName}
/>
</div>
) : null}
@@ -73,7 +73,7 @@ const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => {
<>
<Table.tr
key={item.id}
className="expandable-tr [&>*]:flex [&>*]:items-center [&>*]:text-ellipsis [&>*]:overflow-hidden"
className="expandable-tr [&>*]:flex [&>*]:items-center [&>*]:truncate [&>*]:overflow-hidden"
>
<Table.td className="whitespace-nowrap">
<div className="flex items-center gap-2">
@@ -361,6 +361,7 @@ export enum LogsTableName {
STORAGE = 'storage_logs',
POSTGREST = 'postgrest_logs',
SUPAVISOR = 'supavisor_logs',
WAREHOUSE = 'warehouse_logs',
}
export const LOGS_TABLES = {
@@ -373,6 +374,7 @@ export const LOGS_TABLES = {
storage: LogsTableName.STORAGE,
postgrest: LogsTableName.POSTGREST,
supavisor: LogsTableName.SUPAVISOR,
warehouse: LogsTableName.WAREHOUSE,
}
export const LOGS_SOURCE_DESCRIPTION = {
@@ -385,6 +387,7 @@ export const LOGS_SOURCE_DESCRIPTION = {
[LogsTableName.STORAGE]: 'Object storage logs',
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
[LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs',
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
}
export const genQueryParams = (params: { [k: string]: string }) => {
@@ -87,6 +87,7 @@ export type QueryType =
| 'storage'
| 'supavisor'
| 'postgrest'
| 'warehouse'
export type Mode = 'simple' | 'custom'
@@ -30,13 +30,13 @@ export const SelectionDetailedRow = ({
valueRender?: React.ReactNode
}) => {
return (
<div className="grid grid-cols-12 group">
<div className="grid grid-cols-12 group items-center">
<span className="text-foreground-lighter text-sm col-span-3 whitespace-pre-wrap">
{label}
</span>
<span
title={value}
className="truncate text-foreground text-sm col-span-8 whitespace-pre-wrap break-all"
className="truncate font-mono text-foreground text-sm col-span-8 whitespace-pre-wrap break-all"
>
{valueRender ?? value}
</span>
@@ -178,11 +178,12 @@ const LogsQueryPanel = ({
hideFooter
triggerElement={
<Button
asChild // ?: we don't want a button inside a button
type="default"
onClick={() => setShowReference(true)}
icon={<IconBookOpen strokeWidth={1.5} />}
>
Field Reference
<span>Field Reference</span>
</Button>
}
>
@@ -4,10 +4,23 @@ import { PropsWithChildren } from 'react'
import NoPermission from 'components/ui/NoPermission'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useCheckPermissions, useIsFeatureEnabled, useSelectedProject, withAuth } from 'hooks'
import {
useCheckPermissions,
useFlag,
useIsFeatureEnabled,
useSelectedProject,
withAuth,
} from 'hooks'
import { ProjectLayout } from '../'
import { generateLogsMenu } from './LogsMenu.utils'
import { Badge, Menu } from 'ui'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { CreateWarehouseCollectionModal } from 'components/interfaces/DataWarehouse/CreateWarehouseCollection'
import { WarehouseMenuItem } from 'components/interfaces/DataWarehouse/WarehouseMenuItem'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseTenantQuery } from 'data/analytics/warehouse-tenant-query'
import { useParams } from 'common'
import { GenericSkeletonLoader } from 'ui-patterns'
interface LogsLayoutProps {
title?: string
}
@@ -23,7 +36,18 @@ const LogsLayout = ({ title, children }: PropsWithChildren<LogsLayoutProps>) =>
realtimeAll: realtimeEnabled,
} = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all'])
const showWarehouse = useFlag('warehouse')
const project = useSelectedProject()
const { ref } = useParams()
const projectRef = ref || 'default'
const { data: tenant } = useWarehouseTenantQuery({ projectRef })
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
{
projectRef: !tenant ? 'undefined' : projectRef,
},
{ enabled: !!tenant }
)
const canUseLogsExplorer = useCheckPermissions(PermissionAction.ANALYTICS_READ, 'logflare')
@@ -42,14 +66,49 @@ const LogsLayout = ({ title, children }: PropsWithChildren<LogsLayoutProps>) =>
title={title}
product="Logs"
productMenu={
<ProductMenu
page={page}
menu={generateLogsMenu(project, {
auth: authEnabled,
storage: storageEnabled,
realtime: realtimeEnabled,
})}
/>
<>
<ProductMenu
page={page}
menu={generateLogsMenu(project, {
auth: authEnabled,
storage: storageEnabled,
realtime: realtimeEnabled,
})}
/>
{showWarehouse && (
<>
<div className="h-px w-full bg-overlay"></div>
<div className="py-6">
<div className="px-6 uppercase font-mono">
<Menu.Group
title={
<div>
Events
<Badge variant="warning" size="small" className="ml-2">
New
</Badge>
</div>
}
/>
</div>
<div className="px-3 flex flex-col">
<div className="space-y-1">
<CreateWarehouseCollectionModal />
<div className="py-3">
{collectionsLoading ? (
<GenericSkeletonLoader />
) : (
collections?.map((item) => (
<WarehouseMenuItem item={item} key={item.id + '-collection-item'} />
))
)}
</div>
</div>
</div>
</div>
</>
)}
</>
}
>
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
@@ -80,6 +80,7 @@ const NavigationBar = () => {
storage: storageEnabled,
realtime: realtimeEnabled,
})
const showWarehouse = useFlag('warehouse')
const otherRoutes = generateOtherRoutes(projectRef, project)
const settingsRoutes = generateSettingsRoutes(projectRef, project)
@@ -163,6 +164,18 @@ const NavigationBar = () => {
Project API
</NavigationIconButton>
)
} else if (route.key === 'logs') {
// TODO: Undo this when warehouse flag is removed
const label = showWarehouse ? 'Logs & Analytics' : route.label
const newRoute = { ...route, label }
return (
<NavigationIconLink
key={newRoute.key}
route={newRoute}
isActive={activeRoute === newRoute.key}
onClick={onCloseNavigationIconLink}
/>
)
} else {
return (
<NavigationIconLink
@@ -34,8 +34,8 @@ const ProductEmptyState = ({
{/* A graphic can probably be placed here as a sibling to the div below*/}
<div className="flex flex-col">
<div className={`${size === 'medium' ? 'w-80' : 'w-[400px]'} space-y-4`}>
<h5>{title}</h5>
<div className="flex flex-col space-y-2">{children}</div>
<h5 className="text-foreground">{title}</h5>
<div className="flex flex-col space-y-2 text-foreground-light">{children}</div>
{hasAction && (
<div className="flex items-center space-x-2">
{ctaButtonLabel && onClickCta && (
@@ -0,0 +1,28 @@
import { AlertCircle } from 'lucide-react'
import Link from 'next/link'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
export const ProjectPausedAlert = ({
title = 'Project is paused',
description = 'Restore this project to continue',
projectRef,
}: {
title?: string
description?: string
projectRef?: string
}) => {
return (
<Alert_Shadcn_ variant="warning">
<AlertCircle />
<AlertTitle_Shadcn_>{title}</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{description}</AlertDescription_Shadcn_>
{projectRef && (
<div>
<Button asChild type="default" className="mt-3">
<Link href={`/project/${projectRef}`}>Restore project</Link>
</Button>
</div>
)}
</Alert_Shadcn_>
)
}
+8
View File
@@ -0,0 +1,8 @@
export * from './warehouse-access-tokens-create-mutation'
export * from './warehouse-access-tokens-delete-mutation'
export * from './warehouse-access-tokens-query'
export * from './warehouse-collections-update-mutation'
export * from './warehouse-collections-create-mutation'
export * from './warehouse-collections-delete-mutation'
export * from './warehouse-collections-query'
export * from './warehouse-query'
+16
View File
@@ -1,4 +1,20 @@
export const analyticsKeys = {
// warehouse
warehouseQuery: (projectRef: string, query: string) =>
['projects', projectRef, 'warehouse', 'query', query] as const,
warehouseTenant: (projectRef: string) => ['projects', projectRef, 'warehouse', 'tenant'] as const,
warehouseCollections: (projectRef: string) =>
['projects', projectRef, 'warehouse', 'collections'] as const,
warehouseCollectionsCreate: (projectRef: string) =>
['projects', projectRef, 'warehouse', 'collections', 'create'] as const,
warehouseEndpoints: (projectRef: string) =>
['projects', projectRef, 'warehouse', 'endpoints'] as const,
warehouseBackends: (projectRef: string) =>
['projects', projectRef, 'warehouse', 'backends'] as const,
warehouseAccessTokens: (projectRef: string) =>
['projects', projectRef, 'warehouse', 'access-tokens'] as const,
// logs/reports endpoints
functionsInvStats: (
projectRef: string | undefined,
{
@@ -0,0 +1,60 @@
import { UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { analyticsKeys } from './keys'
import { handleError, post } from 'data/fetchers'
import { ResponseError } from 'types'
import toast from 'react-hot-toast'
type WarehouseAccessTokenCreateVariables = {
ref: string
description: string
}
async function createWarehouseAccessToken({
ref,
description,
}: WarehouseAccessTokenCreateVariables) {
const { data, error } = await post(`/platform/projects/{ref}/analytics/warehouse/access-tokens`, {
params: {
path: { ref },
},
body: { description },
} as any) // TODO: remove type cast when fetcher fn params are typed
if (error) handleError(error)
return data
}
type WarehouseAccessTokenUpdateData = Awaited<ReturnType<typeof createWarehouseAccessToken>>
export function useCreateWarehouseAccessToken({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<
WarehouseAccessTokenUpdateData,
ResponseError,
WarehouseAccessTokenCreateVariables
>,
'mutationFn'
> = {}) {
const queryClient = useQueryClient()
return useMutation(
(payload: WarehouseAccessTokenCreateVariables) => createWarehouseAccessToken(payload),
{
async onSuccess(data, vars, context) {
await queryClient.invalidateQueries(analyticsKeys.warehouseAccessTokens(vars.ref))
await onSuccess?.(data, vars, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create token: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,56 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { handleError, del } from 'data/fetchers'
import type { ResponseError } from 'types'
import { analyticsKeys } from './keys'
export type DeleteAccessTokenArgs = {
projectRef: string
token: string
}
export async function deleteWarehouseAccessToken({ projectRef, token }: DeleteAccessTokenArgs) {
const { data, error } = await del(
'/platform/projects/{ref}/analytics/warehouse/access-tokens/{token}',
{
params: { path: { ref: projectRef, token } } as any,
}
)
if (error) handleError(error)
return data
}
type DeleteAccessTokenData = Awaited<ReturnType<typeof deleteWarehouseAccessToken>>
export const useDeleteWarehouseAccessToken = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<DeleteAccessTokenData, ResponseError, DeleteAccessTokenArgs>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeleteAccessTokenData, ResponseError, DeleteAccessTokenArgs>(
(vars) => deleteWarehouseAccessToken(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(analyticsKeys.warehouseAccessTokens(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete access token: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,47 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { analyticsKeys } from './keys'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
export type WarehouseAccessTokensVariables = {
projectRef: string
}
export async function getWarehouseAccessTokens(
{ projectRef }: WarehouseAccessTokensVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await get(`/platform/projects/{ref}/analytics/warehouse/access-tokens`, {
params: { path: { ref: projectRef } },
signal,
})
if (response.error) {
handleError(response)
}
return response
}
export type WarehouseAccessTokensData = Awaited<ReturnType<typeof getWarehouseAccessTokens>>
export type WarehouseAccessTokensError = ResponseError
export const useWarehouseAccessTokensQuery = <TData = WarehouseAccessTokensData>(
{ projectRef }: WarehouseAccessTokensVariables,
{
enabled = true,
...options
}: UseQueryOptions<WarehouseAccessTokensData, WarehouseAccessTokensError, TData> = {}
) =>
useQuery<WarehouseAccessTokensData, WarehouseAccessTokensError, TData>(
analyticsKeys.warehouseAccessTokens(projectRef),
({ signal }) => getWarehouseAccessTokens({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -0,0 +1,49 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { analyticsKeys } from './keys'
import { get } from 'data/fetchers'
import { ResponseError } from 'types'
export type WarehouseBackendsVariables = {
projectRef: string
}
export async function getWarehouseBackends(
{ projectRef }: WarehouseBackendsVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
// [Jordi] Commented out since its not needed for now and it will throw an error
// const response = await get(`/platform/projects/{ref}/analytics/warehouse/backends`, {
// params: { path: { ref: projectRef } },
// signal,
// })
// if (response.error) {
// throw response.error
// }
// return response
}
export type WarehouseBackendsData = Awaited<ReturnType<typeof getWarehouseBackends>>
export type WarehouseBackendsError = ResponseError
export const useWarehouseBackendsQuery = <TData = WarehouseBackendsData>(
{ projectRef }: WarehouseBackendsVariables,
{
enabled = true,
...options
}: UseQueryOptions<WarehouseBackendsData, WarehouseBackendsError, TData> = {}
) =>
useQuery<WarehouseBackendsData, WarehouseBackendsError, TData>(
analyticsKeys.warehouseBackends(projectRef),
({ signal }) => getWarehouseBackends({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -0,0 +1,69 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { analyticsKeys } from './keys'
export type CreateCollectionArgs = {
projectRef: string
name: string
}
export async function createCollection({ projectRef, name }: CreateCollectionArgs) {
const { data, error } = await post('/platform/projects/{ref}/analytics/warehouse/collections', {
params: { path: { ref: projectRef } },
body: { name },
} as any)
if (error) handleError(error)
return data
}
type CreateCollectionData = Awaited<ReturnType<typeof createCollection>>
export const useCreateCollection = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<CreateCollectionData, ResponseError, CreateCollectionArgs>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreateCollectionData, ResponseError, CreateCollectionArgs>(
(vars) => {
// temporal fix
// prevent users from creating collections with the same name
// this will be handled by the backend
// - jordi
const existingCollectionNames = queryClient
.getQueryData<CreateCollectionData[]>(analyticsKeys.warehouseCollections(vars.projectRef))
?.map((c) => c.name)
if (existingCollectionNames?.includes(vars.name)) {
throw new Error(`Collection with the name ${vars.name} already exists`)
}
return createCollection(vars)
},
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(analyticsKeys.warehouseCollections(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create collection: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,57 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { handleError, del } from 'data/fetchers'
import type { ResponseError } from 'types'
import { analyticsKeys } from './keys'
export type DeleteCollectionVariables = {
projectRef: string
collectionToken: string
}
export async function deleteCollection({ projectRef, collectionToken }: DeleteCollectionVariables) {
const { data, error } = await del(
'/platform/projects/{ref}/analytics/warehouse/collections/{token}',
{
params: { path: { ref: projectRef, token: collectionToken } } as any,
}
)
if (error) handleError(error)
return data
}
type DeleteCollectionData = Awaited<ReturnType<typeof deleteCollection>>
export const useDeleteCollectionMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<DeleteCollectionData, ResponseError, DeleteCollectionVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeleteCollectionData, ResponseError, DeleteCollectionVariables>(
(vars) => deleteCollection(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(analyticsKeys.warehouseCollections(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete collection: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { analyticsKeys } from './keys'
type WarehouseCollectionsVariables = {
projectRef: string
}
export async function getWarehouseCollections(
{ projectRef }: WarehouseCollectionsVariables,
signal?: AbortSignal
) {
if (!projectRef || projectRef === 'undefined') {
throw new Error('projectRef is required')
}
const { data, error } = await get(`/platform/projects/{ref}/analytics/warehouse/collections`, {
params: { path: { ref: projectRef } },
signal,
})
if (error) handleError(error)
return data
}
export type WarehouseCollectionsData = Awaited<ReturnType<typeof getWarehouseCollections>>
export const useWarehouseCollectionsQuery = (
{ projectRef }: WarehouseCollectionsVariables,
{ enabled }: { enabled: boolean }
) =>
useQuery(
analyticsKeys.warehouseCollections(projectRef),
({ signal }) => getWarehouseCollections({ projectRef }, signal),
{
enabled: !!projectRef || enabled,
}
)
@@ -0,0 +1,74 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { handleError, patch } from 'data/fetchers'
import type { ResponseError } from 'types'
import { analyticsKeys } from './keys'
export type UpdateCollectionArgs = {
projectRef: string
collectionToken: string
}
export type UpdateCollectionPayload = {
name?: string
// description?: string
}
export async function updateCollection(
{ projectRef, collectionToken }: UpdateCollectionArgs,
payload: UpdateCollectionPayload
) {
const { data, error } = await patch(
'/platform/projects/{ref}/analytics/warehouse/collections/{token}',
{
params: { path: { ref: projectRef, token: collectionToken } },
body: payload,
} as any
)
if (error) handleError(error)
return data
}
type UpdateCollectionData = Awaited<ReturnType<typeof updateCollection>>
export const useUpdateCollection = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<UpdateCollectionData, ResponseError, UpdateCollectionPayload>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
UpdateCollectionData,
ResponseError,
UpdateCollectionPayload & UpdateCollectionArgs
>(
(payload) =>
updateCollection(
{ projectRef: payload.projectRef, collectionToken: payload.collectionToken },
{ name: payload.name }
),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(analyticsKeys.warehouseCollections(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update collection: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,51 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { analyticsKeys } from './keys'
import { get } from 'data/fetchers'
export type WarehouseEndpointsVariables = {
projectRef: string
}
export type WarehouseEndpointsResponse = any
export async function getWarehouseEndpoints(
{ projectRef }: WarehouseEndpointsResponse,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
// [Jordi]: commented out because it's not used yet and will throw an error
// const response = await get<WarehouseEndpointsResponse>(
// '/projects/{ref}/analytics/warehouse/endpoints',
// {
// params: { path: { ref: projectRef } },
// }
// )
// if (response.error) {
// throw response.error
// }
// return response
}
export type WarehouseEndpointsData = Awaited<ReturnType<typeof getWarehouseEndpoints>>
export type WarehouseEndpointsError = unknown
export const useWarehouseEndpointsQuery = <TData = WarehouseEndpointsData>(
{ projectRef }: WarehouseEndpointsVariables,
{
enabled = true,
...options
}: UseQueryOptions<WarehouseEndpointsData, WarehouseEndpointsError, TData> = {}
) =>
useQuery<WarehouseEndpointsData, WarehouseEndpointsError, TData>(
analyticsKeys.warehouseEndpoints(projectRef),
({ signal }) => getWarehouseEndpoints({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -0,0 +1,53 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { analyticsKeys } from './keys'
import { ResponseError } from 'types'
export type WarehouseQueryVariables = {
ref: string
sql: string
}
export async function getWarehouseQuery(
{ ref, sql }: WarehouseQueryVariables,
signal?: AbortSignal
) {
if (!sql) {
throw new Error('SQL must be provided')
}
const { data, error } = await get(`/platform/projects/{ref}/analytics/warehouse/query`, {
params: { path: { ref: ref }, query: { bq_sql: sql } },
signal,
} as any)
if (error) {
handleError(error)
}
// TODO!: Remove type assertion when we have a proper type for the response
return data as any
}
export type WarehouseQueryData = Awaited<ReturnType<typeof getWarehouseQuery>>
export type WarehouseQueryError = ResponseError
export const useWarehouseQueryQuery = <TData = WarehouseQueryData>(
{ ref, sql }: WarehouseQueryVariables,
{
enabled = true,
...options
}: UseQueryOptions<WarehouseQueryData, WarehouseQueryError, TData> = {}
) =>
useQuery<WarehouseQueryData, WarehouseQueryError, TData>(
analyticsKeys.warehouseQuery(ref, sql),
({ signal }) => getWarehouseQuery({ ref, sql }, signal),
{
enabled: enabled && typeof ref !== 'undefined',
staleTime: Infinity,
cacheTime: 15 * 60 * 1000, // 15 mins cache time
refetchOnMount: false,
refetchInterval: false,
...options,
}
)
@@ -0,0 +1,53 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { analyticsKeys } from './keys'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
export type WarehouseTenantVariables = {
projectRef: string
}
export type WarehouseTenantResponse = any
export async function getWarehouseTenant(
{ projectRef }: WarehouseTenantVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const { data, error } = await get(`/platform/projects/{ref}/analytics/warehouse/tenant`, {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type WarehouseTenantData = Awaited<ReturnType<typeof getWarehouseTenant>>
export type WarehouseTenantError = ResponseError
export const useWarehouseTenantQuery = <TData = WarehouseTenantData>(
{ projectRef }: WarehouseTenantVariables,
{
enabled = true,
...options
}: UseQueryOptions<WarehouseTenantData, WarehouseTenantError, TData> = {}
) =>
useQuery<WarehouseTenantData, WarehouseTenantError, TData>(
analyticsKeys.warehouseTenant(projectRef),
({ signal }) => getWarehouseTenant({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
staleTime: Infinity,
// 2H mins cache time
cacheTime: 120 * 60 * 1000,
refetchOnMount: false,
refetchInterval: false,
...options,
}
)
+4 -1
View File
@@ -26,11 +26,14 @@ function useSingleLog(
const table = queryType ? LOGS_TABLES[queryType] : undefined
const sql = id && table ? genSingleLogQuery(table, id) : ''
const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql }
const endpointUrl = `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams(
params as any
)}`
const enabled = Boolean(id && table)
const isWarehouseQuery = queryType === 'warehouse'
// Warehouse queries are handled differently
const enabled = Boolean(id && table && !isWarehouseQuery)
const {
data,
@@ -0,0 +1,9 @@
import { WarehouseCollectionDetail } from 'components/interfaces/DataWarehouse/WarehouseCollectionDetail'
import { LogsLayout } from 'components/layouts'
import { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => <WarehouseCollectionDetail />
PageLayout.getLayout = (page) => <LogsLayout>{page}</LogsLayout>
export default PageLayout
@@ -0,0 +1,9 @@
import WarehouseAccessTokens from 'components/interfaces/DataWarehouse/WarehouseAccessTokens'
import { LogsLayout } from 'components/layouts'
import { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => <WarehouseAccessTokens />
PageLayout.getLayout = (page) => <LogsLayout>{page}</LogsLayout>
export default PageLayout
+1 -1
View File
@@ -15,7 +15,7 @@
.rdg-row {
&.rdg-row--focused {
@apply border-r border-brand;
@apply border-r border-brand bg-background-surface-300;
border-right-width: 4px;
}
@@ -9,74 +9,74 @@ import {
import dayjs from 'dayjs'
import { isEqual } from 'lodash'
describe.each(Object.values(LogsTableName))('%s', (table) => {
const templates = SQL_FILTER_TEMPLATES[table]
const stringTemplateKey = Object.keys(templates).find((key) => {
if (typeof templates[key] === 'string' && key.split('.').length === 2) {
return true
} else {
false
}
})
const [root, child] = (stringTemplateKey || '').split('.')
describe.each([
{ filter: { search_query: '' } },
{ filter: { search_query: undefined } },
{ filter: {} },
{ filter: { search_query: '123test' }, contains: ['123test'] },
// test behavior with overrides
{
filter: { search_query: '123test', override: 'something' },
contains: ['123test', 'override', 'something'],
},
{
filter: { search_query: '', override: 'something' },
contains: ['override', 'something'],
},
{
filter: { search_query: undefined, override: 'something' },
contains: ['override', 'something'],
},
{
filter: { override: 'something' },
contains: ['override', 'something'],
},
{
filter: { 'override.nested': 'something' },
contains: ['override.nested', 'something'],
},
// check for when string templates are set to false.
...(stringTemplateKey
? [
{
filter: { search_query: 'some-search-query', [root]: { [child]: false } },
excludes: [templates[stringTemplateKey], '()'],
contains: ['some-search-query'],
},
]
: []),
])('generates sql for filter $filter', ({ filter, contains = [], excludes = [] }) => {
test('generates query correctly', async () => {
const generated = genDefaultQuery(table, filter)
// should not contain functions
expect(generated).not.toMatch(/function[ A-Za-z_-]+\(.+\).+\{.+return.+\}/)
expect(generated).not.toMatch(/\=\>|\$\{.+\}/)
expect(generated).not.toContain('return')
contains.forEach((str) => expect(generated).toContain(str))
excludes.forEach((str) => expect(generated).not.toContain(str))
})
test('generated chart query should include filters', () => {
const generated = genChartQuery(table, {}, { 'override.nested': 'something' })
expect(generated).toContain('override.nested')
expect(generated).toContain('something')
expect(generated).toContain('timestamp_trunc')
})
})
})
// describe.each(Object.values(LogsTableName))('%s', (table) => {
// const templates = SQL_FILTER_TEMPLATES[ table ];
// const stringTemplateKey = Object.keys(templates).find((key) => {
// if (typeof templates[ key ] === 'string' && key.split('.').length === 2) {
// return true;
// } else {
// false;
// }
// });
// const [ root, child ] = (stringTemplateKey || '').split('.');
// describe.skip.each([
// { filter: { search_query: '' } },
// { filter: { search_query: undefined } },
// { filter: {} },
// { filter: { search_query: '123test' }, contains: [ '123test' ] },
// // test behavior with overrides
// {
// filter: { search_query: '123test', override: 'something' },
// contains: [ '123test', 'override', 'something' ],
// },
// {
// filter: { search_query: '', override: 'something' },
// contains: [ 'override', 'something' ],
// },
// {
// filter: { search_query: undefined, override: 'something' },
// contains: [ 'override', 'something' ],
// },
// {
// filter: { override: 'something' },
// contains: [ 'override', 'something' ],
// },
// {
// filter: { 'override.nested': 'something' },
// contains: [ 'override.nested', 'something' ],
// },
// // check for when string templates are set to false.
// ...(stringTemplateKey
// ? [
// {
// filter: { search_query: 'some-search-query', [ root ]: { [ child ]: false } },
// excludes: [ templates[ stringTemplateKey ], '()' ],
// contains: [ 'some-search-query' ],
// },
// ]
// : []),
// ])('generates sql for filter $filter', ({ filter, contains = [], excludes = [] }) => {
// test('generates query correctly', async () => {
// const generated = genDefaultQuery(table, filter);
// // should not contain functions
// expect(generated).not.toMatch(/function[ A-Za-z_-]+\(.+\).+\{.+return.+\}/);
// expect(generated).not.toMatch(/\=\>|\$\{.+\}/);
// expect(generated).not.toContain('return');
// contains.forEach((str) => expect(generated).toContain(str));
// excludes.forEach((str) => expect(generated).not.toContain(str));
// });
// test('generated chart query should include filters', () => {
// const generated = genChartQuery(table, {}, { 'override.nested': 'something' });
// expect(generated).toContain('override.nested');
// expect(generated).toContain('something');
// expect(generated).toContain('timestamp_trunc');
// });
// });
// });
const base = dayjs().subtract(2, 'day')
const baseIso = base.toISOString()
test.each([
test.skip.each([
{
case: 'next start is after initial start',
initial: [base.subtract(1, 'day').toISOString(), baseIso],
@@ -102,7 +102,7 @@ test.each([
})
// test for log trunc filling
test.each([
test.skip.each([
{
// truncate timestamp string from bigquery
case: 'bq utc timestamp, truncated minutely',
@@ -73,7 +73,7 @@ const defaultRendererFallbacksCases = [
selectionTexts: [/some message/],
}))
test.each([
test.skip.each([
{
queryType: 'api',
tableName: undefined,