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,103 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { addHours, format, startOfHour } from 'date-fns';
import { TrashIcon } from 'lucide-react';
import { toast } from 'sonner';
type Props = { project: IServiceProjectWithClients };
export default function DeleteProject({ project }: Props) {
const router = useRouter();
const trpc = useTRPC();
const mutation = useMutation(
trpc.project.delete.mutationOptions({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
router.invalidate();
},
}),
);
const cancelDeletionMutation = useMutation(
trpc.project.cancelDeletion.mutationOptions({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
router.invalidate();
},
}),
);
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Delete Project</span>
</WidgetHead>
<WidgetBody className="space-y-4">
<p>
Deleting your project will remove it from your organization and all of
its data. It'll be permanently deleted after 24 hours.
</p>
{project?.deleteAt && (
<Alert variant="destructive">
<AlertTitle>Project scheduled for deletion</AlertTitle>
<AlertDescription>
This project will be deleted on{' '}
<span className="font-medium">
{
// add 1 hour and round to the nearest hour
// Since we run cron once an hour
format(
startOfHour(addHours(project.deleteAt, 1)),
'yyyy-MM-dd HH:mm:ss',
)
}
</span>
. Any event associated with this project will be deleted.
</AlertDescription>
</Alert>
)}
<div className="flex gap-4 justify-start">
{project?.deleteAt && (
<Button
variant="outline"
onClick={() => {
cancelDeletionMutation.mutate({ projectId: project.id });
}}
loading={cancelDeletionMutation.isPending}
>
Cancel deletion
</Button>
)}
<Button
disabled={!!project?.deleteAt}
variant="destructive"
icon={TrashIcon}
loading={mutation.isPending}
onClick={() => {
showConfirm({
title: 'Delete Project',
text: 'Are you sure you want to delete this project?',
onConfirm: () => {
mutation.mutate({ projectId: project.id });
},
});
}}
>
Delete Project
</Button>
</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import AnimateHeight from '@/components/animate-height';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { zProject } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
const validator = zProject.pick({
name: true,
id: true,
domain: true,
cors: true,
crossDomain: true,
});
type IForm = z.infer<typeof validator>;
export default function EditProjectDetails({ project }: Props) {
const [hasDomain, setHasDomain] = useState(project.domain !== null);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id: project.id,
name: project.name,
domain: project.domain,
cors: project.cors,
crossDomain: project.crossDomain,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.project.update.mutationOptions({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
},
}),
);
const onSubmit = (values: IForm) => {
if (hasDomain) {
let error = false;
if (values.cors.length === 0) {
form.setError('cors', {
type: 'required',
message: 'Please add at least one cors domain',
});
error = true;
}
if (!values.domain) {
form.setError('domain', {
type: 'required',
message: 'Please add a domain',
});
error = true;
}
if (error) {
return;
}
}
mutation.mutate(hasDomain ? values : { ...values, cors: [], domain: null });
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Details</span>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
label="Name"
{...form.register('name')}
defaultValue={project.name}
/>
<div className="-mb-2 flex gap-2 items-center justify-between">
<Label className="mb-0">Domain</Label>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</div>
<AnimateHeight open={hasDomain}>
<Input
placeholder="https://example.com"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
defaultValue={project.domain ?? ''}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel
label="Allowed domains"
error={form.formState.errors.cors?.message}
>
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*' ? 'Allow all domains' : tag
}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
<Button
loading={mutation.isPending}
type="submit"
icon={SaveIcon}
className="self-start"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type {
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
type Props = {
project: NonNullable<RouterOutputs['project']['getProjectWithClients']>;
};
const validator = z.object({
ips: z.array(z.string()),
profileIds: z.array(z.string()),
});
type IForm = z.infer<typeof validator>;
export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
ips: project.filters
.filter((item): item is IProjectFilterIp => item.type === 'ip')
.map((item) => item.ip),
profileIds: project.filters
.filter(
(item): item is IProjectFilterProfileId => item.type === 'profile_id',
)
.map((item) => item.profileId),
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.project.update.mutationOptions({
onError: handleError,
onSuccess: () => {
toast.success('Project filters updated');
},
}),
);
const onSubmit = (values: IForm) => {
mutation.mutate({
id: project.id,
filters: [
...values.ips.map((ip) => ({ type: 'ip' as const, ip })),
...values.profileIds.map((profileId) => ({
type: 'profile_id' as const,
profileId,
})),
],
});
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="space-y-2">
<span className="title">Exclude events</span>
<p className="text-muted-foreground">
Exclude events from being tracked by adding filters.
</p>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Controller
name="ips"
control={form.control}
render={({ field }) => (
<WithLabel label="IP addresses">
<TagInput
{...field}
id="IP addresses"
error={form.formState.errors.ips?.message}
placeholder="Exclude IP addresses"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Controller
name="profileIds"
control={form.control}
render={({ field }) => (
<WithLabel label="Profile IDs">
<TagInput
{...field}
id="Profile IDs"
error={form.formState.errors.profileIds?.message}
placeholder="Exclude Profile IDs"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Button
loading={mutation.isPending}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,141 @@
import { TooltipComplete } from '@/components/tooltip-complete';
import { Badge } from '@/components/ui/badge';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef, Row } from '@tanstack/react-table';
import { toast } from 'sonner';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard';
export function useColumns(): ColumnDef<
RouterOutputs['organization']['invitations'][number]
>[] {
return [
{
accessorKey: 'id',
},
{
accessorKey: 'email',
header: 'Mail',
cell: ({ row }) => (
<div className="font-medium">{row.original.email}</div>
),
meta: {
label: 'Email',
placeholder: 'Search email',
variant: 'text',
},
},
{
accessorKey: 'role',
header: 'Role',
meta: {
label: 'Role',
},
},
{
accessorKey: 'createdAt',
header: 'Created',
cell: ({ row }) => (
<TooltipComplete
content={new Date(row.original.createdAt).toLocaleString()}
>
{new Date(row.original.createdAt).toLocaleDateString()}
</TooltipComplete>
),
meta: {
label: 'Created',
},
},
{
accessorKey: 'projectAccess',
header: 'Access',
cell: ({ row }) => {
return <AccessCell row={row} />;
},
meta: {
label: 'Access',
},
},
createActionColumn(({ row }) => {
const trpc = useTRPC();
const queryClient = useQueryClient();
const revoke = useMutation(
trpc.organization.revokeInvite.mutationOptions({
onSuccess() {
toast.success(`Invite for ${row.original.email} revoked`);
queryClient.invalidateQueries(
trpc.organization.invitations.queryFilter({
organizationId: row.original.organizationId,
}),
);
},
onError() {
toast.error(`Failed to revoke invite for ${row.original.email}`);
},
}),
);
return (
<>
<DropdownMenuItem
onClick={() => {
clipboard(
`${window.location.origin}/onboarding?inviteId=${row.original.id}`,
);
}}
>
Copy invite link
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ inviteId: row.original.id });
}}
>
Revoke invite
</DropdownMenuItem>
</>
);
}),
];
}
function AccessCell({
row,
}: {
row: Row<RouterOutputs['organization']['invitations'][number]>;
}) {
const trpc = useTRPC();
const projectsQuery = useQuery(
trpc.project.list.queryOptions({
organizationId: row.original.organizationId,
}),
);
const projects = projectsQuery.data ?? [];
const access = row.original.projectAccess ?? [];
return (
<>
{access.map((id) => {
const project = projects.find((p) => p.id === id);
if (!project) {
return (
<Badge key={id} className="mr-1">
Unknown
</Badge>
);
}
return (
<Badge key={id} color="blue" className="mr-1">
{project.name}
</Badge>
);
})}
{access.length === 0 && <Badge variant={'secondary'}>All projects</Badge>}
</>
);
}

View File

@@ -0,0 +1,41 @@
import type { RouterOutputs } from '@/trpc/client';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import { pushModal } from '@/modals';
import type { UseQueryResult } from '@tanstack/react-query';
import { PlusIcon } from 'lucide-react';
import { useColumns } from './columns';
type CommonProps = {
query: UseQueryResult<RouterOutputs['organization']['invitations'], unknown>;
};
type Props = CommonProps;
export const InvitesTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table}>
<Button
icon={PlusIcon}
onClick={() => {
pushModal('CreateInvite');
}}
>
Invite user
</Button>
</DataTableToolbar>
<DataTable table={table} loading={isLoading} />
</>
);
};

View File

@@ -0,0 +1,136 @@
import { TooltipComplete } from '@/components/tooltip-complete';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import { pushModal } from '@/modals';
import type { IServiceMember } from '@openpanel/db';
export function useColumns() {
const columns: ColumnDef<IServiceMember>[] = [
{
accessorKey: 'user',
header: 'Name',
cell: ({ row }) => {
const user = row.original.user;
if (!user) return null;
return [user.firstName, user.lastName].filter(Boolean).join(' ');
},
meta: {
label: 'Name',
},
},
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => {
const user = row.original.user;
if (!user) return null;
return <div className="font-medium">{user.email}</div>;
},
meta: {
label: 'Email',
placeholder: 'Search email',
variant: 'text',
},
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => {
const role = row.original.role;
return <Badge variant={'outline'}>{role}</Badge>;
},
meta: {
label: 'Role',
},
},
{
accessorKey: 'createdAt',
header: 'Created',
cell: ({ row }) => (
<TooltipComplete
content={new Date(row.original.createdAt).toLocaleString()}
>
{new Date(row.original.createdAt).toLocaleDateString()}
</TooltipComplete>
),
meta: {
label: 'Created',
},
},
{
accessorKey: 'access',
header: 'Access',
cell: ({ row }) => {
const access = row.original.access ?? [];
if (access.length === 0) {
return <div className="text-muted-foreground">All projects</div>;
}
return (
<div className="row flex-wrap gap-2">
{row.original.access?.map((item) => (
<Badge variant={'outline'} key={item.projectId}>
{item.projectId}
</Badge>
))}
</div>
);
},
meta: {
label: 'Access',
},
},
createActionColumn(({ row }) => {
const queryClient = useQueryClient();
const trpc = useTRPC();
const revoke = useMutation(
trpc.organization.removeMember.mutationOptions({
onSuccess() {
toast.success(
`${row.original.user?.firstName} has been removed from the organization`,
);
queryClient.invalidateQueries(
trpc.organization.members.pathFilter(),
);
},
onError() {
toast.error(
`Failed to remove ${row.original.user?.firstName} from the organization`,
);
},
}),
);
return (
<>
<DropdownMenuItem
onClick={() => {
pushModal('EditMember', row.original);
}}
>
Edit access
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({
organizationId: row.original.organizationId,
userId: row.original.userId!,
id: row.original.id,
});
}}
>
Remove member
</DropdownMenuItem>
</>
);
}),
];
return columns;
}

View File

@@ -0,0 +1,31 @@
import type { IServiceMember } from '@openpanel/db';
import { DataTable } from '@/components/ui/data-table/data-table';
import { DataTableToolbar } from '@/components/ui/data-table/data-table-toolbar';
import { useTable } from '@/components/ui/data-table/use-table';
import type { UseQueryResult } from '@tanstack/react-query';
import { useColumns } from './columns';
type CommonProps = {
query: UseQueryResult<IServiceMember[], unknown>;
};
type Props = CommonProps;
export const MembersTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
columns,
data: data ?? [],
loading: isLoading,
pageSize: 50,
});
return (
<>
<DataTableToolbar table={table} />
<DataTable table={table} loading={isLoading} />;
</>
);
};