diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 10d4b15793f..bde90b3f029 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -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. diff --git a/apps/sim/ee/data-drains/components/data-drain-create.tsx b/apps/sim/ee/data-drains/components/data-drain-create.tsx new file mode 100644 index 00000000000..9db2befb798 --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drain-create.tsx @@ -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( + () => 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 ( + <> + guard.guardBack(onBack) }} + title='New drain' + actions={actions} + > +
+ } + title='New drain' + subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.' + /> + + +
+ + setName(e.target.value)} + placeholder='Workflow logs export' + /> + + + setSource(v as (typeof SOURCE_TYPES)[number])} + options={SOURCE_OPTIONS} + align='start' + /> + + + setCadence(v as (typeof CADENCE_TYPES)[number])} + options={CADENCE_OPTIONS} + align='start' + /> + +
+
+ + +
+ + handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])} + options={DESTINATION_OPTIONS} + displayLabel={DESTINATION_LABELS[destinationType]} + align='start' + /> + + + {submitError && ( +

+ {submitError} +

+ )} +
+
+
+
+ + + + ) +} diff --git a/apps/sim/ee/data-drains/components/data-drain-detail.tsx b/apps/sim/ee/data-drains/components/data-drain-detail.tsx new file mode 100644 index 00000000000..5ebbcdbe17b --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drain-detail.tsx @@ -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 ( +
+ {label} + + {children} + +
+ ) +} + +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 ( + <> + +
+ } + title={drain.name} + subtitle={`${SOURCE_LABELS[drain.source]} → ${DESTINATION_LABELS[drain.destinationType]} · ${CADENCE_LABELS[drain.scheduleCadence]}`} + /> + + +
+
+ +

+ Disabled drains keep their cursor and resume where they left off. +

+
+ +
+
+ + +
+ {SOURCE_LABELS[drain.source]} + {CADENCE_LABELS[drain.scheduleCadence]} + + + {drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'} + + + + + {drain.lastSuccessAt ? new Date(drain.lastSuccessAt).toLocaleString() : 'Never'} + + +
+
+ + +
+ {DESTINATION_LABELS[drain.destinationType]} + {Object.entries(drain.destinationConfig).map(([key, value]) => ( + + {formatConfigValue(value)} + + ))} +
+
+ + + {runsLoading ? ( + Loading runs... + ) : runs && runs.length > 0 ? ( +
+ {runs.map((run) => ( + + ))} +
+ ) : ( + No runs yet + )} +
+
+
+ + + + ) +} + +const RUN_STATUS_VARIANT = { + success: 'green', + failed: 'red', + running: 'blue', +} as const + +function RunRow({ run }: { run: DataDrainRun }) { + return ( +
+
+
+ + {run.status} + + {run.trigger} + + {new Date(run.startedAt).toLocaleString()} + +
+ {run.error &&
{run.error}
} +
+
+
{run.rowsExported.toLocaleString()} rows
+
+ {formatFileSize(run.bytesWritten, { includeBytes: true })} +
+
+
+ ) +} diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index d83b67a929e..659c758ffb3 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -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(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 - - return ( - <> - setCreateOpen(true), - }, - ]} - search={{ - value: searchTerm, - onChange: setSearchTerm, - placeholder: 'Search data drains...', + const actions: SettingsAction[] = [ + { + text: 'Create drain', + icon: Plus, + variant: 'primary', + onSelect: () => setIsCreating(true), + disabled: isPending, + }, + ] + + /** + * 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 + + if (isCreating) { + return ( + setIsCreating(false)} + onCreated={(drainId) => { + setIsCreating(false) + void setSelectedDrainId(drainId) }} - > -
-
- {drainsError ? ( -
-

- Failed to load data drains: {toError(drainsError).message} -

-
- ) : drains && drains.length > 0 ? ( - filteredDrains.length > 0 ? ( - - - - Name - Source - Destination - Cadence - Last run - Enabled - - - - - {filteredDrains.map((drain) => ( - - setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id) - } - /> - ))} - -
- ) : ( - - No results for "{searchTerm.trim()}" - - ) - ) : ( - Click "New drain" above to get started - )} -
-
-
- - {createOpen && ( - 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 ( - <> - - -
- - {drain.name} -
-
- - {SOURCE_LABELS[drain.source]} - - - {DESTINATION_LABELS[drain.destinationType]} - - {CADENCE_LABELS[drain.scheduleCadence]} - - {drain.lastRunAt ? new Date(drain.lastRunAt).toLocaleString() : 'Never'} - - e.stopPropagation()}> - - - e.stopPropagation()}> - - -
- {expanded && ( - - - - - - )} - - - ) -} - -interface DrainRunsPanelProps { - organizationId: string - drainId: string -} - -function DrainRunsPanel({ organizationId, drainId }: DrainRunsPanelProps) { - const { data: runs, isLoading } = useDataDrainRuns(organizationId, drainId, 10) - - if (isLoading) { - return
Loading runs...
- } - if (!runs || runs.length === 0) { - return
No runs yet.
+ ) } return ( -
-
Recent runs
- {runs.map((run) => ( - - ))} -
- ) -} - -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 ( -
-
-
- {run.status} - {run.trigger} - - {new Date(run.startedAt).toLocaleString()} - + + {error ? ( +
+

+ {getErrorMessage(error, "Couldn't load data drains")} +

- {run.error &&
{run.error}
} -
-
-
{run.rowsExported.toLocaleString()} rows
-
{(run.bytesWritten / 1024).toFixed(1)} KB
-
-
- ) -} - -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( - () => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState - ) - const [submitError, setSubmitError] = useState(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 ( - !open && onClose()} srTitle='New data drain' size='md'> - onClose()}>New data drain - - - - setSource(v as (typeof SOURCE_TYPES)[number])} - options={SOURCE_OPTIONS} - align='start' - /> - - - setCadence(v as (typeof CADENCE_TYPES)[number])} - options={CADENCE_OPTIONS} - align='start' - /> - - - handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])} - options={DESTINATION_OPTIONS} - displayLabel={DESTINATION_LABELS[destinationType]} - align='start' - /> - - -
- -
- {submitError} -
- -
+ ) : isPending ? null : drains && drains.length > 0 ? ( +
+ {filteredDrains.map((drain) => ( + + ))} + {filteredDrains.length === 0 && ( + + No drains found matching "{searchTerm}" + + )} +
+ ) : ( + Click "Create drain" above to get started + )} + ) } diff --git a/apps/sim/ee/data-drains/destinations/registry.tsx b/apps/sim/ee/data-drains/destinations/registry.tsx index b2d70c06f35..fb1371a1552 100644 --- a/apps/sim/ee/data-drains/destinations/registry.tsx +++ b/apps/sim/ee/data-drains/destinations/registry.tsx @@ -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 = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, bucket: e.target.value })} placeholder='my-logs-bucket' /> - - + + setState({ ...state, region: e.target.value })} placeholder='us-east-1' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, endpoint: e.target.value })} placeholder='https://s3.example.com' /> - - + + setState({ ...state, forcePathStyle: v })} /> - - + + setState({ ...state, accessKeyId: v })} placeholder='AKIA...' /> - - + + setState({ ...state, secretAccessKey: v })} placeholder='Paste your secret access key' /> - + ), isComplete: (s) => @@ -126,28 +140,31 @@ const gcsFormSpec: DestinationFormSpec = { initialState: { bucket: '', prefix: '', serviceAccountJson: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, bucket: e.target.value })} placeholder='my-logs-bucket' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, serviceAccountJson: e.target.value })} placeholder='{ "type": "service_account", ... }' rows={6} /> - + ), isComplete: (s) => s.bucket.length >= 3 && s.serviceAccountJson.length > 0, @@ -177,41 +194,49 @@ const azureBlobFormSpec: DestinationFormSpec = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, accountName: e.target.value })} placeholder='mystorageaccount' /> - - + + setState({ ...state, containerName: e.target.value })} placeholder='sim-exports' /> - - + + setState({ ...state, prefix: e.target.value })} placeholder='exports/sim' /> - - + + setState({ ...state, endpointSuffix: e.target.value })} placeholder='blob.core.windows.net' /> - - + + setState({ ...state, accountKey: v })} placeholder='Paste your storage account key' /> - + ), isComplete: (s) => @@ -250,35 +275,42 @@ const datadogFormSpec: DestinationFormSpec = { initialState: { site: 'us1', service: '', tags: '', apiKey: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, site: v as DatadogState['site'] })} options={DATADOG_SITE_OPTIONS} align='start' /> - - + + setState({ ...state, service: e.target.value })} placeholder='sim' /> - - + + setState({ ...state, tags: e.target.value })} placeholder='env:prod,team:platform' /> - - + + setState({ ...state, apiKey: v })} placeholder='Paste your Datadog API key' /> - + ), isComplete: (s) => s.apiKey.length > 0, @@ -305,35 +337,42 @@ const bigqueryFormSpec: DestinationFormSpec = { initialState: { projectId: '', datasetId: '', tableId: '', serviceAccountJson: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, projectId: e.target.value })} placeholder='my-gcp-project' /> - - + + setState({ ...state, datasetId: e.target.value })} placeholder='sim_drains' /> - - + + setState({ ...state, tableId: e.target.value })} placeholder='workflow_logs' /> - - + + setState({ ...state, serviceAccountJson: e.target.value })} placeholder='{ "type": "service_account", ... }' rows={6} /> - + ), isComplete: (s) => @@ -375,70 +414,82 @@ const snowflakeFormSpec: DestinationFormSpec = { }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, account: e.target.value })} placeholder='orgname-accountname' /> - - + + setState({ ...state, user: e.target.value })} placeholder='SIM_DRAIN_USER' /> - - + + setState({ ...state, warehouse: e.target.value })} placeholder='COMPUTE_WH' /> - - + + setState({ ...state, database: e.target.value })} placeholder='SIM' /> - - + + setState({ ...state, schema: e.target.value })} placeholder='PUBLIC' /> - - + + setState({ ...state, table: e.target.value })} placeholder='WORKFLOW_LOGS' /> - - + + setState({ ...state, column: e.target.value })} placeholder='DATA' /> - - + + setState({ ...state, role: e.target.value })} placeholder='SIM_DRAIN_ROLE' /> - - + + setState({ ...state, privateKey: e.target.value })} placeholder='-----BEGIN PRIVATE KEY-----' rows={6} /> - + ), isComplete: (s) => @@ -477,34 +528,41 @@ const webhookFormSpec: DestinationFormSpec = { initialState: { url: '', signatureHeader: '', signingSecret: '', bearerToken: '' }, FormFields: ({ state, setState }) => ( <> - + setState({ ...state, url: e.target.value })} placeholder='https://example.com/sim-drain' /> - - + + setState({ ...state, signatureHeader: e.target.value })} placeholder='X-Sim-Signature' /> - - + + setState({ ...state, signingSecret: v })} placeholder='At least 32 characters' /> - - + + setState({ ...state, bearerToken: v })} placeholder='Paste your bearer token' /> - + ), isComplete: (s) => s.url.length > 0 && s.signingSecret.length >= 32, diff --git a/apps/sim/ee/data-drains/hooks/data-drains.ts b/apps/sim/ee/data-drains/hooks/data-drains.ts index 5526a57aaac..8a12af69ca0 100644 --- a/apps/sim/ee/data-drains/hooks/data-drains.ts +++ b/apps/sim/ee/data-drains/hooks/data-drains.ts @@ -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(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) }), }) diff --git a/apps/sim/ee/data-drains/labels.ts b/apps/sim/ee/data-drains/labels.ts new file mode 100644 index 00000000000..0ab0c7d4c87 --- /dev/null +++ b/apps/sim/ee/data-drains/labels.ts @@ -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], +}))