Log Drains UI (#28044)

* add main log drain form and navigation

* fix submit and select

* add mutation and update form

* fix submit issue

* fix issues with validation

* fix typeerrs

* add log drain table

* update create log drain mutation

* add delete log drain mutation

* update get log drains query

* fix new log drain click in cards

* add delete mutation

* add delete log drain

* refactor gzip switch

* refactor radiogroup to use formfield

* add headers form

* fix validation, custom headers errors

* refactor to support update in form

* format

* add log drains to nav

* update api spec and list logdrains query

* wire backend

* fix datadog region values

* make api input password

* fix url elastic

* fix typerr

* rm unnecessary value setter

* fix state issue log drains update form

* Add default values setting in useEffect to fix form issues

* format

* Update LogDrains table header width to 250px

* add upgrade plan card when free plan

* fix dumb if statement

* fix the freaking headers

* fix upgrade to team state

* fix plan check and loading state

* disable type update

* Add link to documentation in Log Drains settings

* show add destination only after empty state

* fix bug with inputs not resetting

* add gzip tooltip

* rm command

* defaultValue to value

* add defaultValue

* rm consolelog

* rm anys
This commit is contained in:
Jordi Enric
2024-08-01 22:03:10 +02:00
committed by GitHub
parent ada71a1958
commit 03665dfdcd
14 changed files with 1279 additions and 73 deletions

View File

@@ -0,0 +1,427 @@
import { useParams } from 'common'
import { DATADOG_REGIONS, LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Switch,
Sheet,
SheetContent,
SheetSection,
SheetFooter,
SheetHeader,
SheetTitle,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectLabel_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
cn,
} from 'ui'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useEffect, useState } from 'react'
import { TrashIcon } from 'lucide-react'
import { LogDrainData } from 'data/log-drains/log-drains-query'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
const FORM_ID = 'log-drain-destination-form'
const formUnion = z.discriminatedUnion('type', [
z.object({
type: z.literal('webhook'),
url: z.string().url('Webhook URL is required and must be a valid URL'),
httpVersion: z.enum(['HTTP1', 'HTTP2']),
gzip: z.boolean(),
headers: z.record(z.string(), z.string()),
}),
z.object({
type: z.literal('datadog'),
api_key: z.string().min(1, { message: 'API key is required' }),
region: z.string().min(1, { message: 'Region is required' }),
}),
z.object({
type: z.literal('elastic'),
url: z.string().url({ message: 'URL is required and must be a valid URL' }),
username: z.string().min(1, { message: 'Username is required' }),
password: z.string().min(1, { message: 'Password is required' }),
}),
])
const formSchema = z
.object({
name: z.string().min(1, {
message: 'Destination name is required',
}),
description: z.string().optional(),
})
.and(formUnion)
function LogDrainFormItem({
value,
label,
description,
formControl,
placeholder,
type,
}: {
value: string
label: string
formControl: any
placeholder?: string
description?: string
type?: string
}) {
return (
<FormField_Shadcn_
name={value}
control={formControl}
render={({ field }) => (
<FormItemLayout layout="horizontal" label={label} description={description || ''}>
<FormControl_Shadcn_>
<Input_Shadcn_ type={type || 'text'} placeholder={placeholder} {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
export function LogDrainDestinationSheetForm({
open,
onOpenChange,
defaultValues,
onSubmit,
isLoading,
mode,
}: {
open: boolean
onOpenChange: (v: boolean) => void
defaultValues?: Partial<LogDrainData> & { type: LogDrainType }
isLoading?: boolean
onSubmit: (values: z.infer<typeof formSchema>) => void
mode: 'create' | 'update'
}) {
const defaultType = defaultValues?.type || 'webhook'
const [newCustomHeader, setNewCustomHeader] = useState({ name: '', value: '' })
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
type: defaultType,
...defaultValues,
},
})
const headers = form.watch('headers')
// IDK why but this useEffect is needed to set the default values
// Suppossedly if you call form.reset() defaultValues is not cached, but I have found it to be very unreliable, and sometimes the form will open with the wrong default values.
// - Jordi
useEffect(() => {
if (form.formState.isDirty) return
if (mode === 'update') {
form.setValue('type', defaultType)
form.setValue('name', defaultValues?.name || '')
form.setValue('url', defaultValues?.config?.url || '')
form.setValue('api_key', defaultValues?.config?.api_key || '')
form.setValue('region', defaultValues?.config?.region || '')
form.setValue('username', defaultValues?.config?.username || '')
form.setValue('password', defaultValues?.config?.password || '')
form.setValue('httpVersion', defaultValues?.config?.httpVersion || 'HTTP2')
form.setValue('gzip', defaultValues?.config?.gzip || true)
form.setValue('headers', defaultValues?.config?.headers || {})
form.setValue('description', defaultValues?.description || '')
} else {
form.reset()
}
}, [defaultType, form, defaultValues, mode])
const type = form.watch('type')
function removeHeader(key: string) {
const newHeaders = {
...headers,
}
delete newHeaders[key]
form.setValue('headers', newHeaders)
}
function addHeader() {
const formHeaders = form.getValues('headers')
if (!formHeaders) return
const headerKeys = Object.keys(formHeaders)
if (headerKeys?.length === 20) {
toast.error('You can only have 20 custom headers')
return
}
if (headerKeys?.includes(newCustomHeader.name)) {
toast.error('Header name already exists')
return
}
if (!newCustomHeader.name || !newCustomHeader.value) {
toast.error('Header name and value are required')
return
}
form.setValue('headers', { ...formHeaders, [newCustomHeader.name]: newCustomHeader.value })
setNewCustomHeader({ name: '', value: '' })
}
const hasHeaders = Object.keys(headers || {})?.length > 0
return (
<Sheet
open={open}
onOpenChange={(v) => {
form.reset()
onOpenChange(v)
}}
>
<SheetContent tabIndex={undefined} showClose={false} size="lg" className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Add destination</SheetTitle>
</SheetHeader>
<SheetSection>
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit(onSubmit)(e)
}}
>
<div className="space-y-4">
<LogDrainFormItem
value="name"
placeholder="My Destination"
label="Name"
formControl={form.control}
/>
<LogDrainFormItem
value="description"
placeholder="My Destination"
label="Description"
formControl={form.control}
/>
<div>
{mode === 'update' && (
<div className="flex items-center gap-2 text-sm text-foreground-light">
<InfoTooltip align="start">
You cannot change the type of an existing log drain. Please, create a new
log drain with the new type.
</InfoTooltip>
Can't update log drain type
</div>
)}
</div>
<RadioGroupStacked
className={cn(mode === 'update' && 'opacity-50')}
value={type}
onValueChange={(v: LogDrainType) => form.setValue('type', v)}
disabled={mode === 'update'}
>
{LOG_DRAIN_TYPES.map((type) => (
<RadioGroupStackedItem
value={type.value}
key={type.value}
id={type.value}
label={type.name}
description={type.description}
className="text-left"
/>
))}
</RadioGroupStacked>
</div>
<div className="space-y-4 mt-6">
{type === 'webhook' && (
<>
<LogDrainFormItem
value="url"
label="Webhook URL"
formControl={form.control}
placeholder="https://example.com/webhooks/log-drain"
/>
<FormField_Shadcn_
control={form.control}
name="httpVersion"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<RadioGroupStacked
defaultValue="HTTP2"
onValueChange={field.onChange}
value={field.value}
>
<FormItem_Shadcn_ asChild>
<FormControl_Shadcn_>
<RadioGroupStackedItem value="HTTP1" label="HTTP1" />
</FormControl_Shadcn_>
</FormItem_Shadcn_>
<FormItem_Shadcn_ asChild>
<FormControl_Shadcn_>
<RadioGroupStackedItem value="HTTP2" label="HTTP2" />
</FormControl_Shadcn_>
</FormItem_Shadcn_>
</RadioGroupStacked>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name="gzip"
render={({ field }) => (
<FormItem_Shadcn_ className="space-y-2">
<div className="flex gap-2 items-center">
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
<FormLabel_Shadcn_ className="text-base">Gzip</FormLabel_Shadcn_>
<InfoTooltip align="start">
Gzip can reduce the size of the payload and increase the speed of the
request,
<br /> but it will increase the CPU usage of the destination.
</InfoTooltip>
</div>
</FormItem_Shadcn_>
)}
/>
<div>
<FormLabel_Shadcn_>Custom Headers</FormLabel_Shadcn_>
{hasHeaders &&
Object.keys(headers || {})?.map((headerKey) => (
<div
className="flex hover:bg-background-alternative text-sm text-foreground items-center font-mono border-b p-1.5 group"
key={headerKey}
>
<div className="w-full px-1">{headerKey}</div>
<div className="w-full px-1 truncate" title={headers[headerKey]}>
{headers[headerKey]}
</div>
<Button
className="justify-self-end opacity-0 group-hover:opacity-100"
type="text"
title="Remove"
icon={<TrashIcon />}
onClick={() => removeHeader(headerKey)}
></Button>
</div>
))}
</div>
</>
)}
{type === 'datadog' && (
<div className="grid gap-4">
<LogDrainFormItem
type="password"
value="api_key"
label="API Key"
formControl={form.control}
description="The API Key obtained from the Datadog dashboard."
/>
<FormField_Shadcn_
name="region"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="horizontal" label={'Region'}>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="col-span-3">
<SelectValue_Shadcn_ placeholder="Select a region" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectLabel_Shadcn_>Region</SelectLabel_Shadcn_>
{DATADOG_REGIONS.map((reg) => (
<SelectItem_Shadcn_ key={reg.value} value={reg.value}>
{reg.label}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
)}
{type === 'elastic' && (
<div className="grid gap-4">
<LogDrainFormItem value="url" label="Filebeat URL" formControl={form.control} />
<LogDrainFormItem
value="username"
label="Username"
formControl={form.control}
/>
<LogDrainFormItem
type="password"
value="password"
label="Password"
formControl={form.control}
/>
</div>
)}
</div>
</form>
</Form_Shadcn_>
{/* This form needs to be outside the <Form_Shadcn_> */}
{type === 'webhook' && (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
addHeader()
}}
className="flex gap-2 mt-2 items-center"
>
<Input_Shadcn_
size={'tiny'}
type="text"
placeholder="x-header-name"
value={newCustomHeader.name}
onChange={(e) => setNewCustomHeader({ ...newCustomHeader, name: e.target.value })}
/>
<Input_Shadcn_
size={'tiny'}
type="text"
placeholder="Header value"
value={newCustomHeader.value}
onChange={(e) => setNewCustomHeader({ ...newCustomHeader, value: e.target.value })}
/>
<Button htmlType="submit" type="outline">
Add
</Button>
</form>
)}
</SheetSection>
<SheetFooter className="p-4">
<Button form={FORM_ID} loading={isLoading} htmlType="submit" type="primary">
Save destination
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,69 @@
import { Computer, DogIcon, WebhookIcon } from 'lucide-react'
const iconProps = { size: 24, strokeWidth: 1.5, className: 'text-foreground-light' }
export const LOG_DRAIN_TYPES = [
{
value: 'webhook',
name: 'Webhook',
description: 'Forward logs to a custom HTTP endpoint',
icon: <WebhookIcon {...iconProps} />,
},
{
value: 'datadog',
name: 'Data dog',
description: 'Datadog is a monitoring service for cloud-scale applications',
icon: <DogIcon {...iconProps} />,
},
{
value: 'elastic',
name: 'Elastic Filebeat',
description: 'Filebeat is a lightweight shipper for forwarding and centralizing log data',
icon: <Computer {...iconProps} />,
},
] as const
export const LOG_DRAIN_SOURCE_VALUES = LOG_DRAIN_TYPES.map((source) => source.value)
export type LogDrainType = (typeof LOG_DRAIN_TYPES)[number]['value']
export const DATADOG_REGIONS = [
{
label: 'AP1',
value: 'AP1',
},
{
label: 'EU',
value: 'EU',
},
{
label: 'US1',
value: 'US1',
},
{
label: 'US1-FED',
value: 'US1-FED',
},
{
label: 'US3',
value: 'US3',
},
{
label: 'US5',
value: 'US5',
},
] as const
export type LogDrainDatadogConfig = {
api_key: string
region: string
}
export type LogDrainElasticConfig = {
url: string
username: string
password: string
}
export type LogDrainWebhookConfig = {
url: string
}

View File

@@ -0,0 +1,185 @@
import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query'
import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants'
import { useParams } from 'common'
import CardButton from 'components/ui/CardButton'
import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'ui-patterns'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { MoreHorizontal, Pencil, TrashIcon } from 'lucide-react'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useState } from 'react'
import { useDeleteLogDrainMutation } from 'data/log-drains/delete-log-drain-mutation'
import AlertError from 'components/ui/AlertError'
import toast from 'react-hot-toast'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import Link from 'next/link'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
export function LogDrains({
onNewDrainClick,
onUpdateDrainClick,
}: {
onNewDrainClick: (src: LogDrainType) => void
onUpdateDrainClick: (drain: LogDrainData) => void
}) {
const org = useSelectedOrganization()
const { isLoading: orgPlanLoading, plan } = useCurrentOrgPlan()
const logDrainsEnabled = !orgPlanLoading && (plan?.id === 'team' || plan?.id === 'enterprise')
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [selectedLogDrain, setSelectedLogDrain] = useState<LogDrainData | null>(null)
const { ref } = useParams()
const {
data: logDrains,
isLoading,
refetch,
error,
isError,
} = useLogDrainsQuery(
{ ref },
{
enabled: logDrainsEnabled,
}
)
const { mutate: deleteLogDrain } = useDeleteLogDrainMutation({
onSuccess: () => {
setIsDeleteModalOpen(false)
setSelectedLogDrain(null)
},
onError: () => {
setIsDeleteModalOpen(false)
setSelectedLogDrain(null)
toast.error('Failed to delete log drain')
},
})
if (!orgPlanLoading && !logDrainsEnabled) {
return (
<CardButton title="Upgrade to Pro" description="Upgrade to a Team Plan to use Log Drains">
<Button className="mt-2" asChild>
<Link href={`/org/${org?.slug}/billing`}>Upgrade to Pro</Link>
</Button>
</CardButton>
)
}
if (isLoading || orgPlanLoading) {
return (
<div>
<GenericSkeletonLoader />
</div>
)
}
if (!isLoading && logDrains?.length === 0) {
return (
<div className="grid grid-cols-2 gap-3">
{LOG_DRAIN_TYPES.map((src) => (
<CardButton
key={src.value}
title={src.name}
description={src.description}
icon={src.icon}
onClick={() => {
onNewDrainClick(src.value)
}}
/>
))}
</div>
)
}
if (isError) {
return <AlertError error={error}></AlertError>
}
return (
<>
<Panel className="">
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[200px]">Name</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Source</TableHead>
<TableHead className="text-right">
<div className="sr-only">Actions</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logDrains?.map((drain) => (
<TableRow key={drain.id}>
<TableCell className="font-medium">{drain.name}</TableCell>
<TableCell>{drain.description}</TableCell>
<TableCell className="text-right font-mono">{drain.type}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1 opacity-50 hover:opacity-100 !bg-transparent"
icon={<MoreHorizontal />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[140px]" align="end">
<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
title="Delete Log Drain"
visible={isDeleteModalOpen}
onConfirm={() => {
if (selectedLogDrain && ref) {
deleteLogDrain({ token: selectedLogDrain.token, projectRef: ref })
}
}}
onCancel={() => setIsDeleteModalOpen(false)}
>
<div className="text-foreground-light">
<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>
</Panel>
</>
)
}

View File

@@ -47,6 +47,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
])
const warehouseEnabled = useFlag('warehouse')
const logDrainsEnabled = useFlag('logdrains')
const menuRoutes = generateSettingsMenu(ref, project, organization, {
auth: authEnabled,
@@ -54,6 +55,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
storage: storageEnabled,
invoices: invoicesEnabled,
warehouse: warehouseEnabled,
logDrains: logDrainsEnabled,
})
return (

View File

@@ -13,6 +13,7 @@ export const generateSettingsMenu = (
storage?: boolean
invoices?: boolean
warehouse?: boolean
logDrains?: boolean
}
): ProductMenuGroup[] => {
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
@@ -22,6 +23,7 @@ export const generateSettingsMenu = (
const edgeFunctionsEnabled = features?.edgeFunctions ?? true
const storageEnabled = features?.storage ?? true
const warehouseEnabled = features?.warehouse ?? false
const logDrainsEnabled = features?.logDrains ?? false
return [
{
@@ -121,6 +123,16 @@ export const generateSettingsMenu = (
},
]
: []),
...(IS_PLATFORM && logDrainsEnabled
? [
{
name: `Log Drains`,
key: `log-drains`,
url: `/project/${ref}/settings/log-drains`,
items: [],
},
]
: []),
],
},

View File

@@ -0,0 +1,62 @@
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 { logDrainsKeys } from './keys'
import { LogDrainType } from 'components/interfaces/LogDrains/LogDrains.constants'
export type LogDrainCreateVariables = {
projectRef: string
name: string
config: Record<string, never>
type: LogDrainType
}
export async function createLogDrain(payload: LogDrainCreateVariables) {
const { data, error } = await post('/platform/projects/{ref}/analytics/log-drains', {
params: { path: { ref: payload.projectRef } },
body: {
name: payload.name,
type: payload.type,
config: payload.config,
},
})
if (error) handleError(error)
return data
}
type LogDrainCreateData = Awaited<ReturnType<typeof createLogDrain>>
export const useCreateLogDrainMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<LogDrainCreateData, ResponseError, LogDrainCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<LogDrainCreateData, ResponseError, LogDrainCreateVariables>(
(vars) => createLogDrain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(logDrainsKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to mutate: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -0,0 +1,55 @@
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 { logDrainsKeys } from './keys'
export type LogDrainDeleteVariables = {
projectRef: string
token: string
}
export async function deleteLogDrain({ projectRef, token }: LogDrainDeleteVariables) {
// @ts-ignore Just sample, TS lint will validate if the endpoint is valid
const { data, error } = await del('/platform/projects/{ref}/analytics/log-drains/{token}', {
params: { path: { ref: projectRef, token } },
})
if (error) handleError(error)
return data
}
type LogDrainDeleteData = Awaited<ReturnType<typeof deleteLogDrain>>
export const useDeleteLogDrainMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<LogDrainDeleteData, ResponseError, LogDrainDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<LogDrainDeleteData, ResponseError, LogDrainDeleteVariables>(
(vars) => deleteLogDrain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(logDrainsKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to mutate: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -0,0 +1,3 @@
export const logDrainsKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'log-drains'] as const,
}

View File

@@ -0,0 +1,43 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { logDrainsKeys } from './keys'
import { ResponseError } from 'types'
export type LogDrainsVariables = {
ref?: string
}
export async function getLogDrains({ ref }: LogDrainsVariables, signal?: AbortSignal) {
if (!ref) {
throw new Error('ref is required')
}
const { data, error } = await get(`/platform/projects/{ref}/analytics/log-drains`, {
params: { path: { ref: ref } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type LogDrainsData = Awaited<ReturnType<typeof getLogDrains>>
export type LogDrainData = LogDrainsData[number]
export type LogDrainsyError = ResponseError
export const useLogDrainsQuery = <TData = LogDrainsData>(
{ ref }: LogDrainsVariables,
{ enabled, ...options }: UseQueryOptions<LogDrainsData, LogDrainsyError, TData> = {}
) =>
useQuery<LogDrainsData, LogDrainsyError, TData>(
logDrainsKeys.list(ref),
({ signal }) => getLogDrains({ ref }, signal),
{
enabled: enabled && !!ref,
refetchOnMount: false,
...options,
}
)

View File

@@ -0,0 +1,68 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { handleError, put } from 'data/fetchers'
import type { ResponseError } from 'types'
import { logDrainsKeys } from './keys'
import { LogDrainType } from 'components/interfaces/LogDrains/LogDrains.constants'
export type LogDrainUpdateVariables = {
projectRef: string
token?: string
name: string
description?: string
type: LogDrainType
config: Record<string, never>
}
export async function updateLogDrain(payload: LogDrainUpdateVariables) {
if (!payload.token) {
throw new Error('Token is required')
}
const { data, error } = await put('/platform/projects/{ref}/analytics/log-drains/{token}', {
params: { path: { ref: payload.projectRef, token: payload.token } },
body: {
name: payload.name,
description: payload.description,
config: payload.config,
},
})
if (error) handleError(error)
return data
}
type LogDrainUpdateData = Awaited<ReturnType<typeof updateLogDrain>>
export const useUpdateLogDrainMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<LogDrainUpdateData, ResponseError, LogDrainUpdateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<LogDrainUpdateData, ResponseError, LogDrainUpdateVariables>(
(vars) => updateLogDrain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(logDrainsKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to mutate: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -0,0 +1,23 @@
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from './useSelectedOrganization'
export function useCurrentOrgPlan() {
const currentOrg = useSelectedOrganization()
const { data, isLoading, isSuccess } = useOrgSubscriptionQuery({
orgSlug: currentOrg?.slug,
})
if (isLoading) {
return {
plan: null,
isLoading,
isSuccess: false,
}
} else {
return {
plan: data?.plan,
isLoading,
isSuccess,
}
}
}

View File

@@ -0,0 +1,153 @@
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
import { LogDrains } from 'components/interfaces/LogDrains/LogDrains'
import { LogDrainDestinationSheetForm } from 'components/interfaces/LogDrains/LogDrainDestinationSheetForm'
import { Button } from 'ui'
import { useState } from 'react'
import { LogDrainType } from 'components/interfaces/LogDrains/LogDrains.constants'
import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query'
import { useCreateLogDrainMutation } from 'data/log-drains/create-log-drain-mutation'
import toast from 'react-hot-toast'
import { useUpdateLogDrainMutation } from 'data/log-drains/update-log-drain-mutation'
import { useParams } from 'common'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
const LogDrainsSettings: NextPageWithLayout = () => {
const [open, setOpen] = useState(false)
const { ref } = useParams() as { ref: string }
const [selectedLogDrain, setSelectedLogDrain] = useState<Partial<LogDrainData> | null>(null)
const [mode, setMode] = useState<'create' | 'update'>('create')
const { plan, isLoading: planLoading } = useCurrentOrgPlan()
const logDrainsEnabled = !planLoading && (plan?.id === 'team' || plan?.id === 'enterprise')
const { data: logDrains } = useLogDrainsQuery(
{ ref },
{
enabled: logDrainsEnabled,
}
)
const { mutate: createLogDrain, isLoading: createLoading } = useCreateLogDrainMutation({
onSuccess: () => {
toast.success('Log drain destination created')
setOpen(false)
},
onError: () => {
toast.error('Failed to create log drain')
setOpen(false)
},
})
const { mutate: updateLogDrain, isLoading: updateLoading } = useUpdateLogDrainMutation({
onSuccess: () => {
toast.success('Log drain destination updated')
setOpen(false)
},
onError: () => {
setOpen(false)
toast.error('Failed to update log drain')
},
})
const isLoading = createLoading || updateLoading
function handleUpdateClick(drain: LogDrainData) {
setSelectedLogDrain(drain)
setMode('update')
setOpen(true)
}
function handleNewClick(src: LogDrainType) {
setSelectedLogDrain({ type: src })
setMode('create')
setOpen(true)
}
return (
<>
<ScaffoldContainer>
<ScaffoldHeader className="flex flex-row justify-between">
<div>
<ScaffoldTitle>Log Drains</ScaffoldTitle>
<ScaffoldDescription>
Send your project logs to third party destinations
</ScaffoldDescription>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
<Link
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/guides/platform/log-drains"
>
Documentation
</Link>
</Button>
{!(logDrains?.length === 0) && (
<Button
disabled={!logDrainsEnabled}
onClick={() => {
setSelectedLogDrain(null)
setMode('create')
setOpen(true)
}}
type="primary"
>
Add destination
</Button>
)}
</div>
</ScaffoldHeader>
</ScaffoldContainer>
<ScaffoldContainer className="flex flex-col gap-10" bottomPadding>
<LogDrainDestinationSheetForm
mode={mode}
open={open}
onOpenChange={(v) => {
if (!v) {
setSelectedLogDrain(null)
}
setOpen(v)
}}
defaultValues={selectedLogDrain as any}
isLoading={isLoading}
onSubmit={({ name, type, ...values }) => {
const logDrainValues = {
name,
type,
config: values as any, // TODO: fix generated API types from backend
id: selectedLogDrain?.id,
projectRef: ref,
token: selectedLogDrain?.token,
}
if (mode === 'create') {
createLogDrain(logDrainValues)
} else {
if (!logDrainValues.id || !selectedLogDrain?.token) {
throw new Error('Log drain ID and token is required')
} else {
console.log('logDrainValues', logDrainValues)
updateLogDrain(logDrainValues)
}
}
}}
/>
<LogDrains onUpdateDrainClick={handleUpdateClick} onNewDrainClick={handleNewClick} />
</ScaffoldContainer>
</>
)
}
LogDrainsSettings.getLayout = (page) => <SettingsLayout title="Log Drains">{page}</SettingsLayout>
export default LogDrainsSettings

View File

@@ -665,6 +665,18 @@ export interface paths {
/** Gets project's usage api requests count */
get: operations['UsageApiController_getApiRequestsCount']
}
'/platform/projects/{ref}/analytics/log-drains': {
/** Lists all log drains */
get: operations['LogDrainController_listLogDrains']
/** Create a log drain */
post: operations['LogDrainController_createLogDrain']
}
'/platform/projects/{ref}/analytics/log-drains/{token}': {
/** Update a log drain */
put: operations['LogDrainController_updateLogDrain']
/** Delete a log drain */
delete: operations['LogDrainController_deleteLogDrain']
}
'/platform/projects/{ref}/analytics/warehouse/access-tokens': {
/** Lists project's warehouse access tokens from logflare */
get: operations['v1-list-all-warehouse-tokens']
@@ -983,10 +995,6 @@ export interface paths {
/** Sets up a payment method */
post: operations['SetupIntentController_setUpPaymentMethod']
}
'/platform/telemetry/activity': {
/** Sends server activity */
post: operations['TelemetryActivityController_sendServerActivity']
}
'/platform/telemetry/event': {
/** Sends analytics server event */
post: operations['TelemetryEventController_sendServerEvent']
@@ -999,10 +1007,6 @@ export interface paths {
/** Send server page event */
post: operations['TelemetryPageController_sendServerPage']
}
'/platform/telemetry/pageview': {
/** Send pageview event */
post: operations['TelemetryPageviewController_sendServerPageViewed']
}
'/platform/tos/fly': {
/** Redirects to Fly sso flow */
get: operations['TermsOfServiceController_flyTosAccepted']
@@ -1492,6 +1496,18 @@ export interface paths {
/** Gets project's usage api requests count */
get: operations['UsageApiController_getApiRequestsCount']
}
'/v0/projects/{ref}/analytics/log-drains': {
/** Lists all log drains */
get: operations['LogDrainController_listLogDrains']
/** Create a log drain */
post: operations['LogDrainController_createLogDrain']
}
'/v0/projects/{ref}/analytics/log-drains/{token}': {
/** Update a log drain */
put: operations['LogDrainController_updateLogDrain']
/** Delete a log drain */
delete: operations['LogDrainController_deleteLogDrain']
}
'/v0/projects/{ref}/analytics/warehouse/access-tokens': {
/** Lists project's warehouse access tokens from logflare */
get: operations['v1-list-all-warehouse-tokens']
@@ -2509,6 +2525,13 @@ export interface components {
partner_billing: components['schemas']['AwsPartnerBillingBody']
primary_email: string
}
CreateBackendParams: {
config: Record<string, never>
description?: string
name: string
/** @enum {string} */
type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic'
}
CreateBranchBody: {
branch_name: string
git_branch?: string
@@ -2852,16 +2875,8 @@ export interface components {
custom_origin_server: string
hostname: string
id: string
ownership_verification: {
name?: string
type?: string
value?: string
}
ssl: {
status?: string
validation_errors?: components['schemas']['ValidationError'][]
validation_records?: components['schemas']['ValidationRecord'][]
}
ownership_verification: components['schemas']['OwnershipVerification']
ssl: components['schemas']['SslValidation']
status: string
verification_errors?: string[]
}
@@ -3614,6 +3629,20 @@ export interface components {
scopes: string
token: string
}
LFBackend: {
config: Record<string, never>
description?: string
id: number
metadata: {
project_ref?: string
type?: string
}
name: string
token: string
/** @enum {string} */
type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic'
user_id: number
}
LFEndpoint: {
cache_duration_seconds: number
description: string
@@ -3948,11 +3977,10 @@ export interface components {
organization_id: number
overdue_invoice_count: number
}
PageBody: {
location: string
path: string
referrer?: string
title?: string
OwnershipVerification: {
name: string
type: string
value: string
}
PasswordCheckBody: {
password: string
@@ -4755,9 +4783,11 @@ export interface components {
}
RestrictionData: {
grace_period_end?: string
report_date?: string
/** @enum {string} */
restrictions?: 'drop_requests_402'
usage_stats?: components['schemas']['UsageStats']
violation_data?: Record<string, never>
violations?: (
| 'exceed_db_size_quota'
| 'exceed_egress_quota'
@@ -5035,6 +5065,11 @@ export interface components {
SslEnforcements: {
database: boolean
}
SslValidation: {
status: string
validation_errors?: components['schemas']['ValidationError'][]
validation_records: components['schemas']['ValidationRecord'][]
}
StorageBucket: {
created_at: string
id: string
@@ -5210,14 +5245,6 @@ export interface components {
TaxIdV2Response: {
tax_id: components['schemas']['TaxIdV2'] | null
}
TelemetryActivityBody: {
activity: string
data?: Record<string, never>
orgSlug?: string
page: components['schemas']['PageBody']
projectRef?: string
source: string
}
TelemetryEventBody: {
action: string
category: string
@@ -5238,14 +5265,6 @@ export interface components {
route?: string
title: string
}
TelemetryPageviewBody: {
location: string
orgSlug?: string
path: string
projectRef?: string
referrer: string
title: string
}
ThirdPartyAuth: {
custom_jwks?: unknown
id: string
@@ -5447,6 +5466,11 @@ export interface components {
smtp_user?: string
uri_allow_list?: string
}
UpdateBackendParams: {
config?: Record<string, never>
description?: string
name?: string
}
UpdateBranchBody: {
branch_name?: string
git_branch?: string
@@ -10725,6 +10749,110 @@ export interface operations {
}
}
}
/** Lists all log drains */
LogDrainController_listLogDrains: {
parameters: {
path: {
/** @description Project ref */
ref: string
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['LFBackend'][]
}
}
403: {
content: never
}
/** @description Failed to fetch log drains */
500: {
content: never
}
}
}
/** Create a log drain */
LogDrainController_createLogDrain: {
parameters: {
path: {
/** @description Project ref */
ref: string
}
}
requestBody: {
content: {
'application/json': components['schemas']['CreateBackendParams']
}
}
responses: {
201: {
content: {
'application/json': components['schemas']['LFBackend']
}
}
403: {
content: never
}
/** @description Failed to create a log drain */
500: {
content: never
}
}
}
/** Update a log drain */
LogDrainController_updateLogDrain: {
parameters: {
path: {
/** @description Project ref */
ref: string
/** @description Log drains token */
token: string
}
}
requestBody: {
content: {
'application/json': components['schemas']['UpdateBackendParams']
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['LFBackend']
}
}
403: {
content: never
}
/** @description Failed to update log drain */
500: {
content: never
}
}
}
/** Delete a log drain */
LogDrainController_deleteLogDrain: {
parameters: {
path: {
/** @description Project ref */
ref: string
/** @description Log drains token */
token: string
}
}
responses: {
204: {
content: never
}
403: {
content: never
}
/** @description Failed to delete a log drain */
500: {
content: never
}
}
}
/** Lists project's warehouse access tokens from logflare */
'v1-list-all-warehouse-tokens': {
parameters: {
@@ -12680,23 +12808,6 @@ export interface operations {
}
}
}
/** Sends server activity */
TelemetryActivityController_sendServerActivity: {
requestBody: {
content: {
'application/json': components['schemas']['TelemetryActivityBody']
}
}
responses: {
201: {
content: never
}
/** @description Failed to send server activity */
500: {
content: never
}
}
}
/** Sends analytics server event */
TelemetryEventController_sendServerEvent: {
requestBody: {
@@ -12748,23 +12859,6 @@ export interface operations {
}
}
}
/** Send pageview event */
TelemetryPageviewController_sendServerPageViewed: {
requestBody: {
content: {
'application/json': components['schemas']['TelemetryPageviewBody']
}
}
responses: {
201: {
content: never
}
/** @description Failed to send pageview event */
500: {
content: never
}
}
}
/** Redirects to Fly sso flow */
TermsOfServiceController_flyTosAccepted: {
parameters: {
@@ -14742,6 +14836,9 @@ export interface operations {
201: {
content: never
}
403: {
content: never
}
/** @description Failed to remove read replica */
500: {
content: never
@@ -14765,6 +14862,9 @@ export interface operations {
201: {
content: never
}
403: {
content: never
}
/** @description Failed to set up read replica */
500: {
content: never

View File

@@ -186,6 +186,10 @@
"label": "Storage settings",
"url": "/project/_/settings/storage"
},
{
"label": "Log Drains",
"url": "/project/_/settings/log-drains"
},
{
"label": "Custom Domains",
"url": "/project/_/settings/general#custom-domains"