mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
681 lines
34 KiB
TypeScript
681 lines
34 KiB
TypeScript
import { useActions, useValues } from 'kea'
|
|
import { Form } from 'kea-forms'
|
|
|
|
import { IconSort } from '@hanzo/icons'
|
|
import { Button, Table, Tag, Link } from '@hanzo/elements'
|
|
|
|
import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
|
|
import { FlaggedFeature } from 'lib/components/FlaggedFeature'
|
|
import { TZLabel } from 'lib/components/TZLabel'
|
|
import { FEATURE_FLAGS } from 'lib/constants'
|
|
import { dayjs } from 'lib/dayjs'
|
|
import { CalendarSelectInput } from 'lib/elements/Calendar/CalendarSelect'
|
|
import { Checkbox } from 'lib/elements/Checkbox'
|
|
import { Field } from 'lib/elements/Field'
|
|
import { Input } from 'lib/elements/Input'
|
|
import { Progress } from 'lib/elements/Progress'
|
|
import { SegmentedButton } from 'lib/elements/SegmentedButton'
|
|
import { Select } from 'lib/elements/Select'
|
|
import { ProfilePicture } from 'lib/elements/ProfilePicture'
|
|
import { sceneConfigurations } from 'scenes/scenes'
|
|
import { Scene, SceneExport } from 'scenes/sceneTypes'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import { SceneContent } from '~/layout/scenes/components/SceneContent'
|
|
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
|
|
|
|
import { TRIAL_RECORD_LIMIT_MAX, type ManagedMigrationForm, managedMigrationLogic } from './managedMigrationLogic'
|
|
import { TrialResultsModal } from './TrialResultsModal'
|
|
import { type ManagedMigration as ManagedMigrationData } from './types'
|
|
|
|
const STATUS_COLORS = {
|
|
running: 'primary',
|
|
completed: 'success',
|
|
paused: 'danger',
|
|
waiting_to_start: 'muted',
|
|
} as const
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
waiting_to_start: 'Waiting to start',
|
|
}
|
|
|
|
function StatusTag({ status }: { status: string }): JSX.Element {
|
|
const label = STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
|
|
return <Tag type={STATUS_COLORS[status as keyof typeof STATUS_COLORS] || 'default'}>{label}</Tag>
|
|
}
|
|
|
|
function AmplitudeImportOptions({
|
|
managedMigration,
|
|
setManagedMigrationValue,
|
|
}: {
|
|
managedMigration: ManagedMigrationForm
|
|
setManagedMigrationValue: (key: string, value: any) => void
|
|
}): JSX.Element {
|
|
return (
|
|
<FlaggedFeature flag={FEATURE_FLAGS.AMPLITUDE_BATCH_IMPORT_OPTIONS}>
|
|
<Field name="import_events">
|
|
<Checkbox
|
|
checked={managedMigration.import_events !== false}
|
|
onChange={(checked) => setManagedMigrationValue('import_events', checked)}
|
|
label="Import events from Amplitude"
|
|
/>
|
|
</Field>
|
|
|
|
<Field name="generate_identify_events">
|
|
<Checkbox
|
|
checked={managedMigration.generate_identify_events !== false}
|
|
onChange={(checked) => setManagedMigrationValue('generate_identify_events', checked)}
|
|
label="Generate identify events to link user IDs with device IDs"
|
|
/>
|
|
</Field>
|
|
|
|
<Field name="generate_group_identify_events">
|
|
<Checkbox
|
|
checked={managedMigration.generate_group_identify_events === true}
|
|
onChange={(checked) => setManagedMigrationValue('generate_group_identify_events', checked)}
|
|
label="Generate group identify events from group property changes"
|
|
/>
|
|
</Field>
|
|
</FlaggedFeature>
|
|
)
|
|
}
|
|
|
|
function IamRoleSetupInstructions({
|
|
managedMigration,
|
|
}: {
|
|
managedMigration: ManagedMigrationForm
|
|
}): JSX.Element | null {
|
|
const { awsIamSetup } = useValues(managedMigrationLogic)
|
|
|
|
if (!awsIamSetup?.available) {
|
|
return null
|
|
}
|
|
|
|
const bucket = managedMigration.s3_bucket
|
|
const prefix = managedMigration.s3_prefix
|
|
const permissionPolicy = awsIamSetup.permission_policy_template
|
|
.replaceAll('YOUR_BUCKET', bucket || '<YOUR_BUCKET>')
|
|
.replaceAll('YOUR_PREFIX', prefix || '')
|
|
|
|
return (
|
|
<div className="border rounded p-4 space-y-3 bg-surface-secondary">
|
|
<div className="font-semibold">Set up an IAM role for Insights</div>
|
|
<ol className="list-decimal list-inside space-y-3 text-sm">
|
|
<li>
|
|
In the AWS console, create an IAM role with the following trust policy (it lets Insights's import
|
|
role read from your bucket, and no one else):
|
|
<CodeSnippet language={Language.JSON} compact>
|
|
{awsIamSetup.trust_policy}
|
|
</CodeSnippet>
|
|
<div className="text-muted mt-1">
|
|
The external ID <code>{awsIamSetup.external_id}</code> is unique to this project and must appear
|
|
in the trust policy exactly as shown.
|
|
</div>
|
|
</li>
|
|
<li>
|
|
Attach this permission policy to the role. It fills in automatically from the bucket and prefix
|
|
entered above:
|
|
<CodeSnippet language={Language.JSON} compact>
|
|
{permissionPolicy}
|
|
</CodeSnippet>
|
|
<div className="text-muted mt-1">
|
|
{!bucket
|
|
? 'Enter your bucket above to replace <YOUR_BUCKET> in the policy.'
|
|
: prefix
|
|
? `This grants read access to objects under ${prefix} only - nothing else in the bucket.`
|
|
: 'No prefix is set, so this grants read access to the whole bucket. Set a prefix above to narrow what Insights can read.'}
|
|
</div>
|
|
</li>
|
|
<li>
|
|
Paste the new role's ARN:
|
|
<Field name="role_arn" className="mt-1">
|
|
<Input placeholder="arn:aws:iam::123456789012:role/insights-import" />
|
|
</Field>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ManagedMigration(): JSX.Element {
|
|
const { managedMigration, isManagedMigrationSubmitting, awsIamSetup } = useValues(managedMigrationLogic)
|
|
const { setManagedMigrationValue } = useActions(managedMigrationLogic)
|
|
|
|
const isS3Source = managedMigration.source_type === 's3' || managedMigration.source_type === 's3_gzip'
|
|
const usesIamRole = isS3Source && managedMigration.s3_auth_method === 'iam_role' && !!awsIamSetup?.available
|
|
|
|
return (
|
|
<Form logic={managedMigrationLogic} formKey="managedMigration" enableFormOnSubmit className="space-y-4">
|
|
<SceneContent>
|
|
<SceneTitleSection
|
|
name="New managed migration"
|
|
resourceType={{ type: 'managed_migration', forceIcon: <IconSort /> }}
|
|
actions={
|
|
<Button type="primary" htmlType="submit" size="small">
|
|
{managedMigration.is_trial ? 'Start trial run' : 'Import Data'}
|
|
</Button>
|
|
}
|
|
forceBackTo={{
|
|
path: urls.managedMigration(),
|
|
key: 'managed_migrations',
|
|
name: 'Managed migrations',
|
|
}}
|
|
/>
|
|
<Field name="source_type" label="Source">
|
|
<Select
|
|
value={managedMigration.source_type}
|
|
onChange={(value) => {
|
|
setManagedMigrationValue('source_type', value)
|
|
if (value === 'mixpanel' || value === 'amplitude') {
|
|
setManagedMigrationValue('content_type', value)
|
|
}
|
|
}}
|
|
options={[
|
|
{
|
|
value: 's3',
|
|
label: 'S3',
|
|
icon: (
|
|
<img
|
|
src="https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico"
|
|
className="w-4 h-4"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
value: 's3_gzip',
|
|
label: 'S3 (gzipped JSONL)',
|
|
icon: (
|
|
<img
|
|
src="https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico"
|
|
className="w-4 h-4"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
value: 'mixpanel',
|
|
label: 'Mixpanel',
|
|
icon: <img src="https://mixpanel.com/favicon.ico" className="w-4 h-4" />,
|
|
},
|
|
{
|
|
value: 'amplitude',
|
|
label: 'Amplitude',
|
|
icon: <img src="https://amplitude.com/favicon.ico" className="w-4 h-4" />,
|
|
},
|
|
]}
|
|
/>
|
|
</Field>
|
|
|
|
{(managedMigration.source_type === 's3' || managedMigration.source_type === 's3_gzip') && (
|
|
<>
|
|
<Field name="content_type" label="Content Type">
|
|
<Select
|
|
value={managedMigration.content_type}
|
|
onChange={(value) => setManagedMigrationValue('content_type', value)}
|
|
options={[
|
|
{ value: 'captured', label: 'Insights Events' },
|
|
{ value: 'mixpanel', label: 'Mixpanel Events' },
|
|
{ value: 'amplitude', label: 'Amplitude Events' },
|
|
]}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
{(managedMigration.source_type === 's3' || managedMigration.source_type === 's3_gzip') && (
|
|
<>
|
|
<div className="flex gap-4">
|
|
<Field name="s3_region" label="S3 Region" className="flex-1">
|
|
<Input placeholder="us-east-1" />
|
|
</Field>
|
|
|
|
<Field name="s3_bucket" label="S3 Bucket" className="flex-1">
|
|
<Input placeholder="my-bucket" />
|
|
</Field>
|
|
</div>
|
|
|
|
<Field
|
|
name="s3_prefix"
|
|
label="S3 Prefix (optional)"
|
|
help={
|
|
<>
|
|
Matched as a plain string prefix: <code>exports</code> also matches keys under{' '}
|
|
<code>exports-old/</code>. End with <code>/</code> to match a single folder.
|
|
{usesIamRole && (
|
|
<>
|
|
{' '}
|
|
Must exactly match the prefix in your IAM role's permission policy,
|
|
including any trailing slash.
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<Input placeholder="path/to/files/" />
|
|
</Field>
|
|
|
|
{awsIamSetup?.available && (
|
|
<Field name="s3_auth_method" label="Authentication">
|
|
<SegmentedButton
|
|
value={managedMigration.s3_auth_method}
|
|
onChange={(value) => setManagedMigrationValue('s3_auth_method', value)}
|
|
options={[
|
|
{ value: 'iam_role', label: 'IAM role (recommended)' },
|
|
{ value: 'access_keys', label: 'Access keys' },
|
|
]}
|
|
size="small"
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{usesIamRole ? (
|
|
<IamRoleSetupInstructions managedMigration={managedMigration} />
|
|
) : (
|
|
<Field
|
|
name="endpoint_url"
|
|
label="Endpoint URL"
|
|
showOptional
|
|
info={
|
|
<>
|
|
Only required for S3-compatible storage like Cloudflare R2 or MinIO. For R2, use
|
|
https://ACCOUNT_ID.r2.cloudflarestorage.com and set region to "auto".
|
|
</>
|
|
}
|
|
>
|
|
<Input placeholder="https://ACCOUNT_ID.r2.cloudflarestorage.com" />
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
{(managedMigration.source_type === 'mixpanel' || managedMigration.source_type === 'amplitude') && (
|
|
<>
|
|
<div className="flex gap-4">
|
|
<Field name="start_date" label="Start Date" className="flex-1">
|
|
<CalendarSelectInput
|
|
granularity={managedMigration.source_type === 'mixpanel' ? 'day' : 'hour'}
|
|
value={managedMigration.start_date ? dayjs(managedMigration.start_date) : null}
|
|
onChange={(date) =>
|
|
setManagedMigrationValue('start_date', date?.format('YYYY-MM-DD HH:mm:ss'))
|
|
}
|
|
/>
|
|
</Field>
|
|
|
|
<Field name="end_date" label="End Date" className="flex-1">
|
|
<CalendarSelectInput
|
|
granularity={managedMigration.source_type === 'mixpanel' ? 'day' : 'hour'}
|
|
value={managedMigration.end_date ? dayjs(managedMigration.end_date) : null}
|
|
onChange={(date) =>
|
|
setManagedMigrationValue('end_date', date?.format('YYYY-MM-DD HH:mm:ss'))
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<Field name="is_eu_region">
|
|
<Checkbox
|
|
checked={managedMigration.is_eu_region || false}
|
|
onChange={(checked) => setManagedMigrationValue('is_eu_region', checked)}
|
|
label="Use EU region endpoint"
|
|
/>
|
|
</Field>
|
|
|
|
{managedMigration.source_type === 'amplitude' && (
|
|
<AmplitudeImportOptions
|
|
managedMigration={managedMigration}
|
|
setManagedMigrationValue={setManagedMigrationValue}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(managedMigration.source_type === 's3' || managedMigration.source_type === 's3_gzip') &&
|
|
managedMigration.content_type === 'amplitude' && (
|
|
<AmplitudeImportOptions
|
|
managedMigration={managedMigration}
|
|
setManagedMigrationValue={setManagedMigrationValue}
|
|
/>
|
|
)}
|
|
|
|
{usesIamRole ? null : managedMigration.source_type === 'mixpanel' ? (
|
|
<Field
|
|
name="secret_key"
|
|
label="Project secret"
|
|
help={
|
|
<span>
|
|
Find this under Project settings → Access keys in Mixpanel. See{' '}
|
|
<Link
|
|
to="https://docs.mixpanel.com/docs/orgs-and-projects/managing-projects#access-keys"
|
|
target="_blank"
|
|
>
|
|
Mixpanel's docs
|
|
</Link>
|
|
.
|
|
</span>
|
|
}
|
|
>
|
|
<Input type="password" />
|
|
</Field>
|
|
) : (
|
|
<div className="flex gap-4">
|
|
<Field name="access_key" label="Access Key ID" className="flex-1">
|
|
<Input type="password" />
|
|
</Field>
|
|
|
|
<Field name="secret_key" label="Secret Access Key" className="flex-1">
|
|
<Input type="password" />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
<FlaggedFeature flag={FEATURE_FLAGS.MANAGED_MIGRATIONS_TRIAL_RUNS}>
|
|
<Field name="is_trial">
|
|
<Checkbox
|
|
checked={managedMigration.is_trial === true}
|
|
onChange={(checked) => setManagedMigrationValue('is_trial', checked)}
|
|
label="Run as a trial first"
|
|
/>
|
|
</Field>
|
|
{managedMigration.is_trial && (
|
|
<>
|
|
<div className="text-muted text-sm -mt-2">
|
|
A trial parses and transforms a sample of your data and shows the exact events a real
|
|
import would create, without ingesting anything. Trials still call the source API, so
|
|
they consume its export quota.
|
|
</div>
|
|
<Field name="trial_record_limit" label="Number of records to test">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={TRIAL_RECORD_LIMIT_MAX}
|
|
value={managedMigration.trial_record_limit}
|
|
onChange={(value) => setManagedMigrationValue('trial_record_limit', value)}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
</FlaggedFeature>
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="primary" htmlType="submit" loading={isManagedMigrationSubmitting}>
|
|
{managedMigration.is_trial ? 'Start trial run' : 'Import Data'}
|
|
</Button>
|
|
</div>
|
|
</SceneContent>
|
|
</Form>
|
|
)
|
|
}
|
|
|
|
export function ManagedMigrations(): JSX.Element {
|
|
const { managedMigrationId, migrations, migrationsLoading, promotingMigrationId } = useValues(managedMigrationLogic)
|
|
const { pauseMigration, resumeMigration, promoteTrial, viewTrialResults } = useActions(managedMigrationLogic)
|
|
|
|
const calculateProgress = (
|
|
migration: ManagedMigrationData
|
|
): { progress: number; completed: number; total: number } => {
|
|
if (migration.state?.parts && Array.isArray(migration.state.parts)) {
|
|
const parts = migration.state.parts
|
|
const totalParts = parts.length
|
|
const completedParts = parts.filter(
|
|
(part) => part.total_size !== null && part.total_size === part.current_offset
|
|
).length
|
|
return {
|
|
progress: totalParts > 0 ? (completedParts / totalParts) * 100 : 0,
|
|
completed: completedParts,
|
|
total: totalParts,
|
|
}
|
|
}
|
|
return { progress: 0, completed: 0, total: 0 }
|
|
}
|
|
|
|
return (
|
|
<SceneContent>
|
|
{managedMigrationId ? (
|
|
<ManagedMigration />
|
|
) : (
|
|
<>
|
|
<SceneTitleSection
|
|
name="Managed migrations"
|
|
description={sceneConfigurations[Scene.ManagedMigration].description}
|
|
resourceType={{
|
|
type: 'managed_migration',
|
|
forceIcon: <IconSort />,
|
|
}}
|
|
actions={
|
|
<Button
|
|
data-attr="new-managed-migration"
|
|
to={urls.managedMigrationNew()}
|
|
type="primary"
|
|
size="small"
|
|
>
|
|
New migration
|
|
</Button>
|
|
}
|
|
/>
|
|
<Table
|
|
dataSource={migrations}
|
|
loading={migrationsLoading}
|
|
defaultSorting={{
|
|
columnKey: 'created_at',
|
|
order: -1,
|
|
}}
|
|
columns={[
|
|
{
|
|
title: 'Source',
|
|
dataIndex: 'source_type',
|
|
render: (_: any, migration: ManagedMigrationData) => {
|
|
let sourceType: string = migration.source_type
|
|
if (migration.source_type === 'date_range_export') {
|
|
sourceType = migration.content_type
|
|
}
|
|
const sourceTypeMap = {
|
|
s3: {
|
|
icon: 'https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico',
|
|
label: 'AWS S3',
|
|
alt: 'S3',
|
|
},
|
|
s3_gzip: {
|
|
icon: 'https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico',
|
|
label: 'S3 (gzipped JSONL)',
|
|
alt: 'S3 Gzip',
|
|
},
|
|
mixpanel: {
|
|
icon: 'https://mixpanel.com/favicon.ico',
|
|
label: 'Mixpanel',
|
|
alt: 'Mixpanel',
|
|
},
|
|
amplitude: {
|
|
icon: 'https://amplitude.com/favicon.ico',
|
|
label: 'Amplitude',
|
|
alt: 'Amplitude',
|
|
},
|
|
}
|
|
|
|
const config = sourceTypeMap[sourceType as keyof typeof sourceTypeMap]
|
|
|
|
if (!config) {
|
|
return sourceType
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<img src={config.icon} alt={config.alt} className="w-4 h-4" />
|
|
{config.label}
|
|
{migration.is_trial && <Tag type="highlight">Trial</Tag>}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Content Type',
|
|
dataIndex: 'content_type',
|
|
render: (_: any, migration: ManagedMigrationData) => {
|
|
const contentTypeConfig = {
|
|
captured: {
|
|
icon: '/static/icons/favicon.ico?v=2023-07-07',
|
|
alt: 'Insights',
|
|
},
|
|
mixpanel: {
|
|
icon: 'https://mixpanel.com/favicon.ico',
|
|
alt: 'Mixpanel',
|
|
},
|
|
amplitude: {
|
|
icon: 'https://amplitude.com/favicon.ico',
|
|
alt: 'Amplitude',
|
|
},
|
|
}
|
|
|
|
const config =
|
|
contentTypeConfig[migration.content_type as keyof typeof contentTypeConfig]
|
|
|
|
if (!config) {
|
|
return migration.content_type
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<img src={config.icon} alt={config.alt} className="w-4 h-4" />
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'display_status',
|
|
render: (_: any, migration: ManagedMigrationData) => (
|
|
<StatusTag status={migration.display_status} />
|
|
),
|
|
},
|
|
{
|
|
title: 'Progress',
|
|
key: 'progress',
|
|
render: (_: any, migration: ManagedMigrationData) => {
|
|
const { progress, completed, total } = calculateProgress(migration)
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<Progress
|
|
percent={progress}
|
|
strokeColor={
|
|
migration.display_status === 'paused' ? 'var(--danger)' : undefined
|
|
}
|
|
/>
|
|
<span className="text-xs text-muted">
|
|
{migration.display_status === 'completed'
|
|
? 'Complete'
|
|
: migration.display_status === 'paused'
|
|
? 'Paused'
|
|
: migration.display_status === 'waiting_to_start'
|
|
? 'Waiting to start'
|
|
: `${completed}/${total}`}
|
|
</span>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Created by',
|
|
dataIndex: 'created_by',
|
|
render: function Render(_: any, migration: ManagedMigrationData) {
|
|
return (
|
|
<div className="flex flex-row items-center flex-nowrap">
|
|
{migration.created_by && (
|
|
<ProfilePicture user={migration.created_by} size="md" showName />
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Created',
|
|
dataIndex: 'created_at',
|
|
render: function Render(dataValue: any) {
|
|
if (typeof dataValue === 'string') {
|
|
return (
|
|
<div className="whitespace-nowrap text-right">
|
|
<TZLabel time={dayjs(dataValue)} />
|
|
</div>
|
|
)
|
|
}
|
|
return <span className="text-secondary">—</span>
|
|
},
|
|
align: 'right',
|
|
},
|
|
{
|
|
title: 'Status Message',
|
|
dataIndex: 'status_message',
|
|
render: (_: any, migration: ManagedMigrationData) => migration.status_message || '-',
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
key: 'actions',
|
|
render: (_: any, migration: ManagedMigrationData) => {
|
|
if (migration.is_trial && migration.display_status === 'completed') {
|
|
const alreadyPromoted = migrations.some(
|
|
(other) => other.promoted_from_trial_id === migration.id
|
|
)
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="secondary"
|
|
size="small"
|
|
onClick={() => viewTrialResults(migration.id)}
|
|
>
|
|
View results
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
onClick={() => promoteTrial(migration.id)}
|
|
loading={promotingMigrationId === migration.id}
|
|
disabledReason={
|
|
alreadyPromoted
|
|
? 'This trial already started a full import'
|
|
: promotingMigrationId
|
|
? 'Starting import…'
|
|
: undefined
|
|
}
|
|
>
|
|
Run full import
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
if (migration.display_status === 'running') {
|
|
return (
|
|
<Button
|
|
type="secondary"
|
|
size="small"
|
|
onClick={() => pauseMigration(migration.id)}
|
|
loading={migrationsLoading}
|
|
>
|
|
Pause
|
|
</Button>
|
|
)
|
|
} else if (migration.display_status === 'paused') {
|
|
return (
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
onClick={() => resumeMigration(migration.id)}
|
|
loading={migrationsLoading}
|
|
>
|
|
Resume
|
|
</Button>
|
|
)
|
|
}
|
|
return null
|
|
},
|
|
},
|
|
]}
|
|
emptyState="No migrations found. Create a new migration to get started."
|
|
/>
|
|
<TrialResultsModal />
|
|
</>
|
|
)}
|
|
</SceneContent>
|
|
)
|
|
}
|
|
|
|
export const scene: SceneExport = {
|
|
component: ManagedMigrations,
|
|
logic: managedMigrationLogic,
|
|
}
|