Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { LogDrainDestinationSheetForm } from './LogDrainDestinationSheetForm'
Expand Down Expand Up @@ -110,6 +111,37 @@ describe('LogDrainDestinationSheetForm', () => {
expect(screen.getByRole('button', { name: 'Save destination' })).toBeInTheDocument()
})

it('blocks submission when the destination name matches an existing drain', async () => {
const user = userEvent.setup()
const { onSubmit } = renderForm({ existingDrainNames: ['existing-drain'] })

await screen.findByRole('dialog')

await user.type(screen.getByPlaceholderText('My Destination'), 'existing-drain')
submitForm()

await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Log drain name already exists'))
expect(onSubmit).not.toHaveBeenCalled()
})

it('invokes onSaveClick with the destination type when saving', async () => {
const user = userEvent.setup()
const onSaveClick = vi.fn()
const { onSubmit } = renderForm({ onSaveClick })

await screen.findByRole('dialog')

await user.type(screen.getByPlaceholderText('My Destination'), 'Webhook sink')
await user.type(
screen.getByPlaceholderText('https://example.com/log-drain'),
'https://logs.example.com/ingest'
)
submitForm()

await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSaveClick).toHaveBeenCalledWith('webhook')
})

it('shows the protobuf content type header for OTLP create mode', async () => {
renderForm({
defaultValues: { type: 'otlp' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { IS_PLATFORM, useFlag } from 'common'
import Link from 'next/link'
import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { useForm } from 'react-hook-form'
Expand Down Expand Up @@ -53,9 +53,8 @@ import {
} from './LogDrains.utils'
import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
import { Shortcut } from '@/components/ui/Shortcut'
import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query'
import { LogDrainData } from '@/data/log-drains/log-drains-query'
import { DOCS_URL } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'

Expand Down Expand Up @@ -263,7 +262,9 @@ type LogDrainDestinationSubmitValues = z.infer<typeof submitSchema>

const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const

function toSubmitValues(values: LogDrainDestinationFormValues): LogDrainDestinationSubmitValues {
export function toSubmitValues(
values: LogDrainDestinationFormValues
): LogDrainDestinationSubmitValues {
if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) {
return submitSchema.parse(values)
}
Expand Down Expand Up @@ -321,13 +322,17 @@ export function LogDrainDestinationSheetForm({
onSubmit,
isLoading,
mode,
existingDrainNames = [],
onSaveClick,
}: {
open: boolean
onOpenChange: (v: boolean) => void
defaultValues?: DefaultValues
isLoading?: boolean
onSubmit: (values: LogDrainDestinationSubmitValues) => void
mode: 'create' | 'update'
existingDrainNames?: string[]
onSaveClick?: (type: LogDrainType) => void
}) {
// NOTE(kamil): This used to be `any` for a long long time, but after moving to Zod,
// it produces a correct union type of all possible configs. Unfortunately, this type was not designed correctly
Expand All @@ -349,12 +354,6 @@ export function LogDrainDestinationSheetForm({
const last9Enabled = useFlag('Last9LogDrain')
const syslogEnabled = useFlag('syslogLogDrain')

const { ref } = useParams()
const { data: logDrains } = useLogDrainsQuery({
ref,
})

const track = useTrack()
const formRef = useRef<HTMLFormElement>(null)

const formValues = useMemo(() => {
Expand Down Expand Up @@ -435,17 +434,16 @@ export function LogDrainDestinationSheetForm({

// Temp check to make sure the name is unique
const logDrainName = form.getValues('name')
const logDrainExists =
!!logDrains?.length && logDrains?.find((drain) => drain.name === logDrainName)
const logDrainExists = existingDrainNames.includes(logDrainName)
if (logDrainExists && mode === 'create') {
toast.error('Log drain name already exists')
return
}

form.handleSubmit((values) => onSubmit(toSubmitValues(values)))(e)
track('log_drain_save_button_clicked', {
destination: form.getValues('type'),
})
form.handleSubmit((values) => {
onSubmit(toSubmitValues(values))
onSaveClick?.(values.type)
})(e)
}}
>
<div className="space-y-8 px-content">
Expand Down
214 changes: 24 additions & 190 deletions apps/studio/components/interfaces/LogDrains/LogDrains.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,10 @@
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { MoreHorizontal, TrashIcon } from 'lucide-react'
import { cloneElement, useState } from 'react'
import { useParams } from 'common'
import { toast } from 'sonner'
import {
Button,
Card,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'

import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants'
import { LogDrainsCard } from './LogDrainsCard'
import { LogDrainType } from './LogDrains.constants'
import { LogDrainsEmpty } from './LogDrainsEmpty'
import { VoteLink } from './VoteLink'
import AlertError from '@/components/ui/AlertError'
import { LogDrainsList } from './LogDrainsList'
import { useDeleteLogDrainMutation } from '@/data/log-drains/delete-log-drain-mutation'
import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
Expand All @@ -37,196 +17,50 @@ export function LogDrains({
onNewDrainClick: (src: LogDrainType) => void
onUpdateDrainClick: (drain: LogDrainData) => void
}) {
const { ref } = useParams()
const track = useTrack()
const { hasAccess: hasAccessToLogDrains, isLoading: isLoadingEntitlement } =
useCheckEntitlements('log_drains')
const track = useTrack()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [selectedLogDrain, setSelectedLogDrain] = useState<LogDrainData | null>(null)
const { ref } = useParams()

const {
data: logDrains,
isPending: isLoading,
error,
isError,
} = useLogDrainsQuery(
{ ref },
{
enabled: hasAccessToLogDrains,
}
)
const sentryEnabled = useFlag('SentryLogDrain')
const s3Enabled = useFlag('S3logdrain')
const axiomEnabled = useFlag('axiomLogDrain')
const otlpEnabled = useFlag('otlpLogDrain')
const last9Enabled = useFlag('Last9LogDrain')
const syslogEnabled = useFlag('syslogLogDrain')
const hasLogDrains = !!logDrains?.length
} = useLogDrainsQuery({ ref }, { enabled: hasAccessToLogDrains })

const { mutate: deleteLogDrain } = useDeleteLogDrainMutation({
onSuccess: () => {
setIsDeleteModalOpen(false)
setSelectedLogDrain(null)
},
const { mutate: deleteLogDrain, isPending: isDeleting } = useDeleteLogDrainMutation({
onError: () => {
setIsDeleteModalOpen(false)
setSelectedLogDrain(null)
toast.error('Failed to delete log drain')
},
})

if (isLoading || isLoadingEntitlement) {
if (isLoadingEntitlement) {
return (
<div>
<GenericSkeletonLoader />
</div>
)
}

if (!isLoadingEntitlement && !hasAccessToLogDrains) {
if (!hasAccessToLogDrains) {
return <LogDrainsEmpty />
}

if (isError) {
return <AlertError subject="Failed to load log drains" error={error}></AlertError>
}

if (!isLoading && !hasLogDrains) {
return (
<>
<div className="grid lg:grid-cols-3 gap-4">
{LOG_DRAIN_TYPES.filter((t) => {
if (t.value === 'sentry') return sentryEnabled
if (t.value === 's3') return s3Enabled
if (t.value === 'axiom') return axiomEnabled
if (t.value === 'otlp') return otlpEnabled
if (t.value === 'last9') return last9Enabled
if (t.value === 'syslog') return syslogEnabled
return true
}).map((src) => (
<LogDrainsCard
key={src.value}
title={src.name}
description={src.description}
icon={src.icon}
rightLabel={IS_PLATFORM ? 'Additional $60' : undefined}
onClick={() => {
onNewDrainClick(src.value)
}}
/>
))}
</div>
<VoteLink />
</>
)
}

return (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[200px]">Name</TableHead>
<TableHead className="w-96">Description</TableHead>
<TableHead className="w-48">Destination</TableHead>
<TableHead className="text-right">
<div className="sr-only">Actions</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logDrains
?.slice()
.sort((a, b) => b.id - a.id)
.map((drain) => (
<TableRow key={drain.id}>
<TableCell className="font-medium truncate max-w-72" title={drain.name}>
{drain.name}
</TableCell>
<TableCell
className={cn(
'truncate max-w-96',
drain.description ? 'text-foreground-light' : 'text-foreground-muted'
)}
title={drain.description}
>
{drain.description || '-'}
</TableCell>
<TableCell className="text-foreground-light">
<div className="flex items-center gap-2">
{LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.icon && (
<span className="text-foreground-light">
{cloneElement(LOG_DRAIN_TYPES.find((t) => t.value === drain.type)!.icon, {
height: 16,
width: 16,
})}
</span>
)}
<span className="truncate max-w-40">
{LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.name ?? drain.type}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1 opacity-50 hover:opacity-100 bg-transparent! shrink-0"
icon={<MoreHorizontal />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[140px]" align="end">
{/* Jordi: Updating log drains is disabled temporarily.
<DropdownMenuItem
onClick={() => {
onUpdateDrainClick(drain)
}}
>
<Pencil className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem> */}
<DropdownMenuItem
onClick={() => {
setSelectedLogDrain(drain)
setIsDeleteModalOpen(true)
}}
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>

<ConfirmationModal
confirmLabel="Delete"
variant="destructive"
title="Delete Log Drain"
visible={isDeleteModalOpen}
onConfirm={() => {
if (selectedLogDrain && ref) {
deleteLogDrain({ token: selectedLogDrain.token, projectRef: ref })
track('log_drain_removed', {
destination: selectedLogDrain.type,
})
}
}}
onCancel={() => setIsDeleteModalOpen(false)}
>
<div className="text-foreground-light text-sm">
<p>
Are you sure you want to delete{' '}
<span className="text-foreground">{selectedLogDrain?.name}</span>?
</p>
<p>This action cannot be undone.</p>
</div>
</ConfirmationModal>
</Table>
</Card>
</>
<LogDrainsList
logDrains={logDrains}
isLoading={isLoading}
isError={isError}
error={error}
isDeleting={isDeleting}
onNewDrainClick={onNewDrainClick}
onDeleteDrain={(drain) => {
if (ref) {
deleteLogDrain({ token: drain.token, projectRef: ref })
track('log_drain_removed', { destination: drain.type })
}
}}
/>
)
}
Loading
Loading