feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,121 @@
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { BoxSelectIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PingBadge } from '../ping';
import { Button } from '../ui/button';
import {
IntegrationCard,
IntegrationCardFooter,
IntegrationCardLogo,
IntegrationCardSkeleton,
} from './integration-card';
import { INTEGRATIONS } from './integrations';
export function ActiveIntegrations() {
const { organizationId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.integration.list.queryOptions({
organizationId: organizationId!,
}),
);
const client = useQueryClient();
const deletion = useMutation(
trpc.integration.delete.mutationOptions({
onSuccess() {
client.refetchQueries(
trpc.integration.list.queryFilter({
organizationId,
}),
);
},
}),
);
const data = useMemo(() => {
return (query.data || [])
.map((item) => {
const integration = INTEGRATIONS.find(
(integration) => integration.type === item.config.type,
)!;
return {
...item,
integration,
};
})
.filter((item) => item.integration);
}, [query.data]);
const isLoading = query.isLoading;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<IntegrationCard {...item.integration} name={item.name}>
<IntegrationCardFooter className="row justify-between items-center">
<PingBadge>Connected</PingBadge>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${item.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: item.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddIntegration', {
id: item.id,
type: item.config.type,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</IntegrationCard>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlugIcon } from 'lucide-react';
import { IntegrationCard, IntegrationCardFooter } from './integration-card';
import { INTEGRATIONS } from './integrations';
export function AllIntegrations() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{INTEGRATIONS.map((integration) => (
<IntegrationCard
key={integration.name}
icon={integration.icon}
name={integration.name}
description={integration.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="outline"
onClick={() => {
pushModal('AddIntegration', {
type: integration.type,
});
}}
>
<PlugIcon className="size-4 mr-2" />
Connect
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
import { zCreateDiscordIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateDiscordIntegration>;
export function DiscordIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'discord' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateDiscordIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
const handleTest = async () => {
const webhookUrl = form.getValues('config.url');
if (!webhookUrl) {
return toast.error('Webhook URL is required');
}
const res = await sendTestDiscordNotification(webhookUrl);
if (res.ok) {
toast.success('Test notification sent');
} else {
toast.error('Failed to send test notification');
}
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal discord"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="Discord Webhook URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<div className="row gap-4">
<Button type="button" variant="outline" onClick={handleTest}>
Test connection
</Button>
<Button type="submit" className="flex-1">
Create
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,68 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateSlackIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateSlackIntegration>;
export function SlackIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: {
id: defaultValues?.id,
organizationId,
projectId,
name: defaultValues?.name ?? '',
},
resolver: zodResolver(zCreateSlackIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdateSlack.mutationOptions({
async onSuccess(res) {
window.location.href = res.slackInstallUrl;
onSuccess();
},
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. My personal slack"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,77 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
organizationId,
projectId,
config: {
type: 'webhook' as const,
url: '',
headers: {},
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
},
}),
);
const handleSubmit = (values: IForm) => {
mutation.mutate(values);
};
const handleError = () => {
toast.error('Validation error');
};
return (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
placeholder="Eg. Zapier webhook"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -0,0 +1,144 @@
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
export function IntegrationCardFooter({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('row p-4 border-t rounded-b', className)}>
{children}
</div>
);
}
export function IntegrationCardHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('relative row p-4 border-b rounded-t', className)}>
{children}
</div>
);
}
export function IntegrationCardHeaderButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'absolute right-4 top-0 bottom-0 row items-center gap-2',
className,
)}
>
{children}
</div>
);
}
export function IntegrationCardLogoImage({
src,
backgroundColor,
}: {
src: string;
backgroundColor: string;
}) {
return (
<IntegrationCardLogo
style={{
backgroundColor,
}}
>
<img src={src} alt="Integration Logo" />
</IntegrationCardLogo>
);
}
export function IntegrationCardLogo({
children,
className,
...props
}: {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'size-14 rounded overflow-hidden shrink-0 center-center',
className,
)}
{...props}
>
{children}
</div>
);
}
export function IntegrationCard({
icon,
name,
description,
children,
}: {
icon: React.ReactNode;
name: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="card self-start">
<IntegrationCardContent
icon={icon}
name={name}
description={description}
/>
{children}
</div>
);
}
export function IntegrationCardContent({
icon,
name,
description,
}: {
icon: React.ReactNode;
name: string;
description: string;
}) {
return (
<div className="row gap-4 p-4">
{icon}
<div className="col gap-1">
<h2 className="title">{name}</h2>
<p className="text-muted-foreground leading-tight">{description}</p>
</div>
</div>
);
}
export function IntegrationCardSkeleton() {
return (
<div className="card self-start">
<div className="row gap-4 p-4">
<Skeleton className="size-14 rounded shrink-0" />
<div className="col gap-1 flex-grow">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { IIntegrationConfig } from '@openpanel/validation';
import { WebhookIcon } from 'lucide-react';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
} from './integration-card';
export const INTEGRATIONS: {
type: IIntegrationConfig['type'];
name: string;
description: string;
icon: React.ReactNode;
}[] = [
{
type: 'slack',
name: 'Slack',
description:
'Connect your Slack workspace to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
backgroundColor="#481449"
/>
),
},
{
type: 'discord',
name: 'Discord',
description:
'Connect your Discord server to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
backgroundColor="#5864F2"
/>
),
},
{
type: 'webhook',
name: 'Webhook',
description:
'Create a webhook to take actions in your own systems when new events are created.',
icon: (
<IntegrationCardLogo className="bg-foreground text-background">
<WebhookIcon className="size-10" />
</IntegrationCardLogo>
),
},
];