mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
improvement(data-drains): docs page, screenshots, and search/UI polish (#4547)
Adds the enterprise data-drains docs page with two screenshots, a search bar over the drains table, and a UI cleanup pass on the settings component (size-*, useMemo removal, text-sm fixes). The previously-proposed env-var reference feature has been dropped — drain credentials remain raw values, encrypted at rest by the existing pipeline.
This commit is contained in:
@@ -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**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Each drain has four pieces:
|
||||
|
||||
1. A **source** — the category of data to export
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -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,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
MoreHorizontal,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -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'
|
||||
@@ -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() {
|
||||
@@ -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 />
|
||||
@@ -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)} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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' />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user