feature(dashboard): add ability to filter out events by profile id and ip (#101)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-07 21:34:32 +01:00
committed by GitHub
parent 27ee623584
commit f4ad97d87d
39 changed files with 1148 additions and 542 deletions

View File

@@ -77,7 +77,9 @@ export default function LayoutProjectSelector({
<span className="mx-2 truncate">
{projectId
? projects.find((p) => p.id === projectId)?.name
: 'Select project'}
: organizationId
? organizations?.find((o) => o.id === organizationId)?.name
: 'Select project'}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -14,6 +14,7 @@ import type {
getProjectsByOrganizationId,
} from '@openpanel/db';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
@@ -64,7 +65,9 @@ export function LayoutSidebar({
</Button>
</div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<LogoSquare className="max-h-8" />
<Link href="/">
<LogoSquare className="max-h-8" />
</Link>
<LayoutProjectSelector
align="start"
projects={projects}

View File

@@ -41,26 +41,29 @@ export default function EditOrganization({
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
</WidgetBody>
</Widget>
</form>
<section className="max-w-screen-sm">
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="flex items-end gap-2">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetBody>
</Widget>
</form>
</section>
);
}

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 { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { type IProjectEdit, zProject } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, UseFormReturn, useForm, useWatch } 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(true);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id: project.id,
name: project.name,
domain: project.domain,
cors: project.cors,
crossDomain: project.crossDomain,
},
});
const mutation = api.project.update.useMutation({
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, (errors) => {
console.log(errors);
})}
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="Domain"
{...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="Cors">
<TagInput
{...field}
id="Cors"
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.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,118 @@
'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 { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import type {
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
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 mutation = api.project.update.useMutation({
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="col gap-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="col gap-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.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,114 +0,0 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { ClientActions } from '@/components/clients/client-actions';
import { ProjectActions } from '@/components/projects/project-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { pushModal } from '@/modals';
import { InfoIcon, PlusIcon, PlusSquareIcon } from 'lucide-react';
import type { IServiceClientWithProject, IServiceProject } from '@openpanel/db';
interface ListProjectsProps {
projects: IServiceProject[];
clients: IServiceClientWithProject[];
}
export default function ListProjects({ projects, clients }: ListProjectsProps) {
return (
<>
<div className="row mb-4 justify-between">
<h1 className="text-2xl font-bold">Projects</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
<div className="card p-4">
<Alert className="mb-4">
<InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle>
<AlertDescription>
A project can be a website, mobile app or any other application that
you want to track event for. Each project can have one or more
clients. The client is used to send events to the project.
</AlertDescription>
</Alert>
<Accordion type="single" collapsible className="-mx-4">
{projects.map((project) => {
const pClients = clients.filter(
(client) => client.projectId === project.id,
);
return (
<AccordionItem
value={project.id}
key={project.id}
className="last:border-b-0"
>
<AccordionTrigger className="px-4">
<div className="flex-1 text-left">
{project.name}
<span className="ml-2 text-muted-foreground">
{pClients.length > 0
? `(${pClients.length} clients)`
: 'No clients created yet'}
</span>
</div>
<div className="mx-4" />
</AccordionTrigger>
<AccordionContent className="px-4">
<ProjectActions {...project} />
<div className="mt-4 grid gap-4 md:grid-cols-3">
{pClients.map((item) => {
return (
<div
className="relative rounded border border-border p-4"
key={item.id}
>
<div className="mb-1 font-medium">{item.name}</div>
<Tooltiper
className="text-muted-foreground"
content={item.id}
>
Client ID: ...{item.id.slice(-12)}
</Tooltiper>
<div className="text-muted-foreground">
{item.cors &&
item.cors !== '*' &&
`Website: ${item.cors}`}
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
</div>
);
})}
<button
type="button"
onClick={() => {
pushModal('AddClient', {
projectId: project.id,
});
}}
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
>
<PlusSquareIcon />
<div className="font-medium">New client</div>
</button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</>
);
}

View File

@@ -1,29 +1,40 @@
import { Padding } from '@/components/ui/padding';
import {
db,
getClientsByOrganizationId,
getProjectWithClients,
getProjectsByOrganizationId,
} from '@openpanel/db';
import ListProjects from './list-projects';
import { notFound } from 'next/navigation';
import EditProjectDetails from './edit-project-details';
import EditProjectFilters from './edit-project-filters';
import ProjectClients from './project-clients';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
};
}
export default async function Page({
params: { organizationSlug: organizationId },
}: PageProps) {
const [projects, clients] = await Promise.all([
getProjectsByOrganizationId(organizationId),
getClientsByOrganizationId(organizationId),
]);
export default async function Page({ params: { projectId } }: PageProps) {
const project = await getProjectWithClients(projectId);
if (!project) {
notFound();
}
return (
<Padding>
<ListProjects projects={projects} clients={clients} />
<div className="col gap-4">
<div className="row justify-between items-center">
<h1 className="text-2xl font-bold">{project.name}</h1>
</div>
<EditProjectDetails project={project} />
<EditProjectFilters project={project} />
<ProjectClients project={project} />
</div>
</Padding>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { ClientsTable } from '@/components/clients/table';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { pushModal } from '@/modals';
import type {
IServiceClientWithProject,
IServiceProjectWithClients,
} from '@openpanel/db';
import { PlusIcon } from 'lucide-react';
import { omit } from 'ramda';
type Props = { project: IServiceProjectWithClients };
export default function ProjectClients({ project }: Props) {
return (
<Widget className="max-w-screen-md w-full overflow-hidden">
<WidgetHead className="flex items-center justify-between">
<span className="title">Clients</span>
<Button
variant="outline"
icon={PlusIcon}
className="-my-1"
onClick={() => pushModal('AddClient')}
>
New client
</Button>
</WidgetHead>
<WidgetBody className="p-0 [&>div]:border-none [&>div]:rounded-none">
<ClientsTable
// @ts-expect-error
query={{
data: project.clients.map((item) => ({
...item,
project: omit(['clients'], item),
})) as unknown as IServiceClientWithProject[],
isFetching: false,
isLoading: false,
}}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,10 +1,11 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullWidthNavbar from '@/components/full-width-navbar';
import ProjectCard from '@/components/projects/project-card';
import SignOutButton from '@/components/sign-out-button';
import { redirect } from 'next/navigation';
import SettingsToggle from '@/components/settings-toggle';
import { getCurrentOrganizations, getCurrentProjects } from '@openpanel/db';
import LayoutProjectSelector from './[projectId]/layout-project-selector';
interface PageProps {
params: {
@@ -21,7 +22,6 @@ export default async function Page({
]);
const organization = organizations.find((org) => org.id === organizationId);
console.log(organizations, organizationId, projects);
if (!organization) {
return (
@@ -42,10 +42,16 @@ export default async function Page({
return (
<div>
<FullWidthNavbar>
<SignOutButton />
<div className="row gap-4">
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
</FullWidthNavbar>
<div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:max-w-[95vw] lg:max-w-[80vw] ">
<h1 className="text-xl font-medium">Select project</h1>
<div className="grid gap-4 md:grid-cols-2">
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />