chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import type { CreateDataDrainBody, DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
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'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
|
||||
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],
|
||||
}))
|
||||
|
||||
export function DataDrainsSettings() {
|
||||
const { data: session, isPending: sessionPending } = useSession()
|
||||
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const orgId = activeOrganization?.id
|
||||
|
||||
const userEmail = session?.user?.email
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const canManage = isOrgAdminRole(userRole)
|
||||
|
||||
const { data: drains, isLoading: drainsLoading, error: drainsError } = useDataDrains(orgId)
|
||||
|
||||
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 null
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
Data drains are configured per organization. Join or create one to continue.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
Only organization owners and admins can configure data drains.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
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={orgId}
|
||||
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>
|
||||
|
||||
{createOpen && (
|
||||
<CreateDrainModal organizationId={orgId} 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...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { ChipInput, ChipModalField, ChipSelect, ChipTextarea, SecretInput, Switch } from '@sim/emcn'
|
||||
import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains'
|
||||
import type { DestinationType } from '@/lib/data-drains/types'
|
||||
|
||||
type DestinationBranch = Pick<
|
||||
CreateDataDrainBody,
|
||||
'destinationType' | 'destinationConfig' | 'destinationCredentials'
|
||||
>
|
||||
|
||||
interface DestinationFormSpec<TState> {
|
||||
readonly displayName: string
|
||||
readonly initialState: TState
|
||||
readonly FormFields: ComponentType<{
|
||||
state: TState
|
||||
setState: (state: TState) => void
|
||||
}>
|
||||
readonly isComplete: (state: TState) => boolean
|
||||
readonly toDestinationBranch: (state: TState) => DestinationBranch
|
||||
}
|
||||
|
||||
interface S3State {
|
||||
bucket: string
|
||||
region: string
|
||||
prefix: string
|
||||
endpoint: string
|
||||
forcePathStyle: boolean
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
const s3FormSpec: DestinationFormSpec<S3State> = {
|
||||
displayName: 'Amazon S3',
|
||||
initialState: {
|
||||
bucket: '',
|
||||
region: 'us-east-1',
|
||||
prefix: '',
|
||||
endpoint: '',
|
||||
forcePathStyle: false,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Bucket'>
|
||||
<ChipInput
|
||||
value={state.bucket}
|
||||
onChange={(e) => setState({ ...state, bucket: e.target.value })}
|
||||
placeholder='my-logs-bucket'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Region'>
|
||||
<ChipInput
|
||||
value={state.region}
|
||||
onChange={(e) => setState({ ...state, region: e.target.value })}
|
||||
placeholder='us-east-1'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
<ChipInput
|
||||
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)'>
|
||||
<ChipInput
|
||||
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)'>
|
||||
<Switch
|
||||
checked={state.forcePathStyle}
|
||||
onCheckedChange={(v) => setState({ ...state, forcePathStyle: v })}
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Access key ID'>
|
||||
<SecretInput
|
||||
value={state.accessKeyId}
|
||||
onChange={(v) => setState({ ...state, accessKeyId: v })}
|
||||
placeholder='AKIA...'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Secret access key'>
|
||||
<SecretInput
|
||||
value={state.secretAccessKey}
|
||||
onChange={(v) => setState({ ...state, secretAccessKey: v })}
|
||||
placeholder='Paste your secret access key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
s.bucket.length > 0 &&
|
||||
s.region.length > 0 &&
|
||||
s.accessKeyId.length > 0 &&
|
||||
s.secretAccessKey.length > 0,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 's3',
|
||||
destinationConfig: {
|
||||
bucket: s.bucket,
|
||||
region: s.region,
|
||||
prefix: s.prefix || undefined,
|
||||
endpoint: s.endpoint || undefined,
|
||||
forcePathStyle: s.forcePathStyle,
|
||||
},
|
||||
destinationCredentials: {
|
||||
accessKeyId: s.accessKeyId,
|
||||
secretAccessKey: s.secretAccessKey,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
interface GCSState {
|
||||
bucket: string
|
||||
prefix: string
|
||||
serviceAccountJson: string
|
||||
}
|
||||
|
||||
const gcsFormSpec: DestinationFormSpec<GCSState> = {
|
||||
displayName: 'Google Cloud Storage',
|
||||
initialState: { bucket: '', prefix: '', serviceAccountJson: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Bucket'>
|
||||
<ChipInput
|
||||
value={state.bucket}
|
||||
onChange={(e) => setState({ ...state, bucket: e.target.value })}
|
||||
placeholder='my-logs-bucket'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
<ChipInput
|
||||
value={state.prefix}
|
||||
onChange={(e) => setState({ ...state, prefix: e.target.value })}
|
||||
placeholder='exports/sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Service account JSON key'>
|
||||
<ChipTextarea
|
||||
value={state.serviceAccountJson}
|
||||
onChange={(e) => setState({ ...state, serviceAccountJson: e.target.value })}
|
||||
placeholder='{ "type": "service_account", ... }'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.bucket.length >= 3 && s.serviceAccountJson.length > 0,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'gcs',
|
||||
destinationConfig: { bucket: s.bucket, prefix: s.prefix || undefined },
|
||||
destinationCredentials: { serviceAccountJson: s.serviceAccountJson },
|
||||
}),
|
||||
}
|
||||
|
||||
interface AzureBlobState {
|
||||
accountName: string
|
||||
containerName: string
|
||||
prefix: string
|
||||
endpointSuffix: string
|
||||
accountKey: string
|
||||
}
|
||||
|
||||
const azureBlobFormSpec: DestinationFormSpec<AzureBlobState> = {
|
||||
displayName: 'Azure Blob Storage',
|
||||
initialState: {
|
||||
accountName: '',
|
||||
containerName: '',
|
||||
prefix: '',
|
||||
endpointSuffix: '',
|
||||
accountKey: '',
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Account name'>
|
||||
<ChipInput
|
||||
value={state.accountName}
|
||||
onChange={(e) => setState({ ...state, accountName: e.target.value })}
|
||||
placeholder='mystorageaccount'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Container'>
|
||||
<ChipInput
|
||||
value={state.containerName}
|
||||
onChange={(e) => setState({ ...state, containerName: e.target.value })}
|
||||
placeholder='sim-exports'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Prefix (optional)'>
|
||||
<ChipInput
|
||||
value={state.prefix}
|
||||
onChange={(e) => setState({ ...state, prefix: e.target.value })}
|
||||
placeholder='exports/sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Endpoint suffix (optional)'>
|
||||
<ChipInput
|
||||
value={state.endpointSuffix}
|
||||
onChange={(e) => setState({ ...state, endpointSuffix: e.target.value })}
|
||||
placeholder='blob.core.windows.net'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Account key'>
|
||||
<SecretInput
|
||||
value={state.accountKey}
|
||||
onChange={(v) => setState({ ...state, accountKey: v })}
|
||||
placeholder='Paste your storage account key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
s.accountName.length >= 3 && s.containerName.length >= 3 && s.accountKey.length === 88,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'azure_blob',
|
||||
destinationConfig: {
|
||||
accountName: s.accountName,
|
||||
containerName: s.containerName,
|
||||
prefix: s.prefix || undefined,
|
||||
endpointSuffix: s.endpointSuffix || undefined,
|
||||
},
|
||||
destinationCredentials: { accountKey: s.accountKey },
|
||||
}),
|
||||
}
|
||||
|
||||
const DATADOG_SITE_OPTIONS = [
|
||||
{ value: 'us1', label: 'US1 (datadoghq.com)' },
|
||||
{ value: 'us3', label: 'US3 (us3.datadoghq.com)' },
|
||||
{ value: 'us5', label: 'US5 (us5.datadoghq.com)' },
|
||||
{ value: 'eu1', label: 'EU1 (datadoghq.eu)' },
|
||||
{ value: 'ap1', label: 'AP1 (ap1.datadoghq.com)' },
|
||||
{ value: 'ap2', label: 'AP2 (ap2.datadoghq.com)' },
|
||||
{ value: 'gov', label: 'Gov (ddog-gov.com)' },
|
||||
]
|
||||
|
||||
interface DatadogState {
|
||||
site: 'us1' | 'us3' | 'us5' | 'eu1' | 'ap1' | 'ap2' | 'gov'
|
||||
service: string
|
||||
tags: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const datadogFormSpec: DestinationFormSpec<DatadogState> = {
|
||||
displayName: 'Datadog',
|
||||
initialState: { site: 'us1', service: '', tags: '', apiKey: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Site'>
|
||||
<ChipSelect
|
||||
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)'>
|
||||
<ChipInput
|
||||
value={state.service}
|
||||
onChange={(e) => setState({ ...state, service: e.target.value })}
|
||||
placeholder='sim'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Tags (optional, comma-separated)'>
|
||||
<ChipInput
|
||||
value={state.tags}
|
||||
onChange={(e) => setState({ ...state, tags: e.target.value })}
|
||||
placeholder='env:prod,team:platform'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='API key'>
|
||||
<SecretInput
|
||||
value={state.apiKey}
|
||||
onChange={(v) => setState({ ...state, apiKey: v })}
|
||||
placeholder='Paste your Datadog API key'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.apiKey.length > 0,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'datadog',
|
||||
destinationConfig: {
|
||||
site: s.site,
|
||||
service: s.service || undefined,
|
||||
tags: s.tags || undefined,
|
||||
},
|
||||
destinationCredentials: { apiKey: s.apiKey },
|
||||
}),
|
||||
}
|
||||
|
||||
interface BigQueryState {
|
||||
projectId: string
|
||||
datasetId: string
|
||||
tableId: string
|
||||
serviceAccountJson: string
|
||||
}
|
||||
|
||||
const bigqueryFormSpec: DestinationFormSpec<BigQueryState> = {
|
||||
displayName: 'Google BigQuery',
|
||||
initialState: { projectId: '', datasetId: '', tableId: '', serviceAccountJson: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Project ID'>
|
||||
<ChipInput
|
||||
value={state.projectId}
|
||||
onChange={(e) => setState({ ...state, projectId: e.target.value })}
|
||||
placeholder='my-gcp-project'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Dataset'>
|
||||
<ChipInput
|
||||
value={state.datasetId}
|
||||
onChange={(e) => setState({ ...state, datasetId: e.target.value })}
|
||||
placeholder='sim_drains'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Table'>
|
||||
<ChipInput
|
||||
value={state.tableId}
|
||||
onChange={(e) => setState({ ...state, tableId: e.target.value })}
|
||||
placeholder='workflow_logs'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Service account JSON key'>
|
||||
<ChipTextarea
|
||||
value={state.serviceAccountJson}
|
||||
onChange={(e) => setState({ ...state, serviceAccountJson: e.target.value })}
|
||||
placeholder='{ "type": "service_account", ... }'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
s.projectId.length >= 6 &&
|
||||
s.datasetId.length > 0 &&
|
||||
s.tableId.length > 0 &&
|
||||
s.serviceAccountJson.length > 0,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'bigquery',
|
||||
destinationConfig: { projectId: s.projectId, datasetId: s.datasetId, tableId: s.tableId },
|
||||
destinationCredentials: { serviceAccountJson: s.serviceAccountJson },
|
||||
}),
|
||||
}
|
||||
|
||||
interface SnowflakeState {
|
||||
account: string
|
||||
user: string
|
||||
warehouse: string
|
||||
database: string
|
||||
schema: string
|
||||
table: string
|
||||
column: string
|
||||
role: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
const snowflakeFormSpec: DestinationFormSpec<SnowflakeState> = {
|
||||
displayName: 'Snowflake',
|
||||
initialState: {
|
||||
account: '',
|
||||
user: '',
|
||||
warehouse: '',
|
||||
database: '',
|
||||
schema: '',
|
||||
table: '',
|
||||
column: '',
|
||||
role: '',
|
||||
privateKey: '',
|
||||
},
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='Account identifier'>
|
||||
<ChipInput
|
||||
value={state.account}
|
||||
onChange={(e) => setState({ ...state, account: e.target.value })}
|
||||
placeholder='orgname-accountname'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='User'>
|
||||
<ChipInput
|
||||
value={state.user}
|
||||
onChange={(e) => setState({ ...state, user: e.target.value })}
|
||||
placeholder='SIM_DRAIN_USER'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Warehouse'>
|
||||
<ChipInput
|
||||
value={state.warehouse}
|
||||
onChange={(e) => setState({ ...state, warehouse: e.target.value })}
|
||||
placeholder='COMPUTE_WH'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Database'>
|
||||
<ChipInput
|
||||
value={state.database}
|
||||
onChange={(e) => setState({ ...state, database: e.target.value })}
|
||||
placeholder='SIM'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Schema'>
|
||||
<ChipInput
|
||||
value={state.schema}
|
||||
onChange={(e) => setState({ ...state, schema: e.target.value })}
|
||||
placeholder='PUBLIC'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Table'>
|
||||
<ChipInput
|
||||
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")'>
|
||||
<ChipInput
|
||||
value={state.column}
|
||||
onChange={(e) => setState({ ...state, column: e.target.value })}
|
||||
placeholder='DATA'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Role (optional)'>
|
||||
<ChipInput
|
||||
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)'>
|
||||
<ChipTextarea
|
||||
value={state.privateKey}
|
||||
onChange={(e) => setState({ ...state, privateKey: e.target.value })}
|
||||
placeholder='-----BEGIN PRIVATE KEY-----'
|
||||
rows={6}
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) =>
|
||||
s.account.length >= 3 &&
|
||||
s.user.length > 0 &&
|
||||
s.warehouse.length > 0 &&
|
||||
s.database.length > 0 &&
|
||||
s.schema.length > 0 &&
|
||||
s.table.length > 0 &&
|
||||
s.privateKey.length > 0,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'snowflake',
|
||||
destinationConfig: {
|
||||
account: s.account,
|
||||
user: s.user,
|
||||
warehouse: s.warehouse,
|
||||
database: s.database,
|
||||
schema: s.schema,
|
||||
table: s.table,
|
||||
column: s.column || undefined,
|
||||
role: s.role || undefined,
|
||||
},
|
||||
destinationCredentials: { privateKey: s.privateKey },
|
||||
}),
|
||||
}
|
||||
|
||||
interface WebhookState {
|
||||
url: string
|
||||
signatureHeader: string
|
||||
signingSecret: string
|
||||
bearerToken: string
|
||||
}
|
||||
|
||||
const webhookFormSpec: DestinationFormSpec<WebhookState> = {
|
||||
displayName: 'HTTPS webhook',
|
||||
initialState: { url: '', signatureHeader: '', signingSecret: '', bearerToken: '' },
|
||||
FormFields: ({ state, setState }) => (
|
||||
<>
|
||||
<ChipModalField type='custom' flush title='URL'>
|
||||
<ChipInput
|
||||
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)'>
|
||||
<ChipInput
|
||||
value={state.signatureHeader}
|
||||
onChange={(e) => setState({ ...state, signatureHeader: e.target.value })}
|
||||
placeholder='X-Sim-Signature'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Signing secret'>
|
||||
<SecretInput
|
||||
value={state.signingSecret}
|
||||
onChange={(v) => setState({ ...state, signingSecret: v })}
|
||||
placeholder='At least 32 characters'
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' flush title='Bearer token (optional)'>
|
||||
<SecretInput
|
||||
value={state.bearerToken}
|
||||
onChange={(v) => setState({ ...state, bearerToken: v })}
|
||||
placeholder='Paste your bearer token'
|
||||
/>
|
||||
</ChipModalField>
|
||||
</>
|
||||
),
|
||||
isComplete: (s) => s.url.length > 0 && s.signingSecret.length >= 32,
|
||||
toDestinationBranch: (s) => ({
|
||||
destinationType: 'webhook',
|
||||
destinationConfig: {
|
||||
url: s.url,
|
||||
signatureHeader: s.signatureHeader || undefined,
|
||||
},
|
||||
destinationCredentials: {
|
||||
signingSecret: s.signingSecret,
|
||||
bearerToken: s.bearerToken || undefined,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror of `DESTINATION_REGISTRY`. The settings page selects a
|
||||
* spec by `destinationType` and never branches on the literal — adding a new
|
||||
* destination is one entry here plus one in the server-side registry.
|
||||
*/
|
||||
export const DESTINATION_FORM_REGISTRY: Record<DestinationType, DestinationFormSpec<unknown>> = {
|
||||
s3: s3FormSpec as DestinationFormSpec<unknown>,
|
||||
gcs: gcsFormSpec as DestinationFormSpec<unknown>,
|
||||
azure_blob: azureBlobFormSpec as DestinationFormSpec<unknown>,
|
||||
datadog: datadogFormSpec as DestinationFormSpec<unknown>,
|
||||
bigquery: bigqueryFormSpec as DestinationFormSpec<unknown>,
|
||||
snowflake: snowflakeFormSpec as DestinationFormSpec<unknown>,
|
||||
webhook: webhookFormSpec as DestinationFormSpec<unknown>,
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type CreateDataDrainBody,
|
||||
createDataDrainContract,
|
||||
type DataDrain,
|
||||
type DataDrainRun,
|
||||
deleteDataDrainContract,
|
||||
listDataDrainRunsContract,
|
||||
listDataDrainsContract,
|
||||
runDataDrainContract,
|
||||
testDataDrainContract,
|
||||
type UpdateDataDrainBody,
|
||||
updateDataDrainContract,
|
||||
} from '@/lib/api/contracts/data-drains'
|
||||
|
||||
const logger = createLogger('DataDrainsQueries')
|
||||
|
||||
export const dataDrainKeys = {
|
||||
all: ['data-drains'] as const,
|
||||
lists: () => [...dataDrainKeys.all, 'list'] as const,
|
||||
list: (organizationId?: string) => [...dataDrainKeys.lists(), organizationId ?? ''] as const,
|
||||
runsAll: () => [...dataDrainKeys.all, 'runs'] as const,
|
||||
runs: (drainId?: string) => [...dataDrainKeys.runsAll(), drainId ?? ''] as const,
|
||||
runsList: (organizationId?: string, drainId?: string, limit?: number) =>
|
||||
[...dataDrainKeys.runs(drainId), organizationId ?? '', limit ?? 10] as const,
|
||||
}
|
||||
|
||||
async function fetchDataDrains(organizationId: string, signal?: AbortSignal): Promise<DataDrain[]> {
|
||||
const { drains } = await requestJson(listDataDrainsContract, {
|
||||
params: { id: organizationId },
|
||||
signal,
|
||||
})
|
||||
return drains
|
||||
}
|
||||
|
||||
async function fetchDataDrainRuns(
|
||||
organizationId: string,
|
||||
drainId: string,
|
||||
limit: number | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<DataDrainRun[]> {
|
||||
const { runs } = await requestJson(listDataDrainRunsContract, {
|
||||
params: { id: organizationId, drainId },
|
||||
query: limit ? { limit } : undefined,
|
||||
signal,
|
||||
})
|
||||
return runs
|
||||
}
|
||||
|
||||
export function useDataDrains(organizationId?: string) {
|
||||
return useQuery<DataDrain[]>({
|
||||
queryKey: dataDrainKeys.list(organizationId),
|
||||
queryFn: ({ signal }) => fetchDataDrains(organizationId as string, signal),
|
||||
enabled: Boolean(organizationId),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDataDrainRuns(organizationId?: string, drainId?: string, limit = 10) {
|
||||
return useQuery<DataDrainRun[]>({
|
||||
queryKey: dataDrainKeys.runsList(organizationId, drainId, limit),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchDataDrainRuns(organizationId as string, drainId as string, limit, signal),
|
||||
enabled: Boolean(organizationId && drainId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateDataDrainParams {
|
||||
organizationId: string
|
||||
body: CreateDataDrainBody
|
||||
}
|
||||
|
||||
export function useCreateDataDrain() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, body }: CreateDataDrainParams) => {
|
||||
const { drain } = await requestJson(createDataDrainContract, {
|
||||
params: { id: organizationId },
|
||||
body,
|
||||
})
|
||||
logger.info('Created data drain', { drainId: drain.id, organizationId })
|
||||
return drain
|
||||
},
|
||||
onSettled: (_drain, _error, variables) =>
|
||||
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateDataDrainParams {
|
||||
organizationId: string
|
||||
drainId: string
|
||||
body: UpdateDataDrainBody
|
||||
}
|
||||
|
||||
export function useUpdateDataDrain() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, drainId, body }: UpdateDataDrainParams) => {
|
||||
const { drain } = await requestJson(updateDataDrainContract, {
|
||||
params: { id: organizationId, drainId },
|
||||
body,
|
||||
})
|
||||
logger.info('Updated data drain', { drainId, organizationId })
|
||||
return drain
|
||||
},
|
||||
onSettled: (_drain, _error, variables) =>
|
||||
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
|
||||
})
|
||||
}
|
||||
|
||||
interface DeleteDataDrainParams {
|
||||
organizationId: string
|
||||
drainId: string
|
||||
}
|
||||
|
||||
export function useDeleteDataDrain() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, drainId }: DeleteDataDrainParams) => {
|
||||
await requestJson(deleteDataDrainContract, {
|
||||
params: { id: organizationId, drainId },
|
||||
})
|
||||
logger.info('Deleted data drain', { drainId, organizationId })
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.removeQueries({ queryKey: dataDrainKeys.runs(variables.drainId) })
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: dataDrainKeys.list(variables.organizationId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface RunDataDrainParams {
|
||||
organizationId: string
|
||||
drainId: string
|
||||
}
|
||||
|
||||
export function useRunDataDrainNow() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, drainId }: RunDataDrainParams) => {
|
||||
const data = await requestJson(runDataDrainContract, {
|
||||
params: { id: organizationId, drainId },
|
||||
})
|
||||
logger.info('Enqueued data drain run', { drainId, jobId: data.jobId })
|
||||
return data
|
||||
},
|
||||
onSettled: (_data, _error, variables) =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: dataDrainKeys.runs(variables.drainId) }),
|
||||
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
interface TestDataDrainParams {
|
||||
organizationId: string
|
||||
drainId: string
|
||||
}
|
||||
|
||||
export function useTestDataDrain() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, drainId }: TestDataDrainParams) => {
|
||||
return await requestJson(testDataDrainContract, {
|
||||
params: { id: organizationId, drainId },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user