Warehouse Clean Up (#37507)

This commit is contained in:
Jordi Enric authored and GitHub committed 2025-07-28 17:14:57 +02:00
1 parent 5c77b07aa4
commit e4837267f6
29 files changed
+148 -1834

No files matched your search

@@ -260,14 +260,6 @@ The table below shows the actions each role can take on the resources belonging
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| Events Collections | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| Warehouse Access Tokens | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Revoke | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| <a href="#branching-permissions" id="branching-permissions">**Branching**</a> | | | | | |
| Production Branch | Read | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Write | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
@@ -1,154 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Button,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Modal,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { Admonition } from 'ui-patterns'
import { useRouter } from 'next/router'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
export interface CollectionFormValues {
name: string
retention_days: number
}
interface CollectionFormProps {
initialValues?: Partial<CollectionFormValues>
onSubmit: (values: CollectionFormValues) => void
isLoading?: boolean
onCancelClick: () => void
}
export const CollectionForm = ({
initialValues,
onSubmit,
isLoading,
onCancelClick,
}: CollectionFormProps) => {
const { plan, isLoading: planLoading } = useCurrentOrgPlan()
const router = useRouter()
const currentOrg = useSelectedOrganization()
function getMaxRetentionDays() {
if (planLoading) return 1
if (plan?.id === 'free') return 3
else return 90
}
const maxRetentionDays = getMaxRetentionDays()
const FormSchema = z.object({
name: z.string().min(1),
retention_days: z.coerce.number().min(1).max(maxRetentionDays).multipleOf(1).positive().int(),
})
const form = useForm<CollectionFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: initialValues?.name || '',
retention_days: initialValues?.retention_days || maxRetentionDays,
},
})
const handleSubmit = form.handleSubmit(onSubmit)
const formatDays = (days: number) => {
return days === 1 ? `1 day` : `${days} days`
}
const retentionDescription = (days: number) => {
if (!Boolean(days) || days < 1)
return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}.</>
return (
<>
Your logs will be removed after {formatDays(days)}. <br />
Your plan allows for a maximum of {formatDays(maxRetentionDays)}.
</>
)
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={handleSubmit}>
<Modal.Content className="space-y-3">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Collection name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_ autoFocus placeholder="Events" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="retention_days"
render={({ field }) => (
<FormItemLayout
label="Retention"
layout="horizontal"
description={retentionDescription(Number(field.value))}
>
<FormControl_Shadcn_>
<div className="relative flex items-center gap-2">
<Input_Shadcn_ type="number" {...field} />
<span className="absolute right-3 text-foreground-lighter text-sm pointer-events-none">
days
</span>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Modal.Content>
{plan?.id === 'free' && (
<>
<Modal.Separator />
<Admonition
className="border-none bg-transparent"
type="default"
title="Upgrade your plan to increase retention"
description={
<div>
Upgrade to the pro plan to increase retention to 90 days.
<Button
className="mt-2"
type="primary"
onClick={() =>
router.push(
`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention`
)
}
>
Upgrade plan
</Button>
</div>
}
/>
</>
)}
<FormMessage_Shadcn_ />
<Modal.Content className="py-4 border-t flex items-center justify-end gap-2">
<Button size="tiny" type="default" onClick={onCancelClick}>
Cancel
</Button>
<Button size="tiny" loading={isLoading} disabled={isLoading} htmlType="submit">
Create collection
</Button>
</Modal.Content>
</form>
</Form_Shadcn_>
)
}
@@ -1,54 +0,0 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useCreateCollection } from 'data/analytics/warehouse-collections-create-mutation'
import { Modal } from 'ui'
import { CollectionForm } from './CollectionForm'
export const CreateWarehouseCollectionModal = ({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const router = useRouter()
const { ref } = useParams()
const { mutate: createCollection, isLoading } = useCreateCollection({
onSuccess: (data) => {
onOpenChange(false)
toast.success('Collection created successfully')
router.push(`/project/${ref}/logs/collections/${data.token}`)
},
onError: (error) => {
toast.error(error.message)
},
})
return (
<Modal
size="medium"
onCancel={() => onOpenChange(false)}
header="Create collection"
visible={open}
hideFooter
>
<CollectionForm
onCancelClick={() => onOpenChange(false)}
isLoading={isLoading}
onSubmit={(vals) => {
if (!ref) {
return
}
createCollection({
projectRef: ref,
name: vals.name,
retention_days: vals.retention_days,
})
}}
/>
</Modal>
)
}
@@ -1,186 +0,0 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '@ui/components/shadcn/ui/select'
import CopyButton from 'components/ui/CopyButton'
import type { WarehouseAccessTokensData } from 'data/analytics/warehouse-access-tokens-query'
import type { WarehouseCollectionsData } from 'data/analytics/warehouse-collections-query'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import {
Button,
Checkbox_Shadcn_,
CodeBlock,
Input_Shadcn_,
Label_Shadcn_,
Separator,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export function TestCollectionDialog({
accessTokens,
collectionToken,
collections,
open,
onOpenChange,
onSubmit,
}: {
accessTokens: WarehouseAccessTokensData['data']
collections: WarehouseCollectionsData
collectionToken: string
projectRef: string
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: () => void
}) {
const router = useRouter()
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 'Authorization: Bearer ${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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button disabled={accessTokens.length === 0} type="outline">
Connect
</Button>
</SheetTrigger>
</TooltipTrigger>
{accessTokens.length === 0 && (
<TooltipContent>Create an access token to connect to your collection</TooltipContent>
)}
</Tooltip>
<SheetContent size="lg" className="h-screen overflow-auto flex flex-col">
<SheetHeader>
<SheetTitle>Send events to this collection</SheetTitle>
<SheetDescription>
Use the following cURL command to send events to this collection
</SheetDescription>
</SheetHeader>
<SheetSection className="flex flex-col gap-4 overflow-auto">
<FormItemLayout label="Collection" isReactForm={false} layout="horizontal">
<Select value={selectedCollection} onValueChange={setSelectedCollection}>
<SelectTrigger size="tiny">
<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} layout="horizontal">
<Select value={testAccessToken} onValueChange={setTestAccessToken}>
<SelectTrigger size="tiny">
<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>
<FormItemLayout layout="horizontal" label="Ingest API URL" isReactForm={false}>
<Input_Shadcn_ size="tiny" className="font-mono" value={BASE_WAREHOUSE_URL} readOnly />
</FormItemLayout>
<FormItemLayout layout="horizontal" label="Collection ID" isReactForm={false}>
<Input_Shadcn_ size="tiny" className="font-mono" value={selectedCollection} readOnly />
</FormItemLayout>
</SheetSection>
<Separator />
<SheetSection className="flex-1">
<div className="flex justify-between items-center">
<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>
<CopyButton type="default" text={getcURL(testAccessToken)} />
</div>
<div className="mt-4 relative">
<CodeBlock
hideCopy
className={'p-1 language-bash prose max-w-full transition-colors'}
language="bash"
>
{getcURL(showAccessToken ? testAccessToken : '********************')}
</CodeBlock>
</div>
</SheetSection>
<SheetFooter className="mt-auto">
<p className="text-sm text-foreground-light mr-auto">
Once you send an event, refresh the page to see it in the collection.
</p>
<div className="flex justify-end">
<Button onClick={onSubmit}>Refresh results</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -1,323 +0,0 @@
import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import AlertError from 'components/ui/AlertError'
import LoadingOpacity from 'components/ui/LoadingOpacity'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useWarehouseAccessTokensQuery } from 'data/analytics/warehouse-access-tokens-query'
import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
import dayjs from 'dayjs'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import { useFlag } from 'hooks/ui/useFlag'
import { RefreshCcw, Rewind, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
import { DatetimeHelper } from '../Settings/Logs/Logs.types'
import LogTable from '../Settings/Logs/LogTable'
import { TestCollectionDialog } from './TestCollectionDialog'
const INTERVALS: DatetimeHelper[] = [
{
text: 'Last hour',
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
default: true,
},
{
text: 'Last 12 hours',
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
},
{
text: 'Last day',
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
calcTo: () => dayjs().toISOString(),
},
{
text: 'Last 7 days',
calcFrom: () => dayjs().subtract(7, 'day').toISOString(),
calcTo: () => dayjs().toISOString(),
},
]
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 hasWarehouse = useFlag('warehouse')
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
{ projectRef },
{ enabled: hasWarehouse }
)
const collection = collections?.find((c) => c.token === collectionToken)
const [search, setSearch] = useState('')
const [filters, setFilters] = useState({
limit: 100,
offset: 0,
search: '',
interval: {
to: INTERVALS[0].calcTo,
from: INTERVALS[0].calcFrom,
},
})
const [sql, setSQL] = useState('')
useEffect(() => {
if (collection) {
const from = filters.interval.from()
const to = filters.interval.to()
const sql = `select id, timestamp from \`${collection.name}\`
where timestamp >= TIMESTAMP('${from}')
and timestamp <= TIMESTAMP('${to}')
order by timestamp desc limit ${filters.limit} offset ${filters.offset}
`
setSQL(sql)
}
}, [filters.interval, filters.limit, filters.offset, collection])
const {
isLoading: queryLoading,
data: queryData,
isError,
isRefetching,
} = useWarehouseQueryQuery(
{ ref: projectRef, sql },
{
enabled: !!sql && hasWarehouse,
}
)
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?.result)
function loadMore() {
setFilters({ ...filters, offset: filters.offset + filters.limit })
}
const isLoading = queryLoading || collectionsLoading || isRefetching
function refreshResults(e?: React.FormEvent<HTMLFormElement>) {
e?.preventDefault()
setFilters({
...filters,
search,
interval: {
to: () => dayjs().toISOString(),
from: () => filters.interval.from() || dayjs().subtract(1, 'hour').toISOString(),
},
})
}
const queryUrl = `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
sql || ''
)}&source=warehouse`
useKeyboardShortcuts(
{
r: () => {
document.getElementById('refresh-button')?.focus()
},
s: () => {
document.getElementById('search-input')?.focus()
},
},
['INPUT', 'BUTTON', 'TEXTAREA']
)
function KeyboardShortcut({ children }: { children: React.ReactNode }) {
return (
<span className="flex items-center gap-2 font-medium text-xs font-mono text-foreground-light border rounded-md px-1.5 py-0.5 border-b-2">
{children}
</span>
)
}
// Selected Log Detail
const [selectedLogId, setSelectedLogId] = useSelectedLog()
const {
data: selectedLogDetail,
isLoading: selectedLogDetailLoading,
error: selectedLogDetailError,
} = useWarehouseLogDetailQuery({
ref: projectRef,
collectionName: collection?.name,
logId: selectedLogId ?? undefined,
})
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 h-12 px-5">
<form onSubmit={refreshResults} className="flex items-center gap-2">
<h2 className="text-foreground-light max-w-60 truncate" title={collection?.name}>
{collection?.name}
</h2>
<Tooltip>
<TooltipTrigger asChild>
<Button
id="refresh-button"
htmlType="submit"
icon={<RefreshCcw />}
type="text"
loading={isLoading}
disabled={isLoading}
className="px-1.5"
/>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
Refresh <KeyboardShortcut>R</KeyboardShortcut>
</TooltipContent>
</Tooltip>
{/* <Input
type="search"
id="search-input"
className="w-52"
size="tiny"
placeholder="Search by event message"
value={search}
onChange={(e) => setSearch(e.target.value)}
/> */}
<LogsDatePicker
helpers={INTERVALS}
value={{
from: filters.interval.from(),
to: filters.interval.to(),
}}
onSubmit={(e) =>
setFilters({
...filters,
interval: {
to: () => e.to || '',
from: () => e.from || '',
},
})
}
/>
</form>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="px-1.5" type="outline" icon={<Terminal />}>
<Link href={queryUrl} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Open query in Collections Explorer
</TooltipContent>
</Tooltip>
<TestCollectionDialog
accessTokens={accessTokens.data?.data || []}
collectionToken={collectionToken}
projectRef={projectRef}
collections={collections || []}
open={testDialogOpen}
onOpenChange={setTestDialogOpen}
onSubmit={() => {
refreshResults()
setTestDialogOpen(false)
}}
/>
</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}
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
selectedLog={selectedLogDetail?.result[0] ?? undefined}
selectedLogError={selectedLogDetailError ?? undefined}
isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading}
data={results}
showHeader={false}
EmptyState={
<ProductEmptyState title="No events found" size="large">
<div className="space-y-4">
<p>Try adjusting your filters, send a test event or refresh the results.</p>
<div className="flex items-center gap-2">
<Button
onClick={() => {
setTestDialogOpen(true)
}}
>
Send test event
</Button>
<Button type="outline" onClick={() => refreshResults()}>
Refresh
</Button>
</div>
</div>
</ProductEmptyState>
}
/>
)}
</div>
</LoadingOpacity>
{!isError && (
<div className="border-t flex flex-row justify-between">
<div className="flex items-center gap-2 p-2">
{results.length > 0 && (
<Button
onClick={loadMore}
icon={<Rewind />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load more
</Button>
)}
{filters.offset !== 0 && (
<>
<Button
onClick={() => setFilters({ ...filters, offset: 0 })}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load latest
</Button>
</>
)}
</div>
</div>
)}
</div>
)
}
@@ -1,154 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Database, EditIcon, TrashIcon } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useDeleteCollectionMutation } from 'data/analytics/warehouse-collections-delete-mutation'
import { useUpdateCollection } from 'data/analytics/warehouse-collections-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
DropdownMenuItem,
DropdownMenuSeparator,
Modal,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { LogsSidebarItem } from '../Settings/Logs/SidebarV2/SidebarItem'
import { CollectionForm } from './CollectionForm'
type Props = {
item: {
id: number
token: string
name: string
retention_days: number
}
}
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 canUpdateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
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
return (
<>
<LogsSidebarItem
isActive={item.token === collectionToken}
label={item.name}
icon={<Database size="14" className="text-foreground-light" />}
href={`/project/${projectRef}/logs/collections/${item.token}`}
dropdownItems={
<>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
disabled={!canUpdateCollection}
onClick={(e) => {
e.preventDefault()
setShowUpdateDialog(true)
}}
>
<EditIcon className="mr-2" size={14} />
<div>Update collection</div>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateCollection && (
<TooltipContent side="right">
You need additional permissions to update collections
</TooltipContent>
)}
</Tooltip>
<DropdownMenuSeparator />
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
disabled={!canUpdateCollection}
onClick={(e) => {
e.preventDefault()
setDeleteDialog(true)
}}
>
<TrashIcon className="mr-2" size={14} />
<div>Delete collection</div>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateCollection && (
<TooltipContent side="right">
You need additional permissions to delete collections
</TooltipContent>
)}
</Tooltip>
</>
}
></LogsSidebarItem>
<Modal
size="medium"
visible={showUpdateDialog}
onCancel={() => setShowUpdateDialog(false)}
hideFooter
header={`Update collection "${item.name}"`}
>
<CollectionForm
onCancelClick={() => setShowUpdateDialog(false)}
isLoading={isLoading}
initialValues={{ name: item.name, retention_days: item.retention_days }}
onSubmit={({ name, retention_days }) => {
updateCollection.mutate({
projectRef,
collectionToken: item.token,
name,
retention_days,
})
}}
/>
</Modal>
<ConfirmationModal
variant="destructive"
visible={showDeleteDialog}
onCancel={() => setDeleteDialog(false)}
confirmLabel="Delete collection"
title={`Delete collection "${item.name}"`}
size="small"
onConfirm={() => {
deleteCollection.mutate({ projectRef, collectionToken: item.token })
}}
>
<p className="text-sm text-foreground-light">
All data in this collection will be deleted.
<br />
<div className="mt-2">This action cannot be undone.</div>
</p>
</ConfirmationModal>
</>
)
}
@@ -22,7 +22,6 @@ export interface LogSelectionProps {
onClose: () => void
queryType?: QueryType
projectRef: string
collectionName?: string
isLoading: boolean
error?: string | object
}
@@ -41,8 +40,6 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
if (!log) return <LogDetailEmptyState />
switch (queryType) {
// case 'warehouse':
// return <WarehouseSelectionRenderer log={log} />
case 'api':
const status = log?.metadata?.[0]?.response?.[0]?.status_code
const method = log?.metadata?.[0]?.request?.[0]?.method
@@ -1,52 +0,0 @@
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">
{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>
)
}
@@ -49,8 +49,6 @@ interface Props {
onSave?: () => void
hasEditorValue?: boolean
className?: string
collectionName?: string // Used for warehouse queries
warehouseError?: string
EmptyState?: ReactNode
showHeader?: boolean
showHistogramToggle?: boolean
@@ -79,7 +77,6 @@ const LogTable = ({
onSave,
hasEditorValue,
className,
collectionName,
EmptyState,
showHeader = true,
showHistogramToggle = true,
@@ -154,9 +151,6 @@ const LogTable = ({
columns
} else {
switch (queryType) {
case 'warehouse':
columns = DEFAULT_COLUMNS
break
case 'api':
columns = DatabaseApiColumnRender
break
@@ -460,7 +454,6 @@ const LogTable = ({
log={selectedLog}
error={selectedLogError}
queryType={queryType}
collectionName={collectionName}
/>
</ResizablePanel>
)}
@@ -369,7 +369,6 @@ export enum LogsTableName {
POSTGREST = 'postgrest_logs',
SUPAVISOR = 'supavisor_logs',
PGBOUNCER = 'pgbouncer_logs',
WAREHOUSE = 'warehouse_logs',
PG_UPGRADE = 'pg_upgrade_logs',
PG_CRON = 'pg_cron_logs',
}
@@ -384,7 +383,6 @@ export const LOGS_TABLES = {
storage: LogsTableName.STORAGE,
postgrest: LogsTableName.POSTGREST,
supavisor: LogsTableName.SUPAVISOR,
warehouse: LogsTableName.WAREHOUSE,
pg_upgrade: LogsTableName.PG_UPGRADE,
pg_cron: LogsTableName.POSTGRES,
pgbouncer: LogsTableName.PGBOUNCER,
@@ -401,7 +399,6 @@ export const LOGS_SOURCE_DESCRIPTION = {
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
[LogsTableName.SUPAVISOR]: 'Shared connection pooler logs for PostgreSQL',
[LogsTableName.PGBOUNCER]: 'Dedicated connection pooler for PostgreSQL',
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
[LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process',
[LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs',
}
@@ -90,7 +90,6 @@ export type QueryType =
| 'storage'
| 'supavisor'
| 'postgrest'
| 'warehouse'
| 'pg_upgrade'
| 'pg_cron'
| 'pgbouncer'
@@ -145,9 +144,3 @@ export interface DatetimeHelper {
disabled?: boolean
availableIn?: PlanId[]
}
export interface WarehouseCollection {
name: string
id: number
token: string
}
@@ -6,7 +6,6 @@ import { ReactNode, useState } from 'react'
import { IS_PLATFORM } from 'common'
import Table from 'components/to-be-cleaned/Table'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useFlag } from 'hooks/ui/useFlag'
import { logConstants } from 'shared-data'
import {
Badge,
@@ -28,23 +27,16 @@ import {
LogsTableName,
} from './Logs.constants'
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
import { LogsWarning, LogTemplate, WarehouseCollection } from './Logs.types'
import { WarehouseQueryTemplate } from './Warehouse.utils'
import { LogsWarning, LogTemplate } from './Logs.types'
export type SourceType = 'logs' | 'warehouse'
export interface LogsQueryPanelProps {
templates?: LogTemplate[]
warehouseTemplates?: WarehouseQueryTemplate[]
defaultFrom: string
defaultTo: string
warnings: LogsWarning[]
warehouseCollections: WarehouseCollection[]
dataSource: SourceType
onSelectTemplate: (template: LogTemplate) => void
onSelectWarehouseTemplate: (template: WarehouseQueryTemplate) => void
onSelectSource: (source: string) => void
onDateChange: (value: DatePickerValue) => void
onDataSourceChange: (sourceType: SourceType) => void
}
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
@@ -58,17 +50,12 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
const LogsQueryPanel = ({
templates = [],
warehouseTemplates = [],
defaultFrom,
defaultTo,
warnings,
warehouseCollections,
dataSource,
onSelectTemplate,
onSelectWarehouseTemplate,
onSelectSource,
onDateChange,
onDataSourceChange,
}: LogsQueryPanelProps) => {
const [showReference, setShowReference] = useState(false)
@@ -78,15 +65,12 @@ const LogsQueryPanel = ({
projectEdgeFunctionAll: edgeFunctionsEnabled,
} = useIsFeatureEnabled(['project_auth:all', 'project_storage:all', 'project_edge_function:all'])
const warehouseEnabled = useFlag('warehouse')
const logsTableNames = Object.entries(LogsTableName)
.filter(([key]) => {
if (key === 'AUTH') return authEnabled
if (key === 'STORAGE') return storageEnabled
if (key === 'FN_EDGE') return edgeFunctionsEnabled
if (key === 'PG_CRON') return false
if (key === 'WAREHOUSE') return false
return true
})
.map(([, value]) => value)
@@ -117,90 +101,31 @@ const LogsQueryPanel = ({
<div className="flex w-full items-center justify-between px-4 md:px-5 py-2 overflow-x-scroll no-scrollbar">
<div className="flex w-full flex-row items-center justify-between gap-x-4">
<div className="flex items-center gap-2">
{warehouseEnabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Data source{' '}
<span className="ml-2 font-mono opacity-50">
{dataSource === 'warehouse' ? 'collections' : 'logs'}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onClick={() => onDataSourceChange('logs')}>
<DropdownMenuItemContent name="Logs" desc="Logs for all Supabase products" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDataSourceChange('warehouse')}>
<DropdownMenuItemContent
name={
<span>
Collections <Badge variant="warning">NEW</Badge>
</span>
}
desc="Query your collections"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{dataSource === 'warehouse' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Templates
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-96 overflow-auto" side="bottom" align="start">
{warehouseTemplates.map((template) => (
<DropdownMenuItem
key={template.name}
onClick={() => onSelectWarehouseTemplate(template)}
>
<DropdownMenuItemContent name={template.name} desc={template.description} />
</DropdownMenuItem>
))}
{warehouseCollections.length === 0 && (
<DropdownMenuItem className="hover:bg-transparent cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[70vh] overflow-auto"
>
{logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name="No collections found"
desc="You can create collections in the left sidebar."
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
))}
</DropdownMenuContent>
</DropdownMenu>
{dataSource === 'logs' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[70vh] overflow-auto"
>
{dataSource === 'logs' &&
logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{dataSource === 'logs' && IS_PLATFORM && (
{IS_PLATFORM && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
@@ -222,16 +147,14 @@ const LogsQueryPanel = ({
</DropdownMenu>
)}
{dataSource === 'logs' && (
<LogsDatePicker
value={selectedDatePickerValue}
onSubmit={(value) => {
setSelectedDatePickerValue(value)
onDateChange(value)
}}
helpers={EXPLORER_DATEPICKER_HELPERS}
/>
)}
<LogsDatePicker
value={selectedDatePickerValue}
onSubmit={(value) => {
setSelectedDatePickerValue(value)
onDateChange(value)
}}
helpers={EXPLORER_DATEPICKER_HELPERS}
/>
<div
data-testid="log-explorer-warnings"
@@ -261,93 +184,92 @@ const LogsQueryPanel = ({
</Tooltip>
</div>
</div>
{dataSource === 'logs' && (
<SidePanel
size="large"
header={
<div className="flex flex-row justify-between items-center">
<h3>Field Reference</h3>
<Button
type="text"
className="px-1"
onClick={() => setShowReference(false)}
icon={<X />}
/>
</div>
}
visible={showReference}
cancelText="Close"
onCancel={() => setShowReference(false)}
hideFooter
triggerElement={
<SidePanel
size="large"
header={
<div className="flex flex-row justify-between items-center">
<h3>Field Reference</h3>
<Button
asChild // ?: we don't want a button inside a button
type="text"
onClick={() => setShowReference(true)}
icon={<BookOpen />}
className="px-2"
>
<span>Field Reference</span>
</Button>
}
>
<SidePanel.Content>
<div className="pt-4 pb-2 space-y-1">
<p className="text-sm">
The following table shows all the available paths that can be queried from each
respective source. Do note that to access nested keys, you would need to perform
the necessary{' '}
<Link
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
target="_blank"
rel="noreferrer"
className="text-brand"
>
unnesting joins
<ExternalLink
size="14"
className="ml-1 inline -translate-y-[2px]"
strokeWidth={1.5}
/>
</Link>
</p>
</div>
</SidePanel.Content>
<SidePanel.Separator />
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="edge_logs"
listClassNames="px-2"
className="px-1"
onClick={() => setShowReference(false)}
icon={<X />}
/>
</div>
}
visible={showReference}
cancelText="Close"
onCancel={() => setShowReference(false)}
hideFooter
triggerElement={
<Button
asChild // ?: we don't want a button inside a button
type="text"
onClick={() => setShowReference(true)}
icon={<BookOpen />}
className="px-2"
>
{logConstants.schemas.map((schema) => (
<Tabs.Panel
key={schema.reference}
id={schema.reference}
label={schema.name}
className="px-4 pb-4"
<span>Field Reference</span>
</Button>
}
>
<SidePanel.Content>
<div className="pt-4 pb-2 space-y-1">
<p className="text-sm">
The following table shows all the available paths that can be queried from each
respective source. Do note that to access nested keys, you would need to perform
the necessary{' '}
<Link
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
target="_blank"
rel="noreferrer"
className="text-brand"
>
<Table
head={[
<Table.th className="text-xs !p-2" key="path">
Path
</Table.th>,
<Table.th key="type" className="text-xs !p-2">
Type
</Table.th>,
]}
body={schema.fields
.sort((a: any, b: any) => a.path - b.path)
.map((field) => (
<Field key={field.path} field={field} />
))}
unnesting joins
<ExternalLink
size="14"
className="ml-1 inline -translate-y-[2px]"
strokeWidth={1.5}
/>
</Tabs.Panel>
))}
</Tabs>
</SidePanel>
)}
</Link>
</p>
</div>
</SidePanel.Content>
<SidePanel.Separator />
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="edge_logs"
listClassNames="px-2"
>
{logConstants.schemas.map((schema) => (
<Tabs.Panel
key={schema.reference}
id={schema.reference}
label={schema.name}
className="px-4 pb-4"
>
<Table
head={[
<Table.th className="text-xs !p-2" key="path">
Path
</Table.th>,
<Table.th key="type" className="text-xs !p-2">
Type
</Table.th>,
]}
body={schema.fields
.sort((a: any, b: any) => a.path - b.path)
.map((field) => (
<Field key={field.path} field={field} />
))}
/>
</Tabs.Panel>
))}
</Tabs>
</SidePanel>
</div>
</div>
</div>
@@ -1,26 +0,0 @@
export type WarehouseQueryTemplate = {
query: string
name: string
description: string
}
export function createWarehouseQueryTemplates(
collections: { name: string }[]
): WarehouseQueryTemplate[] {
return collections.flatMap((collection) => [
{
query: `select count(*) as event_count from \`${collection.name}\`
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)`,
name: `${collection.name} - Daily Event Count`,
description: `Count events in the last 24 hours from ${collection.name} collection`,
},
{
query: `select id, timestamp, event_message from \`${collection.name}\`
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)
and event_message like '%YOUR_TEXT_HERE%'
order by timestamp desc limit 10`,
name: `${collection.name} - Filter by text`,
description: `Select last 10 events from ${collection.name} collection`,
},
])
}
@@ -223,8 +223,6 @@ const ProjectLinks = () => {
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { securityLints, errorLints } = useLints()
const showWarehouse = useFlag('warehouse')
const activeRoute = router.pathname.split('/')[3]
const {
@@ -310,14 +308,11 @@ const ProjectLinks = () => {
</div>
)
} 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 (
<SideBarNavLink
key={`other-routes-${i}`}
route={newRoute}
active={activeRoute === newRoute.key}
route={route}
active={activeRoute === route.key}
/>
)
} else {
@@ -9,13 +9,9 @@ import {
useFeaturePreviewModal,
useUnifiedLogsPreview,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { CreateWarehouseCollectionModal } from 'components/interfaces/DataWarehouse/CreateWarehouseCollection'
import { WarehouseMenuItem } from 'components/interfaces/DataWarehouse/WarehouseMenuItem'
import SavedQueriesItem from 'components/interfaces/Settings/Logs/Logs.SavedQueriesItem'
import { LogsSidebarItem } from 'components/interfaces/Settings/Logs/SidebarV2/SidebarItem'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseTenantQuery } from 'data/analytics/warehouse-tenant-query'
import { useContentQuery } from 'data/content/content-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
@@ -27,14 +23,7 @@ import {
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import {
InnerSideBarEmptyPanel,
@@ -90,16 +79,11 @@ export function LogsSidebarMenuV2() {
const router = useRouter()
const { ref } = useParams() as { ref: string }
const warehouseEnabled = useFlag('warehouse')
const unifiedLogsFlagEnabled = useFlag('unifiedLogs')
const { selectFeaturePreview } = useFeaturePreviewModal()
const { enable: enableUnifiedLogs } = useUnifiedLogsPreview()
const [searchText, setSearchText] = useState('')
const [createCollectionOpen, setCreateCollectionOpen] = useState(false)
const canCreateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
const { data: tenantData } = useWarehouseTenantQuery({ projectRef: ref })
const {
projectAuthAll: authEnabled,
@@ -107,11 +91,6 @@ export function LogsSidebarMenuV2() {
realtimeAll: realtimeEnabled,
} = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all'])
const { data: whCollections, isLoading: whCollectionsLoading } = useWarehouseCollectionsQuery(
{ projectRef: ref },
{ enabled: IS_PLATFORM && warehouseEnabled && !!tenantData }
)
const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan()
const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free'
@@ -223,9 +202,6 @@ export function LogsSidebarMenuV2() {
const filteredOperationalLogs = OPERATIONAL_COLLECTIONS.filter((collection) => {
return collection?.name.toLowerCase().includes(searchText.toLowerCase())
})
const filteredWarehouse = whCollections?.filter((collection) => {
return collection.name.toLowerCase().includes(searchText.toLowerCase())
})
return (
<div className="pb-12 relative">
@@ -285,56 +261,11 @@ export function LogsSidebarMenuV2() {
></InnerSideBarFilterSearchInput>
</InnerSideBarFilters>
{warehouseEnabled ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem className="gap-x-2" asChild>
<Link href={`/project/${ref}/logs/explorer`}>
<FilePlus size={14} />
Create query
</Link>
</DropdownMenuItem>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem className="gap-x-2" asChild>
<button
onClick={() => setCreateCollectionOpen(true)}
className="w-full flex items-center text-xs px-2 py-1"
disabled={!canCreateCollection}
>
<Plus size={14} />
Create collection
</button>
</DropdownMenuItem>
</TooltipTrigger>
{!canCreateCollection && (
<TooltipContent>
You need additional permissions to create a collection
</TooltipContent>
)}
</Tooltip>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
type="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
onClick={() => router.push(`/project/${ref}/logs/explorer`)}
/>
)}
<CreateWarehouseCollectionModal
open={createCollectionOpen}
onOpenChange={setCreateCollectionOpen}
<Button
type="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
onClick={() => router.push(`/project/${ref}/logs/explorer`)}
/>
</div>
<div className="px-2">
@@ -361,17 +292,6 @@ export function LogsSidebarMenuV2() {
/>
)
})}
{whCollectionsLoading && warehouseEnabled ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : filteredWarehouse?.length ? (
<div>
{filteredWarehouse.map((collection) => (
<WarehouseMenuItem item={collection} key={collection.token}></WarehouseMenuItem>
))}
</div>
) : null}
</SidebarCollapsible>
{OPERATIONAL_COLLECTIONS.length > 0 && (
<>
-15
View File
@@ -1,19 +1,4 @@
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,
@@ -1,50 +0,0 @@
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')
}
// TODO: Remove typecast when codegen types are fixed
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
/**
* This will be deprecated or rewritten in favor of the new project API keys
*/
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,
}
)
@@ -1,19 +0,0 @@
import { useWarehouseQueryQuery } from './warehouse-query'
export function useWarehouseLogDetailQuery({
ref,
collectionName,
logId,
columns = ['id', 'timestamp'],
}: {
ref: string
collectionName?: string
logId?: string
columns?: string[]
}) {
const enabled = !!logId && !!collectionName
const sql = `select ${columns.join(',')} from \`${collectionName}\`
where id = '${logId}' and timestamp > '2024-01-01' limit 1`
return useWarehouseQueryQuery({ ref, sql }, { enabled })
}
@@ -1,70 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { analyticsKeys } from './keys'
export type CreateCollectionArgs = {
projectRef: string
name: string
retention_days: number
}
export async function createCollection({ projectRef, name, retention_days }: CreateCollectionArgs) {
const { data, error } = await post('/platform/projects/{ref}/analytics/warehouse/collections', {
params: { path: { ref: projectRef } },
body: { name, retention_days },
} 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,
}
)
}
@@ -1,57 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { del, handleError } 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,
}
)
}
@@ -1,41 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { IS_PLATFORM } from 'lib/constants'
import { analyticsKeys } from './keys'
type WarehouseCollectionsVariables = {
projectRef: string
}
export async function getWarehouseCollections(
{ projectRef }: WarehouseCollectionsVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
// TODO: Remove type cast when codegen types are fixed
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 } = { enabled: true }
) =>
useQuery(
analyticsKeys.warehouseCollections(projectRef),
({ signal }) => getWarehouseCollections({ projectRef }, signal),
{
enabled: enabled && !!projectRef && IS_PLATFORM,
}
)
@@ -1,75 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
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
retention_days?: number
}
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, retention_days: payload.retention_days }
),
{
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,
}
)
}
@@ -1,50 +0,0 @@
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 { result: any[] }
}
export type WarehouseQueryData = Awaited<ReturnType<typeof getWarehouseQuery>>
export type WarehouseQueryError = ResponseError
export const useWarehouseQueryQuery = <TData = WarehouseQueryData>(
{ ref, sql }: WarehouseQueryVariables,
{ enabled, ...options }: UseQueryOptions<WarehouseQueryData, WarehouseQueryError, TData> = {}
) =>
useQuery<WarehouseQueryData, WarehouseQueryError, TData>(
analyticsKeys.warehouseQuery(ref, sql),
({ signal }) => getWarehouseQuery({ ref, sql }, signal),
{
enabled,
staleTime: Infinity,
cacheTime: 15 * 60 * 1000, // 15 mins cache time
refetchOnMount: false,
refetchInterval: false,
...options,
}
)
@@ -1,53 +0,0 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { IS_PLATFORM } from 'lib/constants'
import { ResponseError } from 'types'
import { analyticsKeys } from './keys'
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 && !!projectRef && IS_PLATFORM,
staleTime: Infinity,
// 2H mins cache time
cacheTime: 120 * 60 * 1000,
refetchOnMount: false,
refetchInterval: false,
...options,
}
)
@@ -1,6 +1,5 @@
import { useQueryState } from 'nuqs'
// Used in logs and warehouse.
export function useSelectedLog() {
return useQueryState('log')
}
+1 -3
View File
@@ -33,9 +33,7 @@ function useSingleLog({
const params: LogsEndpointParams = { ...paramsToMerge, sql }
const isWarehouseQuery = queryType === 'warehouse'
// Warehouse queries are handled differently
const enabled = Boolean(id && table && !isWarehouseQuery)
const enabled = Boolean(id && table)
const {
data,
@@ -1,14 +0,0 @@
import { WarehouseCollectionDetail } from 'components/interfaces/DataWarehouse/WarehouseCollectionDetail'
import DefaultLayout from 'components/layouts/DefaultLayout'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => <WarehouseCollectionDetail />
PageLayout.getLayout = (page) => (
<DefaultLayout>
<LogsLayout>{page}</LogsLayout>
</DefaultLayout>
)
export default PageLayout
@@ -22,17 +22,14 @@ import {
maybeShowUpgradePrompt,
useEditorHints,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import LogsQueryPanel, { SourceType } from 'components/interfaces/Settings/Logs/LogsQueryPanel'
import LogsQueryPanel from 'components/interfaces/Settings/Logs/LogsQueryPanel'
import LogTable from 'components/interfaces/Settings/Logs/LogTable'
import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import { createWarehouseQueryTemplates } from 'components/interfaces/Settings/Logs/Warehouse.utils'
import DefaultLayout from 'components/layouts/DefaultLayout'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import LoadingOpacity from 'components/ui/LoadingOpacity'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
import { useContentQuery } from 'data/content/content-query'
import {
UpsertContentPayload,
@@ -55,8 +52,6 @@ import {
ResizablePanelGroup,
} from 'ui'
const PLACEHOLDER_WAREHOUSE_QUERY =
'-- Fetch the last 10 logs in the last 7 days \nselect id, timestamp, event_message from `COLLECTION_NAME` \nwhere timestamp > timestamp_sub(current_timestamp(), interval 7 day) \norder by timestamp desc limit 10'
const LOCAL_PLACEHOLDER_QUERY =
'select\n timestamp, event_message, metadata\n from edge_logs limit 5'
@@ -79,13 +74,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const { timestampStart, timestampEnd, setTimeRange } = useLogsUrlState()
const [editorValue, setEditorValue] = useState<string>(PLACEHOLDER_QUERY)
const [warehouseEditorId, setWarehouseEditorId] = useState<string>(uuidv4())
const [warehouseEditorValue, setWarehouseEditorValue] = useState<string>(
PLACEHOLDER_WAREHOUSE_QUERY
)
const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
const [warnings, setWarnings] = useState<LogsWarning[]>([])
const [sourceType, setSourceType] = useState<SourceType>((routerSource as SourceType) || 'logs')
const [selectedLog, setSelectedLog] = useState<any>(null)
const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
@@ -98,7 +88,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
type: 'log_sql',
})
const query = content?.content.find((x) => x.id === queryId)
const {
params,
logData,
@@ -112,24 +101,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
iso_timestamp_start: timestampStart,
iso_timestamp_end: timestampEnd,
},
sourceType === 'logs'
true
)
const {
refetch: runWarehouseQuery,
data: warehouseResults,
isFetching: warehouseFetching,
error: warehouseError,
} = useWarehouseQueryQuery(
{ ref: projectRef, sql: warehouseEditorValue },
{
enabled: false,
}
)
const results = sourceType === 'warehouse' ? warehouseResults?.result : logData
const isLoading = logsLoading || warehouseFetching
const { data: warehouseCollections } = useWarehouseCollectionsQuery({ projectRef })
const results = logData
const isLoading = logsLoading
const { mutate: upsertContent, isLoading: isUpsertingContent } = useContentUpsertMutation({
onError: (e) => {
@@ -187,40 +163,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const handleRun = (value?: string | React.MouseEvent<HTMLButtonElement>) => {
const query = typeof value === 'string' ? value || editorValue : editorValue
if (sourceType === 'warehouse') {
const whQuery = warehouseEditorValue
if (!warehouseCollections?.length) {
toast.error('You do not have any collections yet.')
return
}
// Check that a collection name is included in the query
const collectionNames = warehouseCollections?.map((collection) => collection.name)
const collectionExists = collectionNames?.find((collectionName) =>
whQuery.includes(collectionName)
)
if (!collectionExists) {
toast.error('Please specify a collection name in the query')
return
}
// Check that the user is not trying to query logs tables and warehouse collections at the same time
const logsSources = Object.values(LOGS_TABLES)
const logsSourceExists = logsSources.find((source) => whQuery.includes(source))
if (logsSourceExists) {
return toast.error('Cannot query logs tables from current query.')
}
runWarehouseQuery()
return router.push({
pathname: router.pathname,
query: { ...router.query, q: query },
})
}
changeQuery(query)
runQuery()
router.push({
@@ -231,30 +173,25 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
}
const handleInsertSource = (source: string) => {
if (sourceType === 'warehouse') {
//TODO: Only one collection can be queried at a time, we need to replace the current collection from the query for the new one
return setWarehouseEditorId(uuidv4())
} else {
if (editorRef.current && monaco) {
const editorModel = editorRef.current?.getModel()
const currentValue = editorRef.current.getValue()
const index = currentValue.indexOf('from')
if (editorRef.current && monaco) {
const editorModel = editorRef.current?.getModel()
const currentValue = editorRef.current.getValue()
const index = currentValue.indexOf('from')
const updatedValue =
index < 0
? `${currentValue}${source}`
: `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
const updatedValue =
index < 0
? `${currentValue}${source}`
: `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
editorRef.current.pushUndoStop()
editorRef.current.executeEdits(`insert-identifier`, [
{
text: updatedValue,
range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
},
])
editorRef.current.pushUndoStop()
editorRef.current.focus()
}
editorRef.current.pushUndoStop()
editorRef.current.executeEdits(`insert-identifier`, [
{
text: updatedValue,
range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
},
])
editorRef.current.pushUndoStop()
editorRef.current.focus()
}
}
@@ -316,14 +253,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
}
useEffect(() => {
if (warehouseError) toast.error(warehouseError.message)
}, [warehouseError])
useEffect(() => {
// on mount, set initial values
if (q) {
setEditorValue(q)
setWarehouseEditorValue(q)
}
}, [q])
@@ -368,43 +299,18 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
onDateChange={handleDateChange}
onSelectSource={handleInsertSource}
templates={TEMPLATES.filter((template) => template.mode === 'custom')}
warehouseCollections={warehouseCollections || []}
onSelectTemplate={onSelectTemplate}
warehouseTemplates={createWarehouseQueryTemplates(warehouseCollections || [])}
onSelectWarehouseTemplate={(template) => {
setWarehouseEditorValue(template.query)
setWarehouseEditorId(uuidv4())
}}
warnings={warnings}
dataSource={sourceType}
onDataSourceChange={(srcType) => {
setSourceType(srcType)
router.push({
pathname: router.pathname,
query: { ...router.query, source: srcType },
})
}}
/>
<ShimmerLine active={isLoading} />
{sourceType === 'warehouse' ? (
<CodeEditor
id={warehouseEditorId}
language="pgsql" // its bq sql but monaco doesn't have a language for it
defaultValue={warehouseEditorValue}
onInputChange={(v) => setWarehouseEditorValue(v || '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
/>
) : (
<CodeEditor
id={editorId}
editorRef={editorRef}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
/>
)}
<CodeEditor
id={editorId}
editorRef={editorRef}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel collapsible minSize={5} className="overflow-auto">
@@ -13,12 +13,7 @@ test('run and clear', async () => {
onSelectSource={() => {}}
onSelectTemplate={() => {}}
warnings={[]}
warehouseCollections={[]}
dataSource="logs"
onDataSourceChange={() => {}}
templates={[]}
warehouseTemplates={[]}
onSelectWarehouseTemplate={() => {}}
/>
)
await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow()