Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

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,
}