mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(data-drains): move drains to the fullscreen list/detail pattern (#5954)
* improvement(data-drains): move drains to the fullscreen list/detail pattern Data drains was the last settings surface still on a Table plus a create modal. It now matches Skills and Custom Tools: - the list is SettingsResourceRow rows in a clickable button, with the source, destination, cadence, and last run on the row and a Disabled tag when a drain is paused - clicking a row opens a detail sub-view: the drain's actions (Run now / Test connection / Delete), its enabled toggle, resolved destination config, and recent run history — replacing the expanding table row - creating a drain is a fullscreen view instead of a modal, reusing the destination form registry so the two never fork - a drain's detail is deep-linkable via `data-drain-id`, pushed on open and replaced on close, matching custom-tool-id and custom-block-id The detail is read-only apart from the enabled toggle: destination credentials are never returned by the API, so changing one means recreating the drain. Alignment work the migration surfaced: - the destination registry rendered its 36 fields with ChipModalField, whose label is byte-identical to a SettingsSection header — on a page that made section titles and field labels indistinguishable. All of them, and the create view's own fields, now use SettingRow like every other page-level detail view - the shared source/destination/cadence label maps moved to labels.ts, now that three files need them - run status uses Badge with a dot rather than hand-coloured text, and byte counts use formatFileSize — the old /1024 math showed a 5 GB export as "5242880.0 KB" - copy follows the sibling surfaces: Create drain, Disabled, Run queued, Connection test passed, and "No drains found matching …" - seed the list cache on create so landing on the new drain's detail doesn't depend on the invalidation refetch succeeding * fix(data-drains): wire destination field labels to their controls The ChipModalField -> SettingRow migration dropped the label/control association that ChipModalField generated for free, so screen readers announced the destination fields unnamed and clicking a label focused nothing. All 35 id-capable controls now carry an id with a matching htmlFor on their row; the one ChipSelect has no id prop, matching how the existing SettingRow + combobox rows render. Also pass includeBytes to formatFileSize — without it any run writing under 1 KB reported '0 Bytes'. * fix(data-drains): name the select fields for assistive tech ChipSelect takes an aria-label that lands on its trigger button, so the four select fields no longer announce as unnamed buttons — the Datadog site plus the create view's source, cadence, and destination type. The visible SettingRow label stays; this only gives the trigger an accessible name, since ChipSelect exposes no id for htmlFor to point at.
This commit is contained in:
@@ -163,6 +163,22 @@ export const customToolIdUrlKeys = {
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* `data-drain-id` deep-links the Data Drains settings tab to a specific drain's
|
||||
* detail sub-view. The "create new" flow stays in local state — only existing
|
||||
* entities are deep-linkable.
|
||||
*/
|
||||
export const dataDrainIdParam = {
|
||||
key: 'data-drain-id',
|
||||
parser: parseAsString,
|
||||
} as const
|
||||
|
||||
/** Opening a drain's detail is a destination → push to history; clear on close. */
|
||||
export const dataDrainIdUrlKeys = {
|
||||
history: 'push',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* `fork-direction` is the sync direction (push/pull) on the parent fork's detail
|
||||
* page — shareable view state, so a copied link opens the same side of the sync.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipInput, ChipSelect, toast } from '@sim/emcn'
|
||||
import { ArrowLeft, Database } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains'
|
||||
import type { CADENCE_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
|
||||
import { DESTINATION_TYPES } from '@/lib/data-drains/types'
|
||||
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
CredentialDetailHeading,
|
||||
UnsavedChangesModal,
|
||||
} from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
import { SettingRow } from '@/ee/components/setting-row'
|
||||
import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry'
|
||||
import { useCreateDataDrain } from '@/ee/data-drains/hooks/data-drains'
|
||||
import {
|
||||
CADENCE_OPTIONS,
|
||||
DESTINATION_LABELS,
|
||||
DESTINATION_OPTIONS,
|
||||
SOURCE_OPTIONS,
|
||||
} from '@/ee/data-drains/labels'
|
||||
|
||||
const logger = createLogger('DataDrainCreate')
|
||||
|
||||
interface DataDrainCreateProps {
|
||||
organizationId: string
|
||||
onBack: () => void
|
||||
/** Lands the caller on the drain it just created, matching the skills create flow. */
|
||||
onCreated: (drainId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page data drain creation rendered as a settings detail sub-view: a back
|
||||
* chip, a Create action, and the common fields plus the selected destination's
|
||||
* own fields from {@link DESTINATION_FORM_REGISTRY}.
|
||||
*/
|
||||
export function DataDrainCreate({ organizationId, onBack, onCreated }: DataDrainCreateProps) {
|
||||
const createDrain = useCreateDataDrain()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs')
|
||||
const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily')
|
||||
const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>(
|
||||
DESTINATION_TYPES[0]
|
||||
)
|
||||
const [destState, setDestState] = useState<unknown>(
|
||||
() => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState
|
||||
)
|
||||
|
||||
const spec = DESTINATION_FORM_REGISTRY[destinationType]
|
||||
const canSubmit = name.trim().length > 0 && spec.isComplete(destState)
|
||||
|
||||
const submitError = createDrain.error ? toError(createDrain.error).message : null
|
||||
const isDirty =
|
||||
name.trim().length > 0 ||
|
||||
JSON.stringify(destState) !== JSON.stringify(spec.initialState) ||
|
||||
source !== 'workflow_logs' ||
|
||||
cadence !== 'daily' ||
|
||||
destinationType !== DESTINATION_TYPES[0]
|
||||
|
||||
const guard = useSettingsUnsavedGuard({ isDirty })
|
||||
|
||||
const handleDestinationChange = (next: (typeof DESTINATION_TYPES)[number]) => {
|
||||
setDestinationType(next)
|
||||
setDestState(DESTINATION_FORM_REGISTRY[next].initialState)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || createDrain.isPending) return
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
source,
|
||||
scheduleCadence: cadence,
|
||||
...spec.toDestinationBranch(destState),
|
||||
} as CreateDataDrainBody
|
||||
try {
|
||||
const drain = await createDrain.mutateAsync({ organizationId, body })
|
||||
toast.success('Drain created')
|
||||
onCreated(drain.id)
|
||||
} catch (error) {
|
||||
const message = toError(error).message
|
||||
toast.error("Couldn't create drain", { description: message })
|
||||
logger.error('Failed to create data drain', { error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const actions: SettingsAction[] = [
|
||||
{
|
||||
text: createDrain.isPending ? 'Creating...' : 'Create',
|
||||
variant: 'primary',
|
||||
onSelect: handleSubmit,
|
||||
disabled: !canSubmit || createDrain.isPending,
|
||||
tooltip: canSubmit ? undefined : 'Name the drain and complete its destination',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
back={{ text: 'Data drains', icon: ArrowLeft, onSelect: () => guard.guardBack(onBack) }}
|
||||
title='New drain'
|
||||
actions={actions}
|
||||
>
|
||||
<div className='flex flex-col gap-7'>
|
||||
<CredentialDetailHeading
|
||||
leading={<ResourceTile icon={Database} />}
|
||||
title='New drain'
|
||||
subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.'
|
||||
/>
|
||||
|
||||
<SettingsSection label='Drain'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<SettingRow label='Name' htmlFor='data-drain-name'>
|
||||
<ChipInput
|
||||
id='data-drain-name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder='Workflow logs export'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Source'>
|
||||
<ChipSelect
|
||||
aria-label='Source'
|
||||
value={source}
|
||||
onChange={(v) => setSource(v as (typeof SOURCE_TYPES)[number])}
|
||||
options={SOURCE_OPTIONS}
|
||||
align='start'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Cadence'>
|
||||
<ChipSelect
|
||||
aria-label='Cadence'
|
||||
value={cadence}
|
||||
onChange={(v) => setCadence(v as (typeof CADENCE_TYPES)[number])}
|
||||
options={CADENCE_OPTIONS}
|
||||
align='start'
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Destination'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<SettingRow label='Type'>
|
||||
<ChipSelect
|
||||
aria-label='Destination type'
|
||||
value={destinationType}
|
||||
onChange={(v) => handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
|
||||
options={DESTINATION_OPTIONS}
|
||||
displayLabel={DESTINATION_LABELS[destinationType]}
|
||||
align='start'
|
||||
/>
|
||||
</SettingRow>
|
||||
<spec.FormFields state={destState} setState={setDestState} />
|
||||
{submitError && (
|
||||
<p role='alert' className='text-[var(--text-error)] text-caption'>
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
||||
<UnsavedChangesModal
|
||||
open={guard.showUnsavedModal}
|
||||
onOpenChange={guard.setShowUnsavedModal}
|
||||
onDiscard={guard.confirmDiscard}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Badge, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn'
|
||||
import { ArrowLeft, Database } from '@sim/emcn/icons'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import { CredentialDetailHeading } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import {
|
||||
useDataDrainRuns,
|
||||
useDeleteDataDrain,
|
||||
useRunDataDrainNow,
|
||||
useTestDataDrain,
|
||||
useUpdateDataDrain,
|
||||
} from '@/ee/data-drains/hooks/data-drains'
|
||||
import { CADENCE_LABELS, DESTINATION_LABELS, SOURCE_LABELS } from '@/ee/data-drains/labels'
|
||||
|
||||
const RECENT_RUN_LIMIT = 10
|
||||
|
||||
/** Rendered generically so a new destination's config needs no formatter. */
|
||||
function formatConfigValue(value: unknown): string {
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** `forcePathStyle` → `Force path style`, so a new destination field needs no map. */
|
||||
function humanizeConfigKey(key: string): string {
|
||||
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: DetailRowProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)] text-small'>{label}</span>
|
||||
<span className='min-w-0 break-words text-right text-[var(--text-body)] text-sm'>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataDrainDetailProps {
|
||||
organizationId: string
|
||||
drain: DataDrain
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page data drain detail rendered as a settings detail sub-view: a back
|
||||
* chip, the drain's actions (Run now / Test connection / Delete), its resolved
|
||||
* configuration, and recent run history. Only the enabled toggle is editable —
|
||||
* destination credentials are never returned, so changing one means recreating
|
||||
* the drain.
|
||||
*/
|
||||
export function DataDrainDetail({ organizationId, drain, onBack }: DataDrainDetailProps) {
|
||||
const updateDrain = useUpdateDataDrain()
|
||||
const deleteDrain = useDeleteDataDrain()
|
||||
const runDrain = useRunDataDrainNow()
|
||||
const testDrain = useTestDataDrain()
|
||||
const {
|
||||
data: runs,
|
||||
isPending: runsPending,
|
||||
isPlaceholderData: runsArePlaceholder,
|
||||
} = useDataDrainRuns(organizationId, drain.id, RECENT_RUN_LIMIT)
|
||||
// Defensive: the runs query keeps previous data, which would read as another
|
||||
// drain's history if this view ever renders without a per-drain key.
|
||||
const runsLoading = runsPending || runsArePlaceholder
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
try {
|
||||
await updateDrain.mutateAsync({
|
||||
organizationId,
|
||||
drainId: drain.id,
|
||||
body: { enabled: !drain.enabled },
|
||||
})
|
||||
toast.success(drain.enabled ? 'Drain disabled' : 'Drain enabled')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunNow = async () => {
|
||||
try {
|
||||
await runDrain.mutateAsync({ organizationId, drainId: drain.id })
|
||||
toast.success('Run queued')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
await testDrain.mutateAsync({ organizationId, drainId: drain.id })
|
||||
toast.success('Connection test passed')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
try {
|
||||
await deleteDrain.mutateAsync({ organizationId, drainId: drain.id })
|
||||
onBack()
|
||||
toast.success('Drain deleted')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const actions: SettingsAction[] = [
|
||||
{
|
||||
text: 'Run now',
|
||||
onSelect: handleRunNow,
|
||||
disabled: !drain.enabled || runDrain.isPending,
|
||||
tooltip: drain.enabled ? undefined : 'Enable the drain to run it',
|
||||
},
|
||||
{ text: 'Test connection', onSelect: handleTest, disabled: testDrain.isPending },
|
||||
{
|
||||
text: 'Delete',
|
||||
variant: 'destructive',
|
||||
onSelect: () => setShowDeleteConfirm(true),
|
||||
disabled: deleteDrain.isPending,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
back={{ text: 'Data drains', icon: ArrowLeft, onSelect: onBack }}
|
||||
title={drain.name}
|
||||
actions={actions}
|
||||
>
|
||||
<div className='flex flex-col gap-7'>
|
||||
<CredentialDetailHeading
|
||||
leading={<ResourceTile icon={Database} />}
|
||||
title={drain.name}
|
||||
subtitle={`${SOURCE_LABELS[drain.source]} → ${DESTINATION_LABELS[drain.destinationType]} · ${CADENCE_LABELS[drain.scheduleCadence]}`}
|
||||
/>
|
||||
|
||||
<SettingsSection label='Status'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label htmlFor='data-drain-enabled'>Enabled</Label>
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
Disabled drains keep their cursor and resume where they left off.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id='data-drain-enabled'
|
||||
checked={drain.enabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
disabled={updateDrain.isPending}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Schedule'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<DetailRow label='Source'>{SOURCE_LABELS[drain.source]}</DetailRow>
|
||||
<DetailRow label='Cadence'>{CADENCE_LABELS[drain.scheduleCadence]}</DetailRow>
|
||||
<DetailRow label='Last run'>
|
||||
<span suppressHydrationWarning>
|
||||
{drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label='Last success'>
|
||||
<span suppressHydrationWarning>
|
||||
{drain.lastSuccessAt ? new Date(drain.lastSuccessAt).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Destination'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<DetailRow label='Type'>{DESTINATION_LABELS[drain.destinationType]}</DetailRow>
|
||||
{Object.entries(drain.destinationConfig).map(([key, value]) => (
|
||||
<DetailRow key={key} label={humanizeConfigKey(key)}>
|
||||
{formatConfigValue(value)}
|
||||
</DetailRow>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Recent runs'>
|
||||
{runsLoading ? (
|
||||
<SettingsEmptyState variant='inline'>Loading runs...</SettingsEmptyState>
|
||||
) : runs && runs.length > 0 ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState variant='inline'>No runs yet</SettingsEmptyState>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
srTitle='Delete drain'
|
||||
title='Delete drain'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: drain.name, bold: true },
|
||||
'? This action cannot be undone.',
|
||||
]}
|
||||
confirm={{ label: 'Delete', onClick: handleConfirmDelete }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const RUN_STATUS_VARIANT = {
|
||||
success: 'green',
|
||||
failed: 'red',
|
||||
running: 'blue',
|
||||
} as const
|
||||
|
||||
function RunRow({ run }: { run: DataDrainRun }) {
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-4 rounded-lg border border-[var(--border-1)] px-3 py-2 text-caption'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant={RUN_STATUS_VARIANT[run.status]} size='sm' dot>
|
||||
{run.status}
|
||||
</Badge>
|
||||
<span className='text-[var(--text-muted)]'>{run.trigger}</span>
|
||||
<span className='text-[var(--text-muted)]' suppressHydrationWarning>
|
||||
{new Date(run.startedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{run.error && <div className='break-words text-[var(--text-error)]'>{run.error}</div>}
|
||||
</div>
|
||||
<div className='flex-shrink-0 text-right text-[var(--text-muted)]'>
|
||||
<div className='tabular-nums'>{run.rowsExported.toLocaleString()} rows</div>
|
||||
<div className='tabular-nums'>
|
||||
{formatFileSize(run.bytesWritten, { includeBytes: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipTag } from '@sim/emcn'
|
||||
import { ArrowRight, Database, Plus } from '@sim/emcn/icons'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
Badge,
|
||||
ChipConfirmModal,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipSelect,
|
||||
cn,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import type { CreateDataDrainBody, DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains'
|
||||
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
dataDrainIdParam,
|
||||
dataDrainIdUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry'
|
||||
import {
|
||||
useCreateDataDrain,
|
||||
useDataDrainRuns,
|
||||
useDataDrains,
|
||||
useDeleteDataDrain,
|
||||
useRunDataDrainNow,
|
||||
useTestDataDrain,
|
||||
useUpdateDataDrain,
|
||||
} from '@/ee/data-drains/hooks/data-drains'
|
||||
|
||||
const logger = createLogger('DataDrainsSettings')
|
||||
|
||||
const SOURCE_LABELS: Record<(typeof SOURCE_TYPES)[number], string> = {
|
||||
workflow_logs: 'Workflow logs',
|
||||
job_logs: 'Job logs',
|
||||
audit_logs: 'Audit logs',
|
||||
copilot_chats: 'Chats',
|
||||
copilot_runs: 'Chat runs',
|
||||
}
|
||||
|
||||
const DESTINATION_LABELS: Record<(typeof DESTINATION_TYPES)[number], string> = {
|
||||
s3: 'Amazon S3',
|
||||
gcs: 'Google Cloud Storage',
|
||||
azure_blob: 'Azure Blob Storage',
|
||||
datadog: 'Datadog',
|
||||
bigquery: 'Google BigQuery',
|
||||
snowflake: 'Snowflake',
|
||||
webhook: 'HTTPS webhook',
|
||||
}
|
||||
|
||||
const CADENCE_LABELS: Record<(typeof CADENCE_TYPES)[number], string> = {
|
||||
hourly: 'Every hour',
|
||||
daily: 'Every day',
|
||||
}
|
||||
|
||||
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] }))
|
||||
|
||||
const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: DESTINATION_LABELS[t],
|
||||
}))
|
||||
import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create'
|
||||
import { DataDrainDetail } from '@/ee/data-drains/components/data-drain-detail'
|
||||
import { useDataDrains } from '@/ee/data-drains/hooks/data-drains'
|
||||
import { CADENCE_LABELS, DESTINATION_LABELS, SOURCE_LABELS } from '@/ee/data-drains/labels'
|
||||
|
||||
interface DataDrainsSettingsProps {
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) {
|
||||
const {
|
||||
data: drains,
|
||||
isLoading: drainsLoading,
|
||||
error: drainsError,
|
||||
} = useDataDrains(organizationId)
|
||||
const { data: drains, isPending, error } = useDataDrains(organizationId)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [expandedDrainId, setExpandedDrainId] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [selectedDrainId, setSelectedDrainId] = useQueryState(dataDrainIdParam.key, {
|
||||
...dataDrainIdParam.parser,
|
||||
...dataDrainIdUrlKeys,
|
||||
})
|
||||
/** The create flow has no entity id and is not deep-linkable — stays local. */
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const selectedDrain = selectedDrainId ? drains?.find((d) => d.id === selectedDrainId) : undefined
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false)
|
||||
void setSelectedDrainId(null, { history: 'replace' })
|
||||
}
|
||||
|
||||
const query = searchTerm.trim().toLowerCase()
|
||||
const filteredDrains = !query
|
||||
@@ -101,360 +53,104 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps)
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
)
|
||||
|
||||
if (drainsLoading) return null
|
||||
const actions: SettingsAction[] = [
|
||||
{
|
||||
text: 'Create drain',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setIsCreating(true),
|
||||
disabled: isPending,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
actions={[
|
||||
{
|
||||
text: 'New drain',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setCreateOpen(true),
|
||||
},
|
||||
]}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search data drains...',
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col gap-4.5'>
|
||||
<div>
|
||||
{drainsError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[var(--text-error)] text-sm leading-tight'>
|
||||
Failed to load data drains: {toError(drainsError).message}
|
||||
</p>
|
||||
</div>
|
||||
) : 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={organizationId}
|
||||
expanded={expandedDrainId === drain.id}
|
||||
onToggleExpand={() =>
|
||||
setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No results for "{searchTerm.trim()}"
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
) : (
|
||||
<SettingsEmptyState>Click "New drain" above to get started</SettingsEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
/**
|
||||
* Hold the first paint while a deep-linked id could still resolve, so a valid
|
||||
* link never flashes the list before jumping to it. A dead id still falls back
|
||||
* to the list.
|
||||
*/
|
||||
if (selectedDrainId !== null && isPending) return null
|
||||
|
||||
{createOpen && (
|
||||
<CreateDrainModal organizationId={organizationId} onClose={() => setCreateOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DrainRowProps {
|
||||
drain: DataDrain
|
||||
organizationId: string
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
}
|
||||
|
||||
function DrainRow({ drain, organizationId, expanded, onToggleExpand }: DrainRowProps) {
|
||||
const updateMutation = useUpdateDataDrain()
|
||||
const deleteMutation = useDeleteDataDrain()
|
||||
const runMutation = useRunDataDrainNow()
|
||||
const testMutation = useTestDataDrain()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
async function handleToggleEnabled() {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
organizationId,
|
||||
drainId: drain.id,
|
||||
body: { enabled: !drain.enabled },
|
||||
})
|
||||
toast.success(drain.enabled ? 'Drain disabled' : 'Drain enabled')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunNow() {
|
||||
try {
|
||||
await runMutation.mutateAsync({ organizationId, drainId: drain.id })
|
||||
toast.success('Drain run enqueued')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
try {
|
||||
await testMutation.mutateAsync({ organizationId, drainId: drain.id })
|
||||
toast.success('Connection test succeeded')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
try {
|
||||
setShowDeleteConfirm(false)
|
||||
await deleteMutation.mutateAsync({ organizationId, drainId: drain.id })
|
||||
toast.success('Drain deleted')
|
||||
} catch (error) {
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className='cursor-pointer' onClick={onToggleExpand}>
|
||||
<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>
|
||||
<TableCell>
|
||||
<Badge>{DESTINATION_LABELS[drain.destinationType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{CADENCE_LABELS[drain.scheduleCadence]}</TableCell>
|
||||
<TableCell className='text-[var(--text-muted)] text-small' suppressHydrationWarning>
|
||||
{drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={drain.enabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<RowActionsMenu
|
||||
label='Drain actions'
|
||||
actions={[
|
||||
{ label: 'Run now', onSelect: handleRunNow, disabled: !drain.enabled },
|
||||
{ label: 'Test connection', onSelect: handleTest },
|
||||
{ label: 'Delete', onSelect: handleDelete, destructive: true },
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className='bg-[var(--surface-muted)] p-4'>
|
||||
<DrainRunsPanel organizationId={organizationId} drainId={drain.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<ChipConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
srTitle='Delete Drain'
|
||||
title='Delete Drain'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: drain.name, bold: true },
|
||||
'? This action cannot be undone.',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: handleConfirmDelete,
|
||||
pending: deleteMutation.isPending,
|
||||
pendingLabel: 'Deleting...',
|
||||
if (isCreating) {
|
||||
return (
|
||||
<DataDrainCreate
|
||||
organizationId={organizationId}
|
||||
onBack={() => setIsCreating(false)}
|
||||
onCreated={(drainId) => {
|
||||
setIsCreating(false)
|
||||
void setSelectedDrainId(drainId)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DrainRunsPanelProps {
|
||||
organizationId: string
|
||||
drainId: string
|
||||
}
|
||||
|
||||
function DrainRunsPanel({ organizationId, drainId }: DrainRunsPanelProps) {
|
||||
const { data: runs, isLoading } = useDataDrainRuns(organizationId, drainId, 10)
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='text-[var(--text-muted)] text-small'>Loading runs...</div>
|
||||
}
|
||||
if (!runs || runs.length === 0) {
|
||||
return <div className='text-[var(--text-muted)] text-small'>No runs yet.</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='font-medium text-[var(--text-primary)] text-small'>Recent runs</div>
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: DataDrainRun }) {
|
||||
const statusColor =
|
||||
run.status === 'success'
|
||||
? 'text-[var(--text-success)]'
|
||||
: run.status === 'failed'
|
||||
? 'text-[var(--text-error)]'
|
||||
: 'text-[var(--text-muted)]'
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-4 rounded-lg border border-[var(--border)] px-3 py-2 text-caption'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className={cn('font-medium', statusColor)}>{run.status}</span>
|
||||
<span className='text-[var(--text-muted)]'>{run.trigger}</span>
|
||||
<span className='text-[var(--text-muted)]' suppressHydrationWarning>
|
||||
{new Date(run.startedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{run.error && <div className='text-[var(--text-error)]'>{run.error}</div>}
|
||||
</div>
|
||||
<div className='text-right text-[var(--text-muted)]'>
|
||||
<div>{run.rowsExported.toLocaleString()} rows</div>
|
||||
<div>{(run.bytesWritten / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CreateDrainModalProps {
|
||||
organizationId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {
|
||||
const createMutation = useCreateDataDrain()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs')
|
||||
const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily')
|
||||
const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>(
|
||||
DESTINATION_TYPES[0]
|
||||
)
|
||||
const [destState, setDestState] = useState<unknown>(
|
||||
() => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState
|
||||
)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const spec = DESTINATION_FORM_REGISTRY[destinationType]
|
||||
const canSubmit = name.trim().length > 0 && spec.isComplete(destState)
|
||||
|
||||
function handleDestinationChange(next: (typeof DESTINATION_TYPES)[number]) {
|
||||
setDestinationType(next)
|
||||
setDestState(DESTINATION_FORM_REGISTRY[next].initialState)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
source,
|
||||
scheduleCadence: cadence,
|
||||
...spec.toDestinationBranch(destState),
|
||||
} as CreateDataDrainBody
|
||||
await createMutation.mutateAsync({ organizationId, body })
|
||||
toast.success('Drain created')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
const msg = toError(error).message
|
||||
logger.error('Failed to create data drain', { error: msg })
|
||||
setSubmitError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal open onOpenChange={(open) => !open && onClose()} srTitle='New data drain' size='md'>
|
||||
<ChipModalHeader onClose={() => onClose()}>New data drain</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder='Workflow logs export'
|
||||
required
|
||||
/>
|
||||
<ChipModalField type='custom' title='Source'>
|
||||
<ChipSelect
|
||||
value={source}
|
||||
onChange={(v) => setSource(v as (typeof SOURCE_TYPES)[number])}
|
||||
options={SOURCE_OPTIONS}
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' title='Cadence'>
|
||||
<ChipSelect
|
||||
value={cadence}
|
||||
onChange={(v) => setCadence(v as (typeof CADENCE_TYPES)[number])}
|
||||
options={CADENCE_OPTIONS}
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' title='Destination'>
|
||||
<ChipSelect
|
||||
value={destinationType}
|
||||
onChange={(v) => handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
|
||||
options={DESTINATION_OPTIONS}
|
||||
displayLabel={DESTINATION_LABELS[destinationType]}
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<section className='flex flex-col gap-4 px-2'>
|
||||
<spec.FormFields state={destState} setState={setDestState} />
|
||||
</section>
|
||||
<ChipModalError>{submitError}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={onClose}
|
||||
cancelDisabled={createMutation.isPending}
|
||||
primaryAction={{
|
||||
label: createMutation.isPending ? 'Creating...' : 'Create drain',
|
||||
onClick: handleSubmit,
|
||||
disabled: !canSubmit || createMutation.isPending,
|
||||
}}
|
||||
if (selectedDrain) {
|
||||
return (
|
||||
<DataDrainDetail
|
||||
key={selectedDrain.id}
|
||||
organizationId={organizationId}
|
||||
drain={selectedDrain}
|
||||
onBack={closeDetail}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
actions={actions}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search data drains...',
|
||||
disabled: isPending,
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[var(--text-error)] text-sm leading-tight'>
|
||||
{getErrorMessage(error, "Couldn't load data drains")}
|
||||
</p>
|
||||
</div>
|
||||
) : isPending ? null : drains && drains.length > 0 ? (
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{filteredDrains.map((drain) => (
|
||||
<button
|
||||
key={drain.id}
|
||||
type='button'
|
||||
onClick={() => void setSelectedDrainId(drain.id)}
|
||||
className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<SettingsResourceRow
|
||||
icon={<Database className='text-[var(--text-icon)]' />}
|
||||
iconFilled
|
||||
title={drain.name}
|
||||
description={
|
||||
<>
|
||||
{`${SOURCE_LABELS[drain.source]} → ${DESTINATION_LABELS[drain.destinationType]} · ${CADENCE_LABELS[drain.scheduleCadence]} · `}
|
||||
<span suppressHydrationWarning>
|
||||
{drain.lastRunAt
|
||||
? `Last run ${new Date(drain.lastRunAt).toLocaleDateString()}`
|
||||
: 'Never run'}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
{!drain.enabled && <ChipTag variant='gray'>Disabled</ChipTag>}
|
||||
<ArrowRight className='size-4 text-[var(--text-icon)]' />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{filteredDrains.length === 0 && (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No drains found matching "{searchTerm}"
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState>Click "Create drain" above to get started</SettingsEmptyState>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { ChipInput, ChipModalField, ChipSelect, ChipTextarea, SecretInput, Switch } from '@sim/emcn'
|
||||
import { ChipInput, ChipSelect, ChipTextarea, SecretInput, Switch } from '@sim/emcn'
|
||||
import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains'
|
||||
import type { DestinationType } from '@/lib/data-drains/types'
|
||||
import { SettingRow } from '@/ee/components/setting-row'
|
||||
|
||||
type DestinationBranch = Pick<
|
||||
CreateDataDrainBody,
|
||||
@@ -44,54 +45,67 @@ const s3FormSpec: DestinationFormSpec<S3State> = {
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Bucket'>
|
||||
<SettingRow label='Bucket' htmlFor='drain-s3-bucket'>
|
||||
<ChipInput
|
||||
id='drain-s3-bucket'
|
||||
value={state.bucket}
|
||||
onChange={(e) => setState({ ...state, bucket: e.target.value })}
|
||||
placeholder='my-logs-bucket'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Region'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Region' htmlFor='drain-s3-region'>
|
||||
<ChipInput
|
||||
id='drain-s3-region'
|
||||
value={state.region}
|
||||
onChange={(e) => setState({ ...state, region: e.target.value })}
|
||||
placeholder='us-east-1'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Prefix (optional)' htmlFor='drain-s3-prefix-optional'>
|
||||
<ChipInput
|
||||
id='drain-s3-prefix-optional'
|
||||
value={state.prefix}
|
||||
onChange={(e) => setState({ ...state, prefix: e.target.value })}
|
||||
placeholder='exports/sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Endpoint (optional, S3-compatible stores)'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Endpoint (optional, S3-compatible stores)'
|
||||
htmlFor='drain-s3-endpoint-optional-s3-compatible-stores'
|
||||
>
|
||||
<ChipInput
|
||||
id='drain-s3-endpoint-optional-s3-compatible-stores'
|
||||
value={state.endpoint}
|
||||
onChange={(e) => setState({ ...state, endpoint: e.target.value })}
|
||||
placeholder='https://s3.example.com'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Force path style (MinIO, Ceph)'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Force path style (MinIO, Ceph)'
|
||||
htmlFor='drain-s3-force-path-style-minio-ceph'
|
||||
>
|
||||
<Switch
|
||||
id='drain-s3-force-path-style-minio-ceph'
|
||||
checked={state.forcePathStyle}
|
||||
onCheckedChange={(v) => setState({ ...state, forcePathStyle: v })}
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Access key ID'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Access key ID' htmlFor='drain-s3-access-key-id'>
|
||||
<SecretInput
|
||||
id='drain-s3-access-key-id'
|
||||
value={state.accessKeyId}
|
||||
onChange={(v) => setState({ ...state, accessKeyId: v })}
|
||||
placeholder='AKIA...'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Secret access key'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Secret access key' htmlFor='drain-s3-secret-access-key'>
|
||||
<SecretInput
|
||||
id='drain-s3-secret-access-key'
|
||||
value={state.secretAccessKey}
|
||||
onChange={(v) => setState({ ...state, secretAccessKey: v })}
|
||||
placeholder='Paste your secret access key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
@@ -126,28 +140,31 @@ const gcsFormSpec: DestinationFormSpec<GCSState> = {
|
||||
initialState: { bucket: '', prefix: '', serviceAccountJson: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Bucket'>
|
||||
<SettingRow label='Bucket' htmlFor='drain-gcs-bucket'>
|
||||
<ChipInput
|
||||
id='drain-gcs-bucket'
|
||||
value={state.bucket}
|
||||
onChange={(e) => setState({ ...state, bucket: e.target.value })}
|
||||
placeholder='my-logs-bucket'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Prefix (optional)' htmlFor='drain-gcs-prefix-optional'>
|
||||
<ChipInput
|
||||
id='drain-gcs-prefix-optional'
|
||||
value={state.prefix}
|
||||
onChange={(e) => setState({ ...state, prefix: e.target.value })}
|
||||
placeholder='exports/sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Service account JSON key'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Service account JSON key' htmlFor='drain-gcs-service-account-json-key'>
|
||||
<ChipTextarea
|
||||
id='drain-gcs-service-account-json-key'
|
||||
value={state.serviceAccountJson}
|
||||
onChange={(e) => setState({ ...state, serviceAccountJson: e.target.value })}
|
||||
placeholder='{ "type": "service_account", ... }'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.bucket.length >= 3 && s.serviceAccountJson.length > 0,
|
||||
@@ -177,41 +194,49 @@ const azureBlobFormSpec: DestinationFormSpec<AzureBlobState> = {
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Account name'>
|
||||
<SettingRow label='Account name' htmlFor='drain-azure-blob-account-name'>
|
||||
<ChipInput
|
||||
id='drain-azure-blob-account-name'
|
||||
value={state.accountName}
|
||||
onChange={(e) => setState({ ...state, accountName: e.target.value })}
|
||||
placeholder='mystorageaccount'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Container'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Container' htmlFor='drain-azure-blob-container'>
|
||||
<ChipInput
|
||||
id='drain-azure-blob-container'
|
||||
value={state.containerName}
|
||||
onChange={(e) => setState({ ...state, containerName: e.target.value })}
|
||||
placeholder='sim-exports'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Prefix (optional)' htmlFor='drain-azure-blob-prefix-optional'>
|
||||
<ChipInput
|
||||
id='drain-azure-blob-prefix-optional'
|
||||
value={state.prefix}
|
||||
onChange={(e) => setState({ ...state, prefix: e.target.value })}
|
||||
placeholder='exports/sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Endpoint suffix (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Endpoint suffix (optional)'
|
||||
htmlFor='drain-azure-blob-endpoint-suffix-optional'
|
||||
>
|
||||
<ChipInput
|
||||
id='drain-azure-blob-endpoint-suffix-optional'
|
||||
value={state.endpointSuffix}
|
||||
onChange={(e) => setState({ ...state, endpointSuffix: e.target.value })}
|
||||
placeholder='blob.core.windows.net'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Account key'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Account key' htmlFor='drain-azure-blob-account-key'>
|
||||
<SecretInput
|
||||
id='drain-azure-blob-account-key'
|
||||
value={state.accountKey}
|
||||
onChange={(v) => setState({ ...state, accountKey: v })}
|
||||
placeholder='Paste your storage account key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
@@ -250,35 +275,42 @@ const datadogFormSpec: DestinationFormSpec<DatadogState> = {
|
||||
initialState: { site: 'us1', service: '', tags: '', apiKey: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Site'>
|
||||
<SettingRow label='Site'>
|
||||
<ChipSelect
|
||||
aria-label='Site'
|
||||
value={state.site}
|
||||
onChange={(v) => setState({ ...state, site: v as DatadogState['site'] })}
|
||||
options={DATADOG_SITE_OPTIONS}
|
||||
align='start'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Service (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Service (optional)' htmlFor='drain-datadog-service-optional'>
|
||||
<ChipInput
|
||||
id='drain-datadog-service-optional'
|
||||
value={state.service}
|
||||
onChange={(e) => setState({ ...state, service: e.target.value })}
|
||||
placeholder='sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Tags (optional, comma-separated)'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Tags (optional, comma-separated)'
|
||||
htmlFor='drain-datadog-tags-optional-comma-separated'
|
||||
>
|
||||
<ChipInput
|
||||
id='drain-datadog-tags-optional-comma-separated'
|
||||
value={state.tags}
|
||||
onChange={(e) => setState({ ...state, tags: e.target.value })}
|
||||
placeholder='env:prod,team:platform'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='API key'>
|
||||
</SettingRow>
|
||||
<SettingRow label='API key' htmlFor='drain-datadog-api-key'>
|
||||
<SecretInput
|
||||
id='drain-datadog-api-key'
|
||||
value={state.apiKey}
|
||||
onChange={(v) => setState({ ...state, apiKey: v })}
|
||||
placeholder='Paste your Datadog API key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.apiKey.length > 0,
|
||||
@@ -305,35 +337,42 @@ const bigqueryFormSpec: DestinationFormSpec<BigQueryState> = {
|
||||
initialState: { projectId: '', datasetId: '', tableId: '', serviceAccountJson: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Project ID'>
|
||||
<SettingRow label='Project ID' htmlFor='drain-bigquery-project-id'>
|
||||
<ChipInput
|
||||
id='drain-bigquery-project-id'
|
||||
value={state.projectId}
|
||||
onChange={(e) => setState({ ...state, projectId: e.target.value })}
|
||||
placeholder='my-gcp-project'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Dataset'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Dataset' htmlFor='drain-bigquery-dataset'>
|
||||
<ChipInput
|
||||
id='drain-bigquery-dataset'
|
||||
value={state.datasetId}
|
||||
onChange={(e) => setState({ ...state, datasetId: e.target.value })}
|
||||
placeholder='sim_drains'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Table'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Table' htmlFor='drain-bigquery-table'>
|
||||
<ChipInput
|
||||
id='drain-bigquery-table'
|
||||
value={state.tableId}
|
||||
onChange={(e) => setState({ ...state, tableId: e.target.value })}
|
||||
placeholder='workflow_logs'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Service account JSON key'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Service account JSON key'
|
||||
htmlFor='drain-bigquery-service-account-json-key'
|
||||
>
|
||||
<ChipTextarea
|
||||
id='drain-bigquery-service-account-json-key'
|
||||
value={state.serviceAccountJson}
|
||||
onChange={(e) => setState({ ...state, serviceAccountJson: e.target.value })}
|
||||
placeholder='{ "type": "service_account", ... }'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
@@ -375,70 +414,82 @@ const snowflakeFormSpec: DestinationFormSpec<SnowflakeState> = {
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Account identifier'>
|
||||
<SettingRow label='Account identifier' htmlFor='drain-snowflake-account-identifier'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-account-identifier'
|
||||
value={state.account}
|
||||
onChange={(e) => setState({ ...state, account: e.target.value })}
|
||||
placeholder='orgname-accountname'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='User'>
|
||||
</SettingRow>
|
||||
<SettingRow label='User' htmlFor='drain-snowflake-user'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-user'
|
||||
value={state.user}
|
||||
onChange={(e) => setState({ ...state, user: e.target.value })}
|
||||
placeholder='SIM_DRAIN_USER'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Warehouse'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Warehouse' htmlFor='drain-snowflake-warehouse'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-warehouse'
|
||||
value={state.warehouse}
|
||||
onChange={(e) => setState({ ...state, warehouse: e.target.value })}
|
||||
placeholder='COMPUTE_WH'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Database'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Database' htmlFor='drain-snowflake-database'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-database'
|
||||
value={state.database}
|
||||
onChange={(e) => setState({ ...state, database: e.target.value })}
|
||||
placeholder='SIM'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Schema'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Schema' htmlFor='drain-snowflake-schema'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-schema'
|
||||
value={state.schema}
|
||||
onChange={(e) => setState({ ...state, schema: e.target.value })}
|
||||
placeholder='PUBLIC'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Table'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Table' htmlFor='drain-snowflake-table'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-table'
|
||||
value={state.table}
|
||||
onChange={(e) => setState({ ...state, table: e.target.value })}
|
||||
placeholder='WORKFLOW_LOGS'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Column (optional, defaults to "DATA")'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Column (optional, defaults to "DATA")'
|
||||
htmlFor='drain-snowflake-column-optional-defaults-to-data'
|
||||
>
|
||||
<ChipInput
|
||||
id='drain-snowflake-column-optional-defaults-to-data'
|
||||
value={state.column}
|
||||
onChange={(e) => setState({ ...state, column: e.target.value })}
|
||||
placeholder='DATA'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Role (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Role (optional)' htmlFor='drain-snowflake-role-optional'>
|
||||
<ChipInput
|
||||
id='drain-snowflake-role-optional'
|
||||
value={state.role}
|
||||
onChange={(e) => setState({ ...state, role: e.target.value })}
|
||||
placeholder='SIM_DRAIN_ROLE'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Private key (PEM, PKCS8)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Private key (PEM, PKCS8)' htmlFor='drain-snowflake-private-key-pem-pkcs8'>
|
||||
<ChipTextarea
|
||||
id='drain-snowflake-private-key-pem-pkcs8'
|
||||
value={state.privateKey}
|
||||
onChange={(e) => setState({ ...state, privateKey: e.target.value })}
|
||||
placeholder='-----BEGIN PRIVATE KEY-----'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
@@ -477,34 +528,41 @@ const webhookFormSpec: DestinationFormSpec<WebhookState> = {
|
||||
initialState: { url: '', signatureHeader: '', signingSecret: '', bearerToken: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='URL'>
|
||||
<SettingRow label='URL' htmlFor='drain-webhook-url'>
|
||||
<ChipInput
|
||||
id='drain-webhook-url'
|
||||
value={state.url}
|
||||
onChange={(e) => setState({ ...state, url: e.target.value })}
|
||||
placeholder='https://example.com/sim-drain'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Signature header (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Signature header (optional)'
|
||||
htmlFor='drain-webhook-signature-header-optional'
|
||||
>
|
||||
<ChipInput
|
||||
id='drain-webhook-signature-header-optional'
|
||||
value={state.signatureHeader}
|
||||
onChange={(e) => setState({ ...state, signatureHeader: e.target.value })}
|
||||
placeholder='X-Sim-Signature'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Signing secret'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Signing secret' htmlFor='drain-webhook-signing-secret'>
|
||||
<SecretInput
|
||||
id='drain-webhook-signing-secret'
|
||||
value={state.signingSecret}
|
||||
onChange={(v) => setState({ ...state, signingSecret: v })}
|
||||
placeholder='At least 32 characters'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Bearer token (optional)'>
|
||||
</SettingRow>
|
||||
<SettingRow label='Bearer token (optional)' htmlFor='drain-webhook-bearer-token-optional'>
|
||||
<SecretInput
|
||||
id='drain-webhook-bearer-token-optional'
|
||||
value={state.bearerToken}
|
||||
onChange={(v) => setState({ ...state, bearerToken: v })}
|
||||
placeholder='Paste your bearer token'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</SettingRow>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.url.length > 0 && s.signingSecret.length >= 32,
|
||||
|
||||
@@ -88,6 +88,12 @@ export function useCreateDataDrain() {
|
||||
logger.info('Created data drain', { drainId: drain.id, organizationId })
|
||||
return drain
|
||||
},
|
||||
// Seed the list so the caller can navigate straight to the new drain's detail
|
||||
// without that handoff depending on the invalidation refetch succeeding.
|
||||
onSuccess: (drain, variables) =>
|
||||
queryClient.setQueryData<DataDrain[]>(dataDrainKeys.list(variables.organizationId), (prev) =>
|
||||
prev?.some((d) => d.id === drain.id) ? prev : [...(prev ?? []), drain]
|
||||
),
|
||||
onSettled: (_drain, _error, variables) =>
|
||||
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
|
||||
|
||||
export const SOURCE_LABELS: Record<(typeof SOURCE_TYPES)[number], string> = {
|
||||
workflow_logs: 'Workflow logs',
|
||||
job_logs: 'Job logs',
|
||||
audit_logs: 'Audit logs',
|
||||
copilot_chats: 'Chats',
|
||||
copilot_runs: 'Chat runs',
|
||||
}
|
||||
|
||||
export const DESTINATION_LABELS: Record<(typeof DESTINATION_TYPES)[number], string> = {
|
||||
s3: 'Amazon S3',
|
||||
gcs: 'Google Cloud Storage',
|
||||
azure_blob: 'Azure Blob Storage',
|
||||
datadog: 'Datadog',
|
||||
bigquery: 'Google BigQuery',
|
||||
snowflake: 'Snowflake',
|
||||
webhook: 'HTTPS webhook',
|
||||
}
|
||||
|
||||
export const CADENCE_LABELS: Record<(typeof CADENCE_TYPES)[number], string> = {
|
||||
hourly: 'Every hour',
|
||||
daily: 'Every day',
|
||||
}
|
||||
|
||||
export const SOURCE_OPTIONS = SOURCE_TYPES.map((t) => ({ value: t, label: SOURCE_LABELS[t] }))
|
||||
export const CADENCE_OPTIONS = CADENCE_TYPES.map((t) => ({ value: t, label: CADENCE_LABELS[t] }))
|
||||
export const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: DESTINATION_LABELS[t],
|
||||
}))
|
||||
Reference in New Issue
Block a user