From 03665dfdcd064e12e3f5a5535df07c4f85f1ee8a Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:03:10 +0200 Subject: [PATCH] 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 --- .../LogDrainDestinationSheetForm.tsx | 427 ++++++++++++++++++ .../LogDrains/LogDrains.constants.tsx | 69 +++ .../interfaces/LogDrains/LogDrains.tsx | 185 ++++++++ .../ProjectSettingsLayout/SettingsLayout.tsx | 2 + .../SettingsMenu.utils.ts | 12 + .../log-drains/create-log-drain-mutation.ts | 62 +++ .../log-drains/delete-log-drain-mutation.ts | 55 +++ apps/studio/data/log-drains/keys.ts | 3 + .../data/log-drains/log-drains-query.ts | 43 ++ .../log-drains/update-log-drain-mutation.ts | 68 +++ apps/studio/hooks/misc/useCurrentOrgPlan.ts | 23 + .../project/[ref]/settings/log-drains.tsx | 153 +++++++ packages/api-types/types/api.d.ts | 246 +++++++--- .../Cmdk/utils/shared-nav-items.json | 4 + 14 files changed, 1279 insertions(+), 73 deletions(-) create mode 100644 apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx create mode 100644 apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx create mode 100644 apps/studio/components/interfaces/LogDrains/LogDrains.tsx create mode 100644 apps/studio/data/log-drains/create-log-drain-mutation.ts create mode 100644 apps/studio/data/log-drains/delete-log-drain-mutation.ts create mode 100644 apps/studio/data/log-drains/keys.ts create mode 100644 apps/studio/data/log-drains/log-drains-query.ts create mode 100644 apps/studio/data/log-drains/update-log-drain-mutation.ts create mode 100644 apps/studio/hooks/misc/useCurrentOrgPlan.ts create mode 100644 apps/studio/pages/project/[ref]/settings/log-drains.tsx diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx new file mode 100644 index 00000000000..aadba806269 --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -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 ( + ( + + + + + + )} + /> + ) +} + +export function LogDrainDestinationSheetForm({ + open, + onOpenChange, + defaultValues, + onSubmit, + isLoading, + mode, +}: { + open: boolean + onOpenChange: (v: boolean) => void + defaultValues?: Partial & { type: LogDrainType } + isLoading?: boolean + onSubmit: (values: z.infer) => void + mode: 'create' | 'update' +}) { + const defaultType = defaultValues?.type || 'webhook' + const [newCustomHeader, setNewCustomHeader] = useState({ name: '', value: '' }) + const form = useForm>({ + 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 ( + { + form.reset() + onOpenChange(v) + }} + > + + + Add destination + + + +
{ + e.preventDefault() + form.handleSubmit(onSubmit)(e) + }} + > +
+ + +
+ {mode === 'update' && ( +
+ + You cannot change the type of an existing log drain. Please, create a new + log drain with the new type. + + Can't update log drain type +
+ )} +
+ form.setValue('type', v)} + disabled={mode === 'update'} + > + {LOG_DRAIN_TYPES.map((type) => ( + + ))} + +
+ +
+ {type === 'webhook' && ( + <> + + ( + + + + + + + + + + + + + + + + + + )} + /> + + ( + +
+ + + + Gzip + + Gzip can reduce the size of the payload and increase the speed of the + request, +
but it will increase the CPU usage of the destination. +
+
+
+ )} + /> + +
+ Custom Headers + {hasHeaders && + Object.keys(headers || {})?.map((headerKey) => ( +
+
{headerKey}
+
+ {headers[headerKey]} +
+ +
+ ))} +
+ + )} + {type === 'datadog' && ( +
+ + ( + + + + + + + + + Region + {DATADOG_REGIONS.map((reg) => ( + + {reg.label} + + ))} + + + + + + )} + /> +
+ )} + {type === 'elastic' && ( +
+ + + +
+ )} +
+
+
+ + {/* This form needs to be outside the */} + {type === 'webhook' && ( +
{ + e.preventDefault() + e.stopPropagation() + addHeader() + }} + className="flex gap-2 mt-2 items-center" + > + setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} + /> + setNewCustomHeader({ ...newCustomHeader, value: e.target.value })} + /> + + + + )} +
+ + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx new file mode 100644 index 00000000000..f7b4e9a0b20 --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -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: , + }, + { + value: 'datadog', + name: 'Data dog', + description: 'Datadog is a monitoring service for cloud-scale applications', + icon: , + }, + { + value: 'elastic', + name: 'Elastic Filebeat', + description: 'Filebeat is a lightweight shipper for forwarding and centralizing log data', + icon: , + }, +] 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 +} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx new file mode 100644 index 00000000000..3889f52956f --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -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(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 ( + + + + ) + } + + if (isLoading || orgPlanLoading) { + return ( +
+ +
+ ) + } + + if (!isLoading && logDrains?.length === 0) { + return ( +
+ {LOG_DRAIN_TYPES.map((src) => ( + { + onNewDrainClick(src.value) + }} + /> + ))} +
+ ) + } + + if (isError) { + return + } + + return ( + <> + + + + + Name + Description + Source + +
Actions
+
+
+
+ + {logDrains?.map((drain) => ( + + {drain.name} + {drain.description} + {drain.type} + + + +
+
+ + ) +} diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index 38d18b153bd..dc03c33d496 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -47,6 +47,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren { 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: [], + }, + ] + : []), ], }, diff --git a/apps/studio/data/log-drains/create-log-drain-mutation.ts b/apps/studio/data/log-drains/create-log-drain-mutation.ts new file mode 100644 index 00000000000..2ea08149d6b --- /dev/null +++ b/apps/studio/data/log-drains/create-log-drain-mutation.ts @@ -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 + 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> + +export const useCreateLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (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, + } + ) +} diff --git a/apps/studio/data/log-drains/delete-log-drain-mutation.ts b/apps/studio/data/log-drains/delete-log-drain-mutation.ts new file mode 100644 index 00000000000..a78fcd108a4 --- /dev/null +++ b/apps/studio/data/log-drains/delete-log-drain-mutation.ts @@ -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> + +export const useDeleteLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (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, + } + ) +} diff --git a/apps/studio/data/log-drains/keys.ts b/apps/studio/data/log-drains/keys.ts new file mode 100644 index 00000000000..afc0219b7ee --- /dev/null +++ b/apps/studio/data/log-drains/keys.ts @@ -0,0 +1,3 @@ +export const logDrainsKeys = { + list: (projectRef: string | undefined) => ['projects', projectRef, 'log-drains'] as const, +} diff --git a/apps/studio/data/log-drains/log-drains-query.ts b/apps/studio/data/log-drains/log-drains-query.ts new file mode 100644 index 00000000000..e874534a6ca --- /dev/null +++ b/apps/studio/data/log-drains/log-drains-query.ts @@ -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> +export type LogDrainData = LogDrainsData[number] +export type LogDrainsyError = ResponseError + +export const useLogDrainsQuery = ( + { ref }: LogDrainsVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + logDrainsKeys.list(ref), + ({ signal }) => getLogDrains({ ref }, signal), + { + enabled: enabled && !!ref, + refetchOnMount: false, + ...options, + } + ) diff --git a/apps/studio/data/log-drains/update-log-drain-mutation.ts b/apps/studio/data/log-drains/update-log-drain-mutation.ts new file mode 100644 index 00000000000..39d55bb8b5f --- /dev/null +++ b/apps/studio/data/log-drains/update-log-drain-mutation.ts @@ -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 +} + +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> + +export const useUpdateLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (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, + } + ) +} diff --git a/apps/studio/hooks/misc/useCurrentOrgPlan.ts b/apps/studio/hooks/misc/useCurrentOrgPlan.ts new file mode 100644 index 00000000000..01a97d7b32f --- /dev/null +++ b/apps/studio/hooks/misc/useCurrentOrgPlan.ts @@ -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, + } + } +} diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx new file mode 100644 index 00000000000..ca9ff783a02 --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -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 | 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 ( + <> + + +
+ Log Drains + + Send your project logs to third party destinations + +
+
+ + {!(logDrains?.length === 0) && ( + + )} +
+
+
+ + { + 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) + } + } + }} + /> + + + + ) +} + +LogDrainsSettings.getLayout = (page) => {page} +export default LogDrainsSettings diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index a56e891dd08..12dbff9c1da 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -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 + 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 + 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 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 - 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 + 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 diff --git a/packages/ui-patterns/Cmdk/utils/shared-nav-items.json b/packages/ui-patterns/Cmdk/utils/shared-nav-items.json index 251579b7954..fa292da7753 100644 --- a/packages/ui-patterns/Cmdk/utils/shared-nav-items.json +++ b/packages/ui-patterns/Cmdk/utils/shared-nav-items.json @@ -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"