feat: new importer (#214)

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-05 09:49:36 +01:00
committed by GitHub
parent b51bc8f3f6
commit 212254d31a
80 changed files with 4884 additions and 842 deletions

View File

@@ -50,12 +50,15 @@ export function IntegrationCardHeaderButtons({
export function IntegrationCardLogoImage({
src,
backgroundColor,
className,
}: {
src: string;
backgroundColor: string;
className?: string;
}) {
return (
<IntegrationCardLogo
className={className}
style={{
backgroundColor,
}}

View File

@@ -0,0 +1,116 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { IServiceProject } from '@openpanel/db';
import { PlusIcon, XIcon } from 'lucide-react';
import type {
Control,
FieldArrayWithId,
UseFieldArrayAppend,
UseFieldArrayRemove,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
interface ProjectMapperProps {
fields: FieldArrayWithId<any, 'projectMapper', 'id'>[];
append: UseFieldArrayAppend<any, 'projectMapper'>;
remove: UseFieldArrayRemove;
projects: IServiceProject[];
register: UseFormRegister<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
}
export function ProjectMapper({
fields,
append,
remove,
projects,
register,
watch,
setValue,
}: ProjectMapperProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="mb-0">Project Mapper (Optional)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ from: '', to: '' })}
>
<PlusIcon className="mr-1 h-4 w-4" />
Add Mapping
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground leading-normal">
Map source project IDs to your OpenPanel projects. If you skip mapping
all data will be imported to your current project.
</p>
)}
{fields.length > 0 && (
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="gap-2 rounded-md border p-3 row">
<div className="grid grid-cols-2 gap-2">
<div className="flex-1">
<Label className="text-xs text-muted-foreground">
From (Source Project ID)
</Label>
<Input
placeholder="e.g., abc123"
{...register(`projectMapper.${index}.from`)}
className="mt-1"
/>
</div>
<div className="flex-1">
<Label className="text-xs text-muted-foreground">
To (OpenPanel Project)
</Label>
<Select
value={watch(`projectMapper.${index}.to`)}
onValueChange={(value) =>
setValue(`projectMapper.${index}.to`, value)
}
>
<SelectTrigger className="mt-1 w-full" size="sm">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projects.slice(0, 10).map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="mt-5"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,11 @@ const badgeVariants = cva(
{
variants: {
variant: {
success:
'border-transparent bg-emerald-700 text-white [a&]:hover:bg-emerald-700/90',
warning:
'border-transparent bg-yellow-500 text-white [a&]:hover:bg-yellow-500/90',
info: 'border-transparent bg-blue-500 text-white [a&]:hover:bg-blue-500/90',
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:

View File

@@ -0,0 +1,282 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { ProjectMapper } from '@/components/project-mapper';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import type {
IImportConfig,
IMixpanelImportConfig,
IUmamiImportConfig,
} from '@openpanel/validation';
import {
zMixpanelImportConfig,
zUmamiImportConfig,
} from '@openpanel/validation';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import type { z } from 'zod';
import { popModal, pushModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Provider = 'umami' | 'plausible' | 'mixpanel';
interface AddImportProps {
provider: Provider;
name: string;
types: ('file' | 'api')[];
}
type UmamiFormData = z.infer<typeof zUmamiImportConfig>;
type MixpanelFormData = z.infer<typeof zMixpanelImportConfig>;
interface UmamiImportProps {
onSubmit: (config: IUmamiImportConfig) => void;
isPending: boolean;
organizationId: string;
}
function UmamiImport({
onSubmit,
isPending,
organizationId,
}: UmamiImportProps) {
const trpc = useTRPC();
const { data: projects = [] } = useQuery(
trpc.project.list.queryOptions({
organizationId,
}),
);
const form = useForm<UmamiFormData>({
resolver: zodResolver(zUmamiImportConfig),
defaultValues: {
provider: 'umami',
type: 'file',
fileUrl: '',
projectMapper: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'projectMapper',
});
const handleSubmit = form.handleSubmit((data) => {
onSubmit(data);
});
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4 py-4">
<InputWithLabel
label="File URL"
placeholder="https://example.com/export.csv"
error={form.formState.errors.fileUrl?.message}
info="Provide a publicly accessible URL to your exported CSV file."
{...form.register('fileUrl')}
/>
<ProjectMapper
fields={fields}
append={append}
remove={remove}
projects={projects}
register={form.register}
watch={form.watch}
setValue={form.setValue}
/>
</div>
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Starting...' : 'Start Import'}
</Button>
</div>
</form>
);
}
interface MixpanelImportProps {
onSubmit: (config: IMixpanelImportConfig) => void;
isPending: boolean;
organizationId: string;
}
function MixpanelImport({
onSubmit,
isPending,
organizationId,
}: MixpanelImportProps) {
const trpc = useTRPC();
const form = useForm<MixpanelFormData>({
resolver: zodResolver(zMixpanelImportConfig),
defaultValues: {
provider: 'mixpanel',
type: 'api',
serviceAccount: '',
serviceSecret: '',
projectId: '',
from: '',
to: '',
},
});
const handleDateRangeSelect = () => {
pushModal('DateRangerPicker', {
startDate: form.getValues('from')
? new Date(form.getValues('from'))
: undefined,
endDate: form.getValues('to')
? new Date(form.getValues('to'))
: undefined,
onChange: ({ startDate, endDate }) => {
form.setValue('from', format(startDate, 'yyyy-MM-dd'));
form.setValue('to', format(endDate, 'yyyy-MM-dd'));
form.trigger('from');
form.trigger('to');
},
});
};
const handleSubmit = form.handleSubmit((data) => {
onSubmit(data);
});
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4 py-4">
<InputWithLabel
label="Service Account"
placeholder="Eg. xxx.xxx.mp-service-account"
error={form.formState.errors.serviceAccount?.message}
{...form.register('serviceAccount')}
/>
<InputWithLabel
label="Service Secret"
type="password"
placeholder="Your Mixpanel service secret"
error={form.formState.errors.serviceSecret?.message}
{...form.register('serviceSecret')}
/>
<InputWithLabel
label="Project ID"
placeholder="Your Mixpanel project ID"
error={form.formState.errors.projectId?.message}
{...form.register('projectId')}
/>
<WithLabel
label="Date Range"
info={
!form.getValues('from') || !form.getValues('to')
? 'Select the date range for importing data'
: undefined
}
>
<Button
type="button"
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
(!form.getValues('from') || !form.getValues('to')) &&
'text-muted-foreground',
)}
onClick={handleDateRangeSelect}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{form.getValues('from') && form.getValues('to') ? (
<>
{format(new Date(form.getValues('from')), 'LLL dd, y')} -{' '}
{format(new Date(form.getValues('to')), 'LLL dd, y')}
</>
) : (
<span>Pick a date range</span>
)}
</Button>
</WithLabel>
<InputWithLabel
label="Screen View Property"
placeholder="Enter the name of the property that contains the screen name"
info="Leave empty if not applicable"
error={form.formState.errors.mapScreenViewProperty?.message}
{...form.register('mapScreenViewProperty')}
/>
</div>
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Starting...' : 'Start Import'}
</Button>
</div>
</form>
);
}
export default function AddImport({ provider, name }: AddImportProps) {
const { projectId, organizationId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const createImport = useMutation(
trpc.import.create.mutationOptions({
onSuccess() {
toast.success('Import started', {
description: 'Your data import has been queued for processing.',
});
popModal();
queryClient.invalidateQueries(trpc.import.list.pathFilter());
},
onError: (error) => {
toast.error('Import failed', {
description: error.message,
});
},
}),
);
const handleImportSubmit = (config: IImportConfig) => {
createImport.mutate({
projectId,
provider: config.provider,
config,
});
};
return (
<ModalContent>
<ModalHeader title={`Import from ${name}`} />
{provider === 'umami' && (
<UmamiImport
onSubmit={handleImportSubmit}
isPending={createImport.isPending}
organizationId={organizationId}
/>
)}
{provider === 'mixpanel' && (
<MixpanelImport
onSubmit={handleImportSubmit}
isPending={createImport.isPending}
organizationId={organizationId}
/>
)}
</ModalContent>
);
}

View File

@@ -27,6 +27,7 @@ export default function DateRangerPicker({
return (
<ModalContent className="p-4 md:p-8 min-w-fit">
<Calendar
captionLayout="dropdown"
initialFocus
mode="range"
defaultMonth={subMonths(

View File

@@ -5,6 +5,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
import Instructions from './Instructions';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
import AddImport from './add-import';
import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule';
import AddProject from './add-project';
@@ -38,6 +39,7 @@ const modals = {
EditClient: EditClient,
AddProject: AddProject,
AddClient: AddClient,
AddImport: AddImport,
Confirm: Confirm,
SaveReport: SaveReport,
AddDashboard: AddDashboard,

View File

@@ -56,6 +56,7 @@ import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId_.profiles._tabs.index'
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId_.notifications._tabs.index'
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId_.events._tabs.index'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId_.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId_.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId_.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId_.settings._tabs.clients'
@@ -381,6 +382,12 @@ const AppOrganizationIdProjectIdEventsTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdEventsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
id: '/imports',
path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
id: '/events',
@@ -515,6 +522,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
@@ -569,6 +577,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
}
export interface FileRoutesById {
@@ -633,6 +642,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId_/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId_/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId_/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId_/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId_/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/_app/$organizationId/$projectId_/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId_/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
@@ -692,6 +702,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/events/'
| '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/'
@@ -746,6 +757,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/profiles/$profileId/events'
id:
| '__root__'
@@ -809,6 +821,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId_/settings/_tabs/clients'
| '/_app/$organizationId/$projectId_/settings/_tabs/details'
| '/_app/$organizationId/$projectId_/settings/_tabs/events'
| '/_app/$organizationId/$projectId_/settings/_tabs/imports'
| '/_app/$organizationId/$projectId_/events/_tabs/'
| '/_app/$organizationId/$projectId_/notifications/_tabs/'
| '/_app/$organizationId/$projectId_/profiles/_tabs/'
@@ -1194,6 +1207,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
}
'/_app/$organizationId/$projectId_/settings/_tabs/imports': {
id: '/_app/$organizationId/$projectId_/settings/_tabs/imports'
path: '/imports'
fullPath: '/$organizationId/$projectId/settings/imports'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId_/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId_/settings/_tabs/events'
path: '/events'
@@ -1521,6 +1541,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
}
@@ -1532,6 +1553,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
}

View File

@@ -0,0 +1,303 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import {
IntegrationCard,
IntegrationCardFooter,
IntegrationCardLogoImage,
} from '@/components/integrations/integration-card';
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { IMPORT_PROVIDERS } from '@openpanel/importer/providers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import {
CheckCircleIcon,
Download,
InfoIcon,
Loader2Icon,
XCircleIcon,
} from 'lucide-react';
import { toast } from 'sonner';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/imports',
)({
component: ImportsSettings,
});
function ImportsSettings() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const importsQuery = useQuery(
trpc.import.list.queryOptions(
{ projectId },
{
refetchInterval: 5000,
},
),
);
const imports = importsQuery.data ?? [];
const deleteImport = useMutation(
trpc.import.delete.mutationOptions({
onSuccess: () => {
toast.success('Import deleted', {
description: 'The import has been successfully deleted.',
});
queryClient.invalidateQueries(trpc.import.list.pathFilter());
},
}),
);
const retryImport = useMutation(
trpc.import.retry.mutationOptions({
onSuccess: () => {
toast.success('Import retried', {
description: 'The import has been queued for processing again.',
});
queryClient.invalidateQueries(trpc.import.list.pathFilter());
},
}),
);
const handleProviderSelect = (
provider: (typeof IMPORT_PROVIDERS)[number],
) => {
pushModal('AddImport', {
provider: provider.id,
name: provider.name,
types: provider.types,
});
};
const getStatusBadge = (status: string, errorMessage: string | null) => {
const variants: Record<string, any> = {
pending: 'secondary',
processing: 'default',
completed: 'success',
failed: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
pending: <Loader2Icon className="w-4 h-4 animate-spin" />,
processing: <Loader2Icon className="w-4 h-4 animate-spin" />,
completed: <CheckCircleIcon className="w-4 h-4" />,
failed: <XCircleIcon className="w-4 h-4" />,
};
if (status === 'failed') {
return (
<Tooltiper
content={errorMessage}
tooltipClassName="max-w-xs break-words"
>
<Badge variant={variants[status] || 'default'} className="capitalize">
{icons[status] || null}
{status}
</Badge>
</Tooltiper>
);
}
return (
<Badge variant={variants[status] || 'default'} className="capitalize">
{icons[status] || null}
{status}
</Badge>
);
};
return (
<div className="space-y-8">
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{IMPORT_PROVIDERS.map((provider) => (
<IntegrationCard
key={provider.id}
icon={
<IntegrationCardLogoImage
src={provider.logo}
backgroundColor={provider.backgroundColor}
className="p-4"
/>
}
name={provider.name}
description={provider.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="ghost"
onClick={() => handleProviderSelect(provider)}
>
<Download className="w-4 h-4 mr-2" />
Import Data
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Import History</h3>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Config</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!importsQuery.isLoading && imports.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<FullPageEmptyState
title="No imports yet"
description="Your import history will appear here."
/>
</TableCell>
</TableRow>
)}
{importsQuery.isLoading &&
[1, 2, 3, 4].map((index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-3/5" />
</TableCell>
<TableCell className="text-right justify-end row">
<Skeleton className="h-4 w-3/5" />
</TableCell>
</TableRow>
))}
{imports.map((imp) => (
<TableRow key={imp.id}>
<TableCell className="font-medium capitalize">
<div className="row gap-2 items-center">
<div>{imp.config.provider}</div>
<Badge variant="outline" className="uppercase">
{imp.config.type}
</Badge>
</div>
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(imp.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="space-y-1">
{getStatusBadge(imp.status, imp.errorMessage)}
{imp.statusMessage && (
<div className="text-xs text-muted-foreground truncate">
{imp.statusMessage}
</div>
)}
</div>
</TableCell>
<TableCell>
{imp.totalEvents > 0 ? (
<div className="space-y-1 font-mono">
<div className="text-sm">
{imp.processedEvents.toLocaleString()}
{' / '}
<Tooltiper
content="Estimated number of events. Can be inaccurate depending on the provider."
tooltipClassName="max-w-xs"
>
{imp.totalEvents.toLocaleString()}{' '}
<InfoIcon className="w-4 h-4 inline-block relative -top-px" />
</Tooltiper>
</div>
{imp.status === 'processing' && (
<div className="w-full bg-secondary rounded-full h-1.5">
<div
className="bg-primary h-1.5 rounded-full transition-all"
style={{
width: `${Math.min(Math.round((imp.processedEvents / imp.totalEvents) * 100), 100)}%`,
}}
/>
</div>
)}
</div>
) : imp.totalEvents === -1 ? (
<div className="font-mono text-sm">
{imp.processedEvents.toLocaleString()}
{' / '}
N/A
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<Tooltiper
content={
<pre className="font-mono text-sm leading-normal whitespace-pre-wrap break-words">
{JSON.stringify(imp.config, null, 2)}
</pre>
}
tooltipClassName="max-w-xs"
>
<Badge>Config</Badge>
</Tooltiper>
</TableCell>
<TableCell className="text-right space-x-2">
{imp.status === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={() => retryImport.mutate({ id: imp.id })}
>
Retry
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => deleteImport.mutate({ id: imp.id })}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -42,6 +42,7 @@ function ProjectDashboard() {
{ id: 'details', label: 'Details' },
{ id: 'events', label: 'Events' },
{ id: 'clients', label: 'Clients' },
{ id: 'imports', label: 'Imports' },
];
const handleTabChange = (tabId: string) => {

View File

@@ -1 +1 @@
export * from '@openpanel/common/src/math';
export * from '@openpanel/common';

View File

@@ -1,8 +1,20 @@
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OP_CLIENT_ID,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
const createOpInstance = () => {
if (!clientId || clientId === 'undefined') {
return new Proxy({} as OpenPanel, {
get: () => () => {},
});
}
return new OpenPanel({
clientId,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
};
export const op = createOpInstance();

View File

@@ -1 +1 @@
export * from '@openpanel/common/src/slug';
export * from '@openpanel/common';