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
4 changes: 4 additions & 0 deletions apps/docs/content/docs/en/enterprise/data-drains.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Drains are independent of [Data Retention](/enterprise/data-retention) but desig

Go to **Settings → Enterprise → Data Drains** in your workspace, then click **New drain**.

![Data Drains settings page showing two configured drains — one exporting workflow logs to Amazon S3 daily, another exporting Copilot chats to an HTTPS webhook hourly](/static/enterprise/data-drains-list.png)

![New data drain dialog with fields for name, source, cadence, destination, and S3 credentials](/static/enterprise/data-drains-new.png)

Each drain has four pieces:

1. A **source** — the category of data to export
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
223 changes: 139 additions & 84 deletions apps/sim/ee/data-drains/components/data-drains-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ChevronDown, Plus, Search } from 'lucide-react'
import {
Badge,
Button,
Expand All @@ -19,7 +20,6 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
ModalTitle,
MoreHorizontal,
Switch,
Table,
Expand All @@ -30,6 +30,8 @@ import {
TableRow,
toast,
} from '@/components/emcn'
import { S3Icon } from '@/components/icons'
import { Input as BaseInput } from '@/components/ui'
import type { CreateDataDrainBody, DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
Expand Down Expand Up @@ -70,9 +72,15 @@ const CADENCE_LABELS: Record<(typeof CADENCE_TYPES)[number], string> = {

const SOURCE_OPTIONS = SOURCE_TYPES.map((t) => ({ value: t, label: SOURCE_LABELS[t] }))
const CADENCE_OPTIONS = CADENCE_TYPES.map((t) => ({ value: t, label: CADENCE_LABELS[t] }))
function getDestinationIcon(type: (typeof DESTINATION_TYPES)[number]) {
if (type !== 's3') return null
return <S3Icon className='size-[14px] flex-shrink-0 text-[#1B660F]' />
}

const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({
value: t,
label: DESTINATION_LABELS[t],
iconElement: getDestinationIcon(t),
}))

export function DataDrainsSettings() {
Expand All @@ -89,6 +97,19 @@ export function DataDrainsSettings() {

const [createOpen, setCreateOpen] = useState(false)
const [expandedDrainId, setExpandedDrainId] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')

const query = searchTerm.trim().toLowerCase()
const filteredDrains = !query
? (drains ?? [])
: (drains ?? []).filter((drain) =>
[
drain.name,
SOURCE_LABELS[drain.source],
DESTINATION_LABELS[drain.destinationType],
CADENCE_LABELS[drain.scheduleCadence],
].some((value) => value.toLowerCase().includes(query))
)

if (sessionPending || orgsLoading || drainsLoading) {
return <DataDrainsSkeleton />
Expand All @@ -111,61 +132,75 @@ export function DataDrainsSettings() {
}

return (
<div className='flex flex-col gap-6'>
<div className='flex h-full flex-col gap-4.5'>
<Callout>
Drains continuously export Sim data to your own storage on a schedule. Combine with Data
Retention to satisfy long-term compliance archives.
</Callout>

<div className='flex items-center justify-between'>
<div className='text-[13px] text-[var(--text-muted)]'>
{drains?.length ?? 0} drain{(drains?.length ?? 0) === 1 ? '' : 's'}
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-1.5 transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<Search
className='size-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<BaseInput
placeholder='Search data drains...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button variant='primary' onClick={() => setCreateOpen(true)}>
<Plus className='mr-1.5 size-[13px]' />
New drain
</Button>
</div>

{drainsError ? (
<Callout variant='destructive'>
Failed to load data drains: {toError(drainsError).message}
</Callout>
) : drains && drains.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className='w-[40px]' />
</TableRow>
</TableHeader>
<TableBody>
{drains.map((drain) => (
<DrainRow
key={drain.id}
drain={drain}
organizationId={orgId}
expanded={expandedDrainId === drain.id}
onToggleExpand={() =>
setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id)
}
/>
))}
</TableBody>
</Table>
) : (
<div className='flex flex-col items-center justify-center gap-3 rounded-lg border border-[var(--border)] border-dashed py-12 text-center'>
<div className='text-[14px] text-[var(--text-primary)]'>No drains yet</div>
<div className='max-w-[400px] text-[13px] text-[var(--text-muted)]'>
Create a drain to start exporting workflow logs, audit events, and copilot data to S3 or
your own webhook.
<div className='min-h-0 flex-1 overflow-y-auto'>
{drainsError ? (
<Callout variant='destructive'>
Failed to load data drains: {toError(drainsError).message}
</Callout>
) : drains && drains.length > 0 ? (
filteredDrains.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className='w-[40px]' />
</TableRow>
</TableHeader>
<TableBody>
{filteredDrains.map((drain) => (
<DrainRow
key={drain.id}
drain={drain}
organizationId={orgId}
expanded={expandedDrainId === drain.id}
onToggleExpand={() =>
setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id)
}
/>
))}
</TableBody>
</Table>
) : (
<div className='flex h-full items-center justify-center py-12 text-[var(--text-muted)] text-sm'>
No results for "{searchTerm.trim()}"
</div>
)
) : (
<div className='flex h-full items-center justify-center py-12 text-[var(--text-muted)] text-sm'>
Click "New drain" above to get started
</div>
</div>
)}
)}
</div>

{createOpen && (
<CreateDrainModal organizationId={orgId} onClose={() => setCreateOpen(false)} />
Expand Down Expand Up @@ -231,7 +266,17 @@ function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowP
return (
<>
<TableRow className='cursor-pointer' onClick={onToggleExpand}>
<TableCell className='font-medium'>{drain.name}</TableCell>
<TableCell className='font-medium'>
<div className='flex items-center gap-1.5'>
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
<span>{drain.name}</span>
</div>
</TableCell>
<TableCell>
<Badge>{SOURCE_LABELS[drain.source]}</Badge>
</TableCell>
Expand Down Expand Up @@ -288,7 +333,7 @@ function DrainRunsPanel({ organizationId, drainId }: DrainRunsPanelProps) {
const { data: runs, isLoading } = useDataDrainRuns(organizationId, drainId, 10)

if (isLoading) {
return <div className='text-[13px] text-[var(--text-muted)]'>Loading runs</div>
return <div className='text-[13px] text-[var(--text-muted)]'>Loading runs...</div>
}
if (!runs || runs.length === 0) {
return <div className='text-[13px] text-[var(--text-muted)]'>No runs yet.</div>
Expand Down Expand Up @@ -378,47 +423,57 @@ function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {

return (
<Modal open onOpenChange={(open) => !open && onClose()}>
<ModalContent className='max-w-[560px]'>
<ModalHeader>
<ModalTitle>New data drain</ModalTitle>
</ModalHeader>
<ModalBody className='flex flex-col gap-4'>
<FormField label='Name'>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Workflow logs to S3'
/>
</FormField>
<FormField label='Source'>
<Combobox
value={source}
onChange={(v) => setSource(v as (typeof SOURCE_TYPES)[number])}
options={SOURCE_OPTIONS}
dropdownWidth='trigger'
/>
</FormField>
<FormField label='Cadence'>
<Combobox
value={cadence}
onChange={(v) => setCadence(v as (typeof CADENCE_TYPES)[number])}
options={CADENCE_OPTIONS}
dropdownWidth='trigger'
/>
</FormField>
<FormField label='Destination'>
<Combobox
value={destinationType}
onChange={(v) => handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
options={DESTINATION_OPTIONS}
dropdownWidth='trigger'
/>
</FormField>
<ModalContent size='md' className='max-h-[76vh]'>
<ModalHeader>New data drain</ModalHeader>
<ModalBody className='flex min-h-0 flex-1 flex-col gap-3'>
<section className='flex flex-col gap-3'>
<FormField label='Name'>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Workflow logs export'
/>
</FormField>
<FormField label='Source'>
<Combobox
value={source}
onChange={(v) => setSource(v as (typeof SOURCE_TYPES)[number])}
options={SOURCE_OPTIONS}
dropdownWidth='trigger'
/>
</FormField>
<FormField label='Cadence'>
<Combobox
value={cadence}
onChange={(v) => setCadence(v as (typeof CADENCE_TYPES)[number])}
options={CADENCE_OPTIONS}
dropdownWidth='trigger'
/>
</FormField>
<FormField label='Destination'>
<Combobox
value={destinationType}
onChange={(v) => handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
options={DESTINATION_OPTIONS}
dropdownWidth='trigger'
overlayContent={
<div className='flex items-center gap-2'>
{getDestinationIcon(destinationType)}
<span className='truncate text-[var(--text-primary)]'>
{DESTINATION_LABELS[destinationType]}
</span>
</div>
}
/>
</FormField>
</section>

<spec.FormFields state={destState} setState={setDestState} />
<section className='flex flex-col gap-3'>
<spec.FormFields state={destState} setState={setDestState} />
</section>
</ModalBody>
<ModalFooter>
<Button variant='secondary' onClick={onClose}>
<Button variant='default' onClick={onClose}>
Cancel
</Button>
<Button
Expand Down
9 changes: 5 additions & 4 deletions apps/sim/ee/data-drains/components/data-drains-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { Skeleton } from '@/components/emcn'

export function DataDrainsSkeleton() {
return (
<div className='flex flex-col gap-8'>
<div className='flex items-center justify-between'>
<Skeleton className='h-[18px] w-[200px]' />
<div className='flex h-full flex-col gap-4.5'>
<Skeleton className='h-[34px] w-full rounded-lg' />
<div className='flex items-center gap-2'>
<Skeleton className='h-[34px] flex-1 rounded-lg' />
<Skeleton className='h-[34px] w-[110px] rounded-lg' />
</div>
<div className='flex flex-col gap-3'>
<div className='flex min-h-0 flex-1 flex-col gap-3'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-[64px] w-full rounded-lg' />
))}
Expand Down
Loading