5 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
12e8c9beaa remove template 2026-01-21 19:50:21 +01:00
Carl-Gerhard Lindesvärd
f9b1ec5038 fix coderabbit comments 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
3fa1a5429e wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
a58761e8d7 wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
56f1c5e894 wip 2026-01-21 15:31:58 +01:00
63 changed files with 274 additions and 2851 deletions

View File

@@ -41,7 +41,6 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/ COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/ COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/ COPY packages/integrations/package.json packages/integrations/
COPY packages/js-runtime/package.json packages/js-runtime/
COPY patches ./patches COPY patches ./patches
# BUILD # BUILD
@@ -109,7 +108,6 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen RUN pnpm db:codegen

View File

@@ -7,7 +7,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache, getRedisQueue } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { import {
type IDecrementPayload, type IDecrementPayload,
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
}); });
try { try {
const multi = getRedisQueue().multi(); const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`); multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`); multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec(); const res = await multi.exec();

View File

@@ -189,48 +189,28 @@ export default function OpenSourcePage() {
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word." description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon} icon={GlobeIcon}
> >
<a <p className="text-sm text-muted-foreground mt-2">
href="https://openpanel.dev" Display real-time visitor counts, page views, or other
style={{ metrics on your project's website.
display: 'inline-block', </p>
overflow: 'hidden',
borderRadius: '8px',
width: '250px',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</FeatureCard> </FeatureCard>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
That's it. No complicated requirements, no hidden fees, no That's it. No complicated requirements, no hidden fees, no
catch. We just want to help open source projects succeed. catch. We just want to help open source projects succeed.
</p> </p>
</div> </div>
<div> <div className="text-center text-xs text-muted-foreground">
<div className="text-center text-xs text-muted-foreground"> <iframe
<iframe title="Realtime Widget"
title="Realtime Widget" src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY" width="300"
width="300" height="400"
height="400" className="rounded-xl border mb-2"
className="rounded-xl border mb-2" />
/> Analytics from{' '}
Analytics from{' '} <a className="underline" href="https://openpanel.dev">
<a className="underline" href="https://openpanel.dev"> OpenPanel.dev
OpenPanel.dev </a>
</a>
</div>
</div> </div>
</div> </div>
</Section> </Section>

View File

@@ -71,7 +71,6 @@ export function FeatureCard({
<h3 className="text-lg font-semibold">{title}</h3> <h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
</div> </div>
{children}
</FeatureCardContainer> </FeatureCardContainer>
); );
} }

View File

@@ -79,28 +79,10 @@ export async function Footer() {
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32"> <div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between gap-8"> <div className="container col md:row justify-between gap-8">
<div> <div>
<a <Link href="/" className="row items-center font-medium -ml-3">
href="https://openpanel.dev" <Logo className="h-6" />
style={{ {baseOptions().nav?.title}
display: 'inline-block', </Link>
overflow: 'hidden',
borderRadius: '8px',
width: '100%',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</div> </div>
<Social /> <Social />
</div> </div>

View File

@@ -84,16 +84,9 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"ai": "^4.2.10", "ai": "^4.2.10",
"bind-event-listener": "^3.0.0", "bind-event-listener": "^3.0.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.35.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^0.2.1", "cmdk": "^0.2.1",
"codemirror": "^6.0.1",
"d3": "^7.8.5", "d3": "^7.8.5",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"debounce": "^2.2.0", "debounce": "^2.2.0",

View File

@@ -106,7 +106,7 @@ export function useColumns() {
if (profile) { if (profile) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
> >
{getProfileName(profile)} {getProfileName(profile)}
@@ -117,7 +117,7 @@ export function useColumns() {
if (profileId && profileId !== deviceId) { if (profileId && profileId !== deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profileId)}`} href={`/profiles/${profileId}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
> >
Unknown Unknown
@@ -128,7 +128,7 @@ export function useColumns() {
if (deviceId) { if (deviceId) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(deviceId)}`} href={`/profiles/${deviceId}`}
className="whitespace-nowrap font-medium hover:underline" className="whitespace-nowrap font-medium hover:underline"
> >
Anonymous Anonymous

View File

@@ -1,53 +1,18 @@
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label'; import { InputWithLabel } from '@/components/forms/input-with-label';
import { JsonEditor } from '@/components/json-editor';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation'; import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { path, mergeDeepRight } from 'ramda'; import { path, mergeDeepRight } from 'ramda';
import { useEffect } from 'react';
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { z } from 'zod'; import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>; type IForm = z.infer<typeof zCreateWebhookIntegration>;
const DEFAULT_TRANSFORMER = `(payload) => {
return payload;
}`;
// Convert Record<string, string> to array format for form
function headersToArray(
headers: Record<string, string> | undefined,
): { key: string; value: string }[] {
if (!headers || Object.keys(headers).length === 0) {
return [];
}
return Object.entries(headers).map(([key, value]) => ({ key, value }));
}
// Convert array format back to Record<string, string> for API
function headersToRecord(
headers: { key: string; value: string }[],
): Record<string, string> {
return headers.reduce(
(acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
}
export function WebhookIntegrationForm({ export function WebhookIntegrationForm({
defaultValues, defaultValues,
onSuccess, onSuccess,
@@ -56,13 +21,6 @@ export function WebhookIntegrationForm({
onSuccess: () => void; onSuccess: () => void;
}) { }) {
const { organizationId } = useAppParams(); const { organizationId } = useAppParams();
// Convert headers from Record to array format for form UI
const defaultHeaders =
defaultValues?.config && 'headers' in defaultValues.config
? headersToArray(defaultValues.config.headers)
: [];
const form = useForm<IForm>({ const form = useForm<IForm>({
defaultValues: mergeDeepRight( defaultValues: mergeDeepRight(
{ {
@@ -72,68 +30,18 @@ export function WebhookIntegrationForm({
type: 'webhook' as const, type: 'webhook' as const,
url: '', url: '',
headers: {}, headers: {},
mode: 'message' as const,
javascriptTemplate: undefined,
}, },
}, },
defaultValues ?? {}, defaultValues ?? {},
), ),
resolver: zodResolver(zCreateWebhookIntegration), resolver: zodResolver(zCreateWebhookIntegration),
}); });
// Use a separate form for headers array to work with useFieldArray
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
defaultValues: {
headers: defaultHeaders,
},
});
const headersArray = useFieldArray({
control: headersForm.control,
name: 'headers',
});
// Watch headers array and sync to main form
const watchedHeaders = useWatch({
control: headersForm.control,
name: 'headers',
});
// Sync headers array changes back to main form
useEffect(() => {
if (watchedHeaders) {
const validHeaders = watchedHeaders.filter(
(h): h is { key: string; value: string } =>
h !== undefined &&
typeof h.key === 'string' &&
typeof h.value === 'string',
);
form.setValue('config.headers', headersToRecord(validHeaders), {
shouldValidate: false,
});
}
}, [watchedHeaders, form]);
const mode = form.watch('config.mode');
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({ trpc.integration.createOrUpdate.mutationOptions({
onSuccess, onSuccess,
onError(error) { onError() {
// Handle validation errors from tRPC toast.error('Failed to create integration');
if (error.data?.code === 'BAD_REQUEST') {
const errorMessage = error.message || 'Invalid JavaScript template';
toast.error(errorMessage);
// Set form error if it's a JavaScript template error
if (errorMessage.includes('JavaScript template')) {
form.setError('config.javascriptTemplate', {
type: 'manual',
message: errorMessage,
});
}
} else {
toast.error('Failed to create integration');
}
}, },
}), }),
); );
@@ -162,176 +70,7 @@ export function WebhookIntegrationForm({
{...form.register('config.url')} {...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)} error={path(['config', 'url', 'message'], form.formState.errors)}
/> />
<Button type="submit">Create</Button>
<WithLabel
label="Headers"
info="Add custom HTTP headers to include with webhook requests"
>
<div className="col gap-2">
{headersArray.fields.map((field, index) => (
<div key={field.id} className="row gap-2">
<Input
placeholder="Header Name"
{...headersForm.register(`headers.${index}.key`)}
className="flex-1"
/>
<Input
placeholder="Header Value"
{...headersForm.register(`headers.${index}.value`)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => headersArray.remove(index)}
className="text-destructive"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => headersArray.append({ key: '', value: '' })}
className="self-start"
icon={PlusIcon}
>
Add Header
</Button>
</div>
</WithLabel>
<Controller
control={form.control}
name="config.mode"
render={({ field }) => (
<WithLabel
label="Payload Format"
info="Choose how to format the webhook payload"
>
<Combobox
{...field}
className="w-full"
placeholder="Select format"
items={[
{
label: 'Message',
value: 'message' as const,
},
{
label: 'JavaScript',
value: 'javascript' as const,
},
]}
value={field.value ?? 'message'}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
{mode === 'javascript' && (
<Controller
control={form.control}
name="config.javascriptTemplate"
render={({ field }) => (
<WithLabel
label="JavaScript Transform"
info={
<div className="prose dark:prose-invert max-w-none">
<p>
Write a JavaScript function that transforms the event
payload. The function receives <code>payload</code> as a
parameter and should return an object.
</p>
<p className="text-sm font-semibold mt-2">
Available in payload:
</p>
<ul className="text-sm">
<li>
<code>payload.name</code> - Event name
</li>
<li>
<code>payload.profileId</code> - User profile ID
</li>
<li>
<code>payload.properties</code> - Full properties object
</li>
<li>
<code>payload.properties.your.property</code> - Nested
property value
</li>
<li>
<code>payload.profile.firstName</code> - Profile property
</li>
<li>
<div className="flex gap-x-2 flex-wrap mt-1">
<code>country</code>
<code>city</code>
<code>device</code>
<code>os</code>
<code>browser</code>
<code>path</code>
<code>createdAt</code>
and more...
</div>
</li>
</ul>
<p className="text-sm font-semibold mt-2">
Available helpers:
</p>
<ul className="text-sm">
<li>
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
<code>Array</code>, <code>String</code>,{' '}
<code>Object</code>
</li>
</ul>
<p className="text-sm mt-2">
<strong>Example:</strong>
</p>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
{`(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString(),
location: \`\${payload.city}, \${payload.country}\`
})`}
</pre>
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
<strong>Security:</strong> Network calls, file system
access, and other dangerous operations are blocked.
</p>
</div>
}
>
<JsonEditor
value={field.value ?? DEFAULT_TRANSFORMER}
onChange={(value) => {
field.onChange(value);
// Clear error when user starts typing
if (form.formState.errors.config?.javascriptTemplate) {
form.clearErrors('config.javascriptTemplate');
}
}}
placeholder={DEFAULT_TRANSFORMER}
minHeight="300px"
language="javascript"
/>
{form.formState.errors.config?.javascriptTemplate && (
<p className="mt-1 text-sm text-destructive">
{form.formState.errors.config.javascriptTemplate.message}
</p>
)}
</WithLabel>
)}
/>
)}
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
</form> </form>
); );
} }

View File

@@ -1,218 +0,0 @@
'use client';
import { basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import {
Compartment,
EditorState,
type Extension,
} from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from './theme-provider';
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
language?: 'json' | 'javascript';
onValidate?: (isValid: boolean, error?: string) => void;
}
export function JsonEditor({
value,
onChange,
placeholder = '{}',
className = '',
minHeight = '200px',
language = 'json',
onValidate,
}: JsonEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment | null>(null);
const languageCompartmentRef = useRef<Compartment | null>(null);
const { appTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [error, setError] = useState<string | undefined>();
const isUpdatingRef = useRef(false);
const validateContent = (content: string) => {
if (!content.trim()) {
setIsValid(true);
setError(undefined);
onValidate?.(true);
return;
}
if (language === 'json') {
try {
JSON.parse(content);
setIsValid(true);
setError(undefined);
onValidate?.(true);
} catch (e) {
setIsValid(false);
const errorMsg =
e instanceof Error ? e.message : 'Invalid JSON syntax';
setError(errorMsg);
onValidate?.(false, errorMsg);
}
} else if (language === 'javascript') {
// No frontend validation for JavaScript - validation happens in tRPC
setIsValid(true);
setError(undefined);
onValidate?.(true);
}
};
// Create editor once on mount
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
const themeCompartment = new Compartment();
themeCompartmentRef.current = themeCompartment;
const languageCompartment = new Compartment();
languageCompartmentRef.current = languageCompartment;
const extensions: Extension[] = [
basicSetup,
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
EditorState.tabSize.of(2),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdatingRef.current = true;
const newValue = update.state.doc.toString();
onChange(newValue);
validateContent(newValue);
// Reset flag after a short delay
setTimeout(() => {
isUpdatingRef.current = false;
}, 0);
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
minHeight,
maxHeight: '400px',
},
'&.cm-editor': {
borderRadius: '6px',
border: `1px solid ${
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
}`,
overflow: 'hidden',
},
'.cm-scroller': {
minHeight,
maxHeight: '400px',
overflow: 'auto',
},
'.cm-content': {
padding: '12px 12px 12px 0',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
minHeight,
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--muted))',
borderRight: '1px solid hsl(var(--border))',
paddingLeft: '8px',
},
'.cm-lineNumbers .cm-gutterElement': {
color: 'hsl(var(--muted-foreground))',
paddingRight: '12px',
paddingLeft: '4px',
},
}),
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
];
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// Initial validation
validateContent(value);
return () => {
view.destroy();
viewRef.current = null;
themeCompartmentRef.current = null;
};
}, []); // Only create once
// Update theme using compartment
useEffect(() => {
if (!viewRef.current || !themeCompartmentRef.current) return;
viewRef.current.dispatch({
effects: themeCompartmentRef.current.reconfigure(
appTheme === 'dark' ? [oneDark] : [],
),
});
}, [appTheme]);
// Update language using compartment
useEffect(() => {
if (!viewRef.current || !languageCompartmentRef.current) return;
viewRef.current.dispatch({
effects: languageCompartmentRef.current.reconfigure(
language === 'javascript' ? [javascript()] : [json()],
),
});
validateContent(value);
}, [language, value]);
// Update editor content when value changes externally
useEffect(() => {
if (!viewRef.current || isUpdatingRef.current) return;
const currentContent = viewRef.current.state.doc.toString();
if (currentContent !== value) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
// Validate after external update
validateContent(value);
}
}, [value]);
return (
<div className={className}>
<div
ref={editorRef}
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
/>
{!isValid && (
<p className="mt-1 text-sm text-destructive">
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
</p>
)}
</div>
);
}

View File

@@ -149,7 +149,7 @@ export function useColumns() {
} }
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(event.profileId)}`} href={`/profiles/${event.profileId}`}
className="inline-flex min-w-full flex-none items-center gap-2" className="inline-flex min-w-full flex-none items-center gap-2"
> >
{event.profileId} {event.profileId}

View File

@@ -4,8 +4,7 @@ import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog'; import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react'; import { SearchIcon } from 'lucide-react';
import type React from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
@@ -107,9 +106,7 @@ export function OverviewListModal<T extends OverviewListItem>({
// Calculate totals and check for revenue // Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } = const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => { useMemo(() => {
const maxSessions = Math.max( const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
...filteredData.map((item) => item.sessions),
);
const totalRevenue = filteredData.reduce( const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0), (sum, item) => sum + (item.revenue ?? 0),
0, 0,
@@ -155,8 +152,7 @@ export function OverviewListModal<T extends OverviewListItem>({
<div <div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100" className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{ style={{
gridTemplateColumns: gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}} }}
> >
<div className="text-left truncate">{columnName}</div> <div className="text-left truncate">{columnName}</div>
@@ -208,14 +204,11 @@ export function OverviewListModal<T extends OverviewListItem>({
<div <div
className="relative grid h-full items-center px-4 border-b border-border" className="relative grid h-full items-center px-4 border-b border-border"
style={{ style={{
gridTemplateColumns: gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}} }}
> >
{/* Main content cell */} {/* Main content cell */}
<div className="min-w-0 truncate pr-2"> <div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
{renderItem(item)}
</div>
{/* Revenue cell */} {/* Revenue cell */}
{hasRevenue && ( {hasRevenue && (
@@ -268,3 +261,4 @@ export function OverviewListModal<T extends OverviewListItem>({
</ModalContent> </ModalContent>
); );
} }

View File

@@ -207,7 +207,7 @@ export function OverviewMetricCardNumber({
isLoading?: boolean; isLoading?: boolean;
}) { }) {
return ( return (
<div className={cn('min-w-0 col gap-2', className)}> <div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className="flex min-w-0 items-center gap-2 text-left"> <div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]"> <span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label} {label}
@@ -219,7 +219,7 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" /> <Skeleton className="h-6 w-12" />
</div> </div>
) : ( ) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left"> <div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value} {value}
</div> </div>
)} )}

View File

@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
); );
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const data = query.data ?? []; const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return data; return data;
} }

View File

@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return tableData; return tableData.slice(0, 15);
} }
const queryLower = searchQuery.toLowerCase(); const queryLower = searchQuery.toLowerCase();
return tableData.filter((item) => return tableData
item.name?.toLowerCase().includes(queryLower), .filter((item) => item.name?.toLowerCase().includes(queryLower))
); .slice(0, 15);
}, [tableData, searchQuery]); }, [tableData, searchQuery]);
const tabs = useMemo( const tabs = useMemo(

View File

@@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
); );
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const data = query.data ?? []; const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return data; return data;
} }

View File

@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
); );
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const data = query.data ?? []; const data = query.data?.slice(0, 15) ?? [];
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return data; return data;
} }

View File

@@ -97,7 +97,7 @@ export default function OverviewTopSources({
); );
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const data = query.data ?? []; const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return data; return data;
} }

View File

@@ -2,8 +2,7 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react'; import { ExternalLinkIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip'; import { Tooltiper } from '../ui/tooltip';
@@ -46,42 +45,6 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
); );
} }
function SortableHeader({
name,
isSorted,
sortDirection,
onClick,
isRightAligned,
}: {
name: string;
isSorted: boolean;
sortDirection: 'asc' | 'desc' | null;
onClick: () => void;
isRightAligned?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'row items-center gap-1 hover:opacity-80 transition-opacity',
isRightAligned && 'justify-end ml-auto',
)}
>
<span>{name}</span>
{isSorted ? (
sortDirection === 'desc' ? (
<ChevronDown className="size-3" />
) : (
<ChevronUp className="size-3" />
)
) : (
<ChevronDown className="size-3 opacity-30" />
)}
</button>
);
}
type Props<T> = WidgetTableProps<T> & { type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number; getColumnPercentage: (item: T) => number;
}; };
@@ -93,113 +56,10 @@ export const OverviewWidgetTable = <T,>({
getColumnPercentage, getColumnPercentage,
className, className,
}: Props<T>) => { }: Props<T>) => {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
null,
);
// Handle column header click for sorting
const handleSort = (columnName: string) => {
if (sortColumn === columnName) {
// Cycle through: desc -> asc -> null
if (sortDirection === 'desc') {
setSortDirection('asc');
} else if (sortDirection === 'asc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
// First click on a column = descending (highest to lowest)
setSortColumn(columnName);
setSortDirection('desc');
}
};
// Sort data based on current sort state
// Sort all available items, then limit display to top 15
const sortedData = useMemo(() => {
const allData = data ?? [];
if (!sortColumn || !sortDirection) {
// When not sorting, return top 15 (maintain original behavior)
return allData;
}
const column = columns.find((col) => {
if (typeof col.name === 'string') {
return col.name === sortColumn;
}
return false;
});
if (!column?.getSortValue) {
return allData;
}
// Sort all available items
const sorted = [...allData].sort((a, b) => {
const aValue = column.getSortValue!(a);
const bValue = column.getSortValue!(b);
// Handle null values
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
// Compare values
let comparison = 0;
if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return sortDirection === 'desc' ? -comparison : comparison;
});
return sorted;
}, [data, sortColumn, sortDirection, columns]).slice(0, 15);
// Create columns with sortable headers
const columnsWithSortableHeaders = useMemo(() => {
return columns.map((column, index) => {
const columnName =
typeof column.name === 'string' ? column.name : String(column.name);
const isSortable = !!column.getSortValue;
const isSorted = sortColumn === columnName;
const currentSortDirection = isSorted ? sortDirection : null;
const isRightAligned = index !== 0;
return {
...column,
// Add a key property for React keys (using the original column name string)
key: columnName,
name: isSortable ? (
<SortableHeader
name={columnName}
isSorted={isSorted}
sortDirection={currentSortDirection}
onClick={() => handleSort(columnName)}
isRightAligned={isRightAligned}
/>
) : (
column.name
),
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
});
}, [columns, sortColumn, sortDirection]);
return ( return (
<div className={cn(className)}> <div className={cn(className)}>
<WidgetTable <WidgetTable
data={sortedData} data={data ?? []}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'} className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4" columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
@@ -215,7 +75,18 @@ export const OverviewWidgetTable = <T,>({
</div> </div>
); );
}} }}
columns={columnsWithSortableHeaders} columns={columns.map((column, index) => {
return {
...column,
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
})}
/> />
</div> </div>
); );
@@ -337,8 +208,6 @@ export function OverviewWidgetTablePages({
name: 'Revenue', name: 'Revenue',
width: '100px', width: '100px',
responsive: { priority: 3 }, // Always show if possible responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) { render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0; const revenue = item.revenue ?? 0;
const revenuePercentage = const revenuePercentage =
@@ -362,7 +231,6 @@ export function OverviewWidgetTablePages({
name: 'Views', name: 'Views',
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.pageviews,
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -377,7 +245,6 @@ export function OverviewWidgetTablePages({
name: 'Sess.', name: 'Sess.',
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -472,8 +339,6 @@ export function OverviewWidgetTableEntries({
name: 'Revenue', name: 'Revenue',
width: '100px', width: '100px',
responsive: { priority: 3 }, // Always show if possible responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) { render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0; const revenue = item.revenue ?? 0;
const revenuePercentage = const revenuePercentage =
@@ -497,7 +362,6 @@ export function OverviewWidgetTableEntries({
name: lastColumnName, name: lastColumnName,
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -630,9 +494,6 @@ export function OverviewWidgetTableGeneric({
name: 'Revenue', name: 'Revenue',
width: '100px', width: '100px',
responsive: { priority: 3 }, responsive: { priority: 3 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.revenue ?? 0,
render(item: RouterOutputs['overview']['topGeneric'][number]) { render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0; const revenue = item.revenue ?? 0;
const revenuePercentage = const revenuePercentage =
@@ -660,9 +521,6 @@ export function OverviewWidgetTableGeneric({
name: 'Views', name: 'Views',
width: '84px', width: '84px',
responsive: { priority: 2 }, responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.pageviews,
render(item: RouterOutputs['overview']['topGeneric'][number]) { render(item: RouterOutputs['overview']['topGeneric'][number]) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -679,9 +537,6 @@ export function OverviewWidgetTableGeneric({
name: 'Sess.', name: 'Sess.',
width: '84px', width: '84px',
responsive: { priority: 2 }, responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.sessions,
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -744,7 +599,6 @@ export function OverviewWidgetTableEvents({
name: 'Count', name: 'Count',
width: '84px', width: '84px',
responsive: { priority: 2 }, responsive: { priority: 2 },
getSortValue: (item: EventTableItem) => item.count,
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">

View File

@@ -7,7 +7,6 @@ import {
format, format,
formatISO, formatISO,
isSameMonth, isSameMonth,
isToday,
startOfMonth, startOfMonth,
subMonths, subMonths,
} from 'date-fns'; } from 'date-fns';
@@ -19,26 +18,15 @@ import {
WidgetTitle, WidgetTitle,
} from '../overview/overview-widget'; } from '../overview/overview-widget';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
type Props = { type Props = {
data: { count: number; date: string }[]; data: { count: number; date: string }[];
}; };
function getOpacityLevel(count: number, maxCount: number): number {
if (count === 0 || maxCount === 0) return 0;
const ratio = count / maxCount;
if (ratio <= 0.25) return 0.25;
if (ratio <= 0.5) return 0.5;
if (ratio <= 0.75) return 0.75;
return 1;
}
const MonthCalendar = ({ const MonthCalendar = ({
month, month,
data, data,
maxCount, }: { month: Date; data: Props['data'] }) => (
}: { month: Date; data: Props['data']; maxCount: number }) => (
<div> <div>
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div> <div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1"> <div className="-m-1 grid grid-cols-7 gap-1 p-1">
@@ -49,42 +37,14 @@ const MonthCalendar = ({
const hit = data.find((item) => const hit = data.find((item) =>
item.date.includes(formatISO(date, { representation: 'date' })), item.date.includes(formatISO(date, { representation: 'date' })),
); );
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
return ( return (
<Tooltiper <div
key={date.toISOString()} key={date.toISOString()}
asChild className={cn(
content={ 'aspect-square w-full rounded',
<div className="text-sm col gap-1"> hit ? 'bg-highlight' : 'bg-def-200',
<div className="font-medium">{format(date, 'EEEE, MMM d')}</div> )}
{hit ? ( />
<div className="text-muted-foreground">
{hit.count} {hit.count === 1 ? 'event' : 'events'}
</div>
) : (
<div className="text-muted-foreground">No activity</div>
)}
</div>
}
>
<div
className={cn(
'aspect-square w-full rounded cursor-default group hover:ring-1 hover:ring-foreground overflow-hidden',
)}
>
<div
className={cn(
'size-full group-hover:shadow-[inset_0_0_0_2px_var(--background)] rounded',
isToday(date)
? 'bg-highlight'
: hit
? 'bg-foreground'
: 'bg-def-200',
)}
style={hit && !isToday(date) ? { opacity } : undefined}
/>
</div>
</Tooltiper>
); );
})} })}
</div> </div>
@@ -93,7 +53,6 @@ const MonthCalendar = ({
export const ProfileActivity = ({ data }: Props) => { export const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date())); const [startDate, setStartDate] = useState(startOfMonth(new Date()));
const maxCount = Math.max(...data.map((item) => item.count), 0);
return ( return (
<Widget className="w-full"> <Widget className="w-full">
@@ -124,7 +83,6 @@ export const ProfileActivity = ({ data }: Props) => {
key={offset} key={offset}
month={subMonths(startDate, offset)} month={subMonths(startDate, offset)}
data={data} data={data}
maxCount={maxCount}
/> />
))} ))}
</div> </div>

View File

@@ -20,7 +20,7 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original; const profile = row.original;
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
title={getProfileName(profile, false)} title={getProfileName(profile, false)}
> >

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportAreaChart() { export function ReportAreaChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions( trpc.chart.chart.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportBarChart() { export function ReportBarChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.aggregate.queryOptions( trpc.chart.aggregate.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
@@ -13,11 +14,18 @@ import { Summary } from './summary';
export function ReportConversionChart() { export function ReportConversionChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery( const res = useQuery(
trpc.chart.conversion.queryOptions( trpc.chart.conversion.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportHistogramChart() { export function ReportHistogramChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions( trpc.chart.chart.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,8 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -11,11 +13,18 @@ import { Chart } from './chart';
export function ReportLineChart() { export function ReportLineChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions( trpc.chart.chart.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportMapChart() { export function ReportMapChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions( trpc.chart.chart.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -10,12 +11,18 @@ import { Chart } from './chart';
export function ReportMetricChart() { export function ReportMetricChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions( trpc.chart.chart.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportPieChart() { export function ReportPieChart() {
const { isLazyLoading, report, shareId } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.aggregate.queryOptions( trpc.chart.aggregate.queryOptions(
{ {
...report, ...report,
shareId, shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -53,7 +53,7 @@ export function ReportRetentionChart() {
criteria, criteria,
interval: overviewInterval ?? interval, interval: overviewInterval ?? interval,
shareId, shareId,
id, reportId: id,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -62,7 +62,7 @@ export function useColumns() {
if (session.profile) { if (session.profile) {
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(session.profile.id)}`} href={`/profiles/${session.profile.id}`}
className="font-medium" className="font-medium"
> >
{getProfileName(session.profile)} {getProfileName(session.profile)}
@@ -71,7 +71,7 @@ export function useColumns() {
} }
return ( return (
<ProjectLink <ProjectLink
href={`/profiles/${encodeURIComponent(session.profileId)}`} href={`/profiles/${session.profileId}`}
className="font-mono font-medium" className="font-mono font-medium"
> >
{session.profileId} {session.profileId}

View File

@@ -29,14 +29,6 @@ export interface Props<T> {
* If not provided, column is always visible. * If not provided, column is always visible.
*/ */
responsive?: ColumnResponsive; responsive?: ColumnResponsive;
/**
* Function to extract sortable value. If provided, header becomes clickable.
*/
getSortValue?: (item: T) => number | string | null;
/**
* Optional key for React keys. If not provided, will try to extract from name or use index.
*/
key?: string;
}[]; }[];
keyExtractor: (item: T) => string; keyExtractor: (item: T) => string;
data: T[]; data: T[];
@@ -185,16 +177,9 @@ export function WidgetTable<T>({
dataAttrs['data-min-width'] = String(column.responsive.minWidth); dataAttrs['data-min-width'] = String(column.responsive.minWidth);
} }
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return ( return (
<div <div
key={columnKey} key={column.name?.toString()}
className={cn( className={cn(
'p-2 font-medium font-sans text-sm whitespace-nowrap cell', 'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
columns.length > 1 && column !== columns[0] columns.length > 1 && column !== columns[0]
@@ -246,16 +231,9 @@ export function WidgetTable<T>({
); );
} }
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return ( return (
<div <div
key={columnKey} key={column.name?.toString()}
className={cn( className={cn(
'px-2 relative cell', 'px-2 relative cell',
columns.length > 1 && column !== columns[0] columns.length > 1 && column !== columns[0]

View File

@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
{profile && ( {profile && (
<ProjectLink <ProjectLink
onClick={() => popModal()} onClick={() => popModal()}
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${profile.id}`}
className="card p-4 py-2 col gap-2 hover:bg-def-100" className="card p-4 py-2 col gap-2 hover:bg-def-100"
> >
<div className="row items-center gap-2 justify-between"> <div className="row items-center gap-2 justify-between">

View File

@@ -25,7 +25,7 @@ const ProfileItem = ({ profile }: { profile: any }) => {
return ( return (
<ProjectLink <ProjectLink
preload={false} preload={false}
href={`/profiles/${encodeURIComponent(profile.id)}`} href={`/profiles/${profile.id}`}
title={getProfileName(profile, false)} title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card" className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => { onClick={(e) => {

View File

@@ -20,7 +20,6 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as WidgetTestRouteImport } from './routes/widget/test' import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime' import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
import { Route as WidgetCounterRouteImport } from './routes/widget/counter' import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
import { Route as WidgetBadgeRouteImport } from './routes/widget/badge'
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck' import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
import { Route as ApiConfigRouteImport } from './routes/api/config' import { Route as ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding' import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -151,11 +150,6 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
path: '/widget/counter', path: '/widget/counter',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
id: '/widget/badge',
path: '/widget/badge',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({ const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
id: '/api/healthcheck', id: '/api/healthcheck',
path: '/api/healthcheck', path: '/api/healthcheck',
@@ -573,7 +567,6 @@ export interface FileRoutesByFullPath {
'/onboarding': typeof PublicOnboardingRoute '/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute '/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute '/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute '/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute '/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute '/widget/test': typeof WidgetTestRoute
@@ -643,7 +636,6 @@ export interface FileRoutesByTo {
'/onboarding': typeof PublicOnboardingRoute '/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute '/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute '/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute '/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute '/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute '/widget/test': typeof WidgetTestRoute
@@ -710,7 +702,6 @@ export interface FileRoutesById {
'/_public/onboarding': typeof PublicOnboardingRoute '/_public/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute '/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute '/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute '/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute '/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute '/widget/test': typeof WidgetTestRoute
@@ -791,7 +782,6 @@ export interface FileRouteTypes {
| '/onboarding' | '/onboarding'
| '/api/config' | '/api/config'
| '/api/healthcheck' | '/api/healthcheck'
| '/widget/badge'
| '/widget/counter' | '/widget/counter'
| '/widget/realtime' | '/widget/realtime'
| '/widget/test' | '/widget/test'
@@ -861,7 +851,6 @@ export interface FileRouteTypes {
| '/onboarding' | '/onboarding'
| '/api/config' | '/api/config'
| '/api/healthcheck' | '/api/healthcheck'
| '/widget/badge'
| '/widget/counter' | '/widget/counter'
| '/widget/realtime' | '/widget/realtime'
| '/widget/test' | '/widget/test'
@@ -927,7 +916,6 @@ export interface FileRouteTypes {
| '/_public/onboarding' | '/_public/onboarding'
| '/api/config' | '/api/config'
| '/api/healthcheck' | '/api/healthcheck'
| '/widget/badge'
| '/widget/counter' | '/widget/counter'
| '/widget/realtime' | '/widget/realtime'
| '/widget/test' | '/widget/test'
@@ -1007,7 +995,6 @@ export interface RootRouteChildren {
UnsubscribeRoute: typeof UnsubscribeRoute UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetBadgeRoute: typeof WidgetBadgeRoute
WidgetCounterRoute: typeof WidgetCounterRoute WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute WidgetRealtimeRoute: typeof WidgetRealtimeRoute
WidgetTestRoute: typeof WidgetTestRoute WidgetTestRoute: typeof WidgetTestRoute
@@ -1081,13 +1068,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WidgetCounterRouteImport preLoaderRoute: typeof WidgetCounterRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/widget/badge': {
id: '/widget/badge'
path: '/widget/badge'
fullPath: '/widget/badge'
preLoaderRoute: typeof WidgetBadgeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/healthcheck': { '/api/healthcheck': {
id: '/api/healthcheck' id: '/api/healthcheck'
path: '/api/healthcheck' path: '/api/healthcheck'
@@ -2025,7 +2005,6 @@ const rootRouteChildren: RootRouteChildren = {
UnsubscribeRoute: UnsubscribeRoute, UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute, ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute, ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetBadgeRoute: WidgetBadgeRoute,
WidgetCounterRoute: WidgetCounterRoute, WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute, WidgetRealtimeRoute: WidgetRealtimeRoute,
WidgetTestRoute: WidgetTestRoute, WidgetTestRoute: WidgetTestRoute,

View File

@@ -107,11 +107,6 @@ function Component() {
isToggling={toggleMutation.isPending} isToggling={toggleMutation.isPending}
onToggle={(enabled) => handleToggle('counter', enabled)} onToggle={(enabled) => handleToggle('counter', enabled)}
/> />
<BadgeWidgetSection
widget={counterWidget as any}
dashboardUrl={dashboardUrl}
/>
</div> </div>
); );
} }
@@ -374,96 +369,3 @@ function CounterWidgetSection({
</Widget> </Widget>
); );
} }
interface BadgeWidgetSectionProps {
widget: {
id: string;
public: boolean;
} | null;
dashboardUrl: string;
}
function BadgeWidgetSection({ widget, dashboardUrl }: BadgeWidgetSectionProps) {
const isEnabled = widget?.public ?? false;
const badgeUrl =
isEnabled && widget?.id
? `${dashboardUrl}/widget/badge?shareId=${widget.id}`
: null;
const badgeEmbedCode = badgeUrl
? `<a href="https://openpanel.dev" style="display: inline-block; overflow: hidden; border-radius: 8px;">
<iframe src="${badgeUrl}" height="48" width="250" style="border: none; overflow: hidden; pointer-events: none;" title="OpenPanel Analytics Badge"></iframe>
</a>`
: null;
if (!isEnabled || !badgeUrl) {
return null;
}
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="row items-center justify-between gap-6">
<div className="space-y-2">
<span className="title">Analytics Badge</span>
<p className="text-muted-foreground">
A Product Hunt-style badge showing your 30-day unique visitor count.
Perfect for showcasing your analytics powered by OpenPanel.
</p>
</div>
</WidgetHead>
<WidgetBody className="space-y-6">
<div className="space-y-2">
<h3 className="text-sm font-medium">Widget URL</h3>
<CopyInput label="" value={badgeUrl} className="w-full" />
<p className="text-xs text-muted-foreground">
Direct link to the analytics badge widget.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Embed Code</h3>
<Syntax code={badgeEmbedCode!} language="bash" />
<p className="text-xs text-muted-foreground">
Copy this code and paste it into your website HTML where you want
the badge to appear.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Preview</h3>
<div className="border rounded-lg p-4 bg-muted/30">
<a
href="https://openpanel.dev"
target="_blank"
rel="noopener noreferrer"
style={{
overflow: 'hidden',
borderRadius: '8px',
display: 'inline-block',
}}
>
<iframe
src={badgeUrl}
height="48"
width="250"
className="border-0 pointer-events-none"
title="Analytics Badge Preview"
/>
</a>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
icon={ExternalLinkIcon}
onClick={() =>
window.open(badgeUrl, '_blank', 'noopener,noreferrer')
}
>
Open in new tab
</Button>
</div>
</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -4,7 +4,6 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar'; import { LoginNavbar } from '@/components/login-navbar';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range'; import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart'; import { ReportChart } from '@/components/report-chart';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
@@ -64,7 +63,6 @@ function RouteComponent() {
const { shareId } = Route.useParams(); const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/report/$shareId' }); const { header } = useSearch({ from: '/share/report/$shareId' });
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const shareQuery = useSuspenseQuery( const shareQuery = useSuspenseQuery(
trpc.share.report.queryOptions({ trpc.share.report.queryOptions({
shareId, shareId,
@@ -83,6 +81,8 @@ function RouteComponent() {
const share = shareQuery.data; const share = shareQuery.data;
console.log('share', share);
// Handle password protection // Handle password protection
if (share.password && !hasAccess) { if (share.password && !hasAccess) {
return <ShareEnterPassword shareId={share.id} shareType="report" />; return <ShareEnterPassword shareId={share.id} shareType="report" />;
@@ -114,16 +114,7 @@ function RouteComponent() {
<div className="font-medium text-xl">{share.report.name}</div> <div className="font-medium text-xl">{share.report.name}</div>
</div> </div>
<div className="p-4"> <div className="p-4">
<ReportChart <ReportChart report={share.report} shareId={shareId} />
report={{
...share.report,
range: range ?? share.report.range,
startDate: startDate ?? share.report.startDate,
endDate: endDate ?? share.report.endDate,
interval: interval ?? share.report.interval,
}}
shareId={shareId}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,76 +0,0 @@
import { LogoSquare } from '@/components/logo';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import { z } from 'zod';
const widgetSearchSchema = z.object({
shareId: z.string(),
color: z.string().optional(),
});
export const Route = createFileRoute('/widget/badge')({
component: RouteComponent,
validateSearch: widgetSearchSchema,
});
function RouteComponent() {
const { shareId, color } = Route.useSearch();
const trpc = useTRPC();
// Fetch widget data
const { data, isLoading } = useQuery(
trpc.widget.badge.queryOptions({ shareId }),
);
if (isLoading) {
return <BadgeWidget visitors={0} isLoading color={color} />;
}
if (!data) {
return <BadgeWidget visitors={0} color={color} />;
}
return <BadgeWidget visitors={data.visitors} color={color} />;
}
interface BadgeWidgetProps {
visitors: number;
isLoading?: boolean;
color?: string;
}
function BadgeWidget({ visitors, isLoading, color }: BadgeWidgetProps) {
const number = useNumber();
return (
<div
className="absolute inset-0 group inline-flex items-center gap-3 rounded-lg center-center px-2"
style={{
backgroundColor: color,
}}
>
{/* Logo on the left */}
<div className="flex-shrink-0">
<LogoSquare className="h-8 w-8" />
</div>
{/* Center text */}
<div className="flex flex-col gap-0.5 flex-1 min-w-0 items-start -mt-px">
<div className="text-[10px] font-medium uppercase tracking-wide text-white/80">
ANALYTICS FROM
</div>
<div className="font-semibold text-white leading-tight">OpenPanel</div>
</div>
{/* Visitor count on the right */}
<div className="col center-center flex-shrink-0 gap-1">
<UsersIcon className="size-4 text-white" />
<div className="text-sm font-medium text-white tabular-nums">
{isLoading ? <span>...</span> : number.short(visitors)}
</div>
</div>
</div>
);
}

View File

@@ -36,11 +36,9 @@ COPY packages/queue/package.json ./packages/queue/
COPY packages/logger/package.json ./packages/logger/ COPY packages/logger/package.json ./packages/logger/
COPY packages/common/package.json ./packages/common/ COPY packages/common/package.json ./packages/common/
COPY packages/importer/package.json ./packages/importer/ COPY packages/importer/package.json ./packages/importer/
COPY packages/payments/package.json ./packages/payments/
COPY packages/constants/package.json ./packages/constants/ COPY packages/constants/package.json ./packages/constants/
COPY packages/validation/package.json ./packages/validation/ COPY packages/validation/package.json ./packages/validation/
COPY packages/integrations/package.json ./packages/integrations/ COPY packages/integrations/package.json packages/integrations/
COPY packages/js-runtime/package.json ./packages/js-runtime/
COPY patches ./patches COPY patches ./patches
# BUILD # BUILD
@@ -87,11 +85,8 @@ COPY --from=build /app/packages/queue ./packages/queue
COPY --from=build /app/packages/logger ./packages/logger COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/importer ./packages/importer COPY --from=build /app/packages/importer ./packages/importer
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen RUN pnpm db:codegen

View File

@@ -17,7 +17,6 @@
"@openpanel/db": "workspace:*", "@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*", "@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^", "@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*",
"@openpanel/json": "workspace:*", "@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*", "@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*", "@openpanel/importer": "workspace:*",

View File

@@ -1,6 +1,10 @@
import { logger as baseLogger } from '@/utils/logger'; import { logger as baseLogger } from '@/utils/logger';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler'; import {
import { getTime, isSameDomain, parsePath } from '@openpanel/common'; createSessionEndJob,
createSessionStart,
getSessionEnd,
} from '@/utils/session-handler';
import { isSameDomain, parsePath } from '@openpanel/common';
import { import {
getReferrerWithQuery, getReferrerWithQuery,
parseReferrer, parseReferrer,
@@ -189,14 +193,7 @@ export async function incomingEvent(
if (!sessionEnd) { if (!sessionEnd) {
logger.info('Creating session start event', { event: payload }); logger.info('Creating session start event', { event: payload });
await createEventAndNotify( await createSessionStart({ payload }).catch((error) => {
{
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
).catch((error) => {
logger.error('Error creating session start event', { event: payload }); logger.error('Error creating session start event', { event: payload });
throw error; throw error;
}); });

View File

@@ -1,22 +1,11 @@
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { Prisma, db } from '@openpanel/db'; import { db } from '@openpanel/db';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord'; import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack'; import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime'; import { setSuperJson } from '@openpanel/json';
import type { NotificationQueuePayload } from '@openpanel/queue'; import type { NotificationQueuePayload } from '@openpanel/queue';
import { publishEvent } from '@openpanel/redis'; import { getRedisPub, publishEvent } from '@openpanel/redis';
function isValidJson<T>(
value: T | Prisma.NullableJsonNullValueInput | null | undefined,
): value is T {
return (
value !== null &&
value !== undefined &&
value !== Prisma.JsonNull &&
value !== Prisma.DbNull
);
}
export async function notificationJob(job: Job<NotificationQueuePayload>) { export async function notificationJob(job: Job<NotificationQueuePayload>) {
switch (job.data.type) { switch (job.data.type) {
@@ -25,10 +14,12 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
if (notification.sendToApp) { if (notification.sendToApp) {
publishEvent('notification', 'created', notification); publishEvent('notification', 'created', notification);
// empty for now
return; return;
} }
if (notification.sendToEmail) { if (notification.sendToEmail) {
// empty for now
return; return;
} }
@@ -42,44 +33,18 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
}, },
}); });
const payload = notification.payload;
if (!isValidJson(payload)) {
return new Error('Invalid payload');
}
switch (integration.config.type) { switch (integration.config.type) {
case 'webhook': { case 'webhook': {
let body: unknown;
if (integration.config.mode === 'javascript') {
// We only transform event payloads for now (not funnel)
if (
integration.config.javascriptTemplate &&
payload.type === 'event'
) {
const result = executeJavaScriptTemplate(
integration.config.javascriptTemplate,
payload.event,
);
body = result;
} else {
body = payload;
}
} else {
body = {
title: notification.title,
message: notification.message,
};
}
return fetch(integration.config.url, { return fetch(integration.config.url, {
method: 'POST', method: 'POST',
headers: { headers: {
...(integration.config.headers ?? {}), ...(integration.config.headers ?? {}),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(body), body: JSON.stringify({
title: notification.title,
message: notification.message,
}),
}); });
} }
case 'discord': { case 'discord': {

View File

@@ -12,6 +12,18 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
const getSessionEndJobId = (projectId: string, deviceId: string) => const getSessionEndJobId = (projectId: string, deviceId: string) =>
`sessionEnd:${projectId}:${deviceId}`; `sessionEnd:${projectId}:${deviceId}`;
export async function createSessionStart({
payload,
}: {
payload: IServiceCreateEventPayload;
}) {
return createEvent({
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
});
}
export async function createSessionEndJob({ export async function createSessionEndJob({
payload, payload,
}: { }: {

View File

@@ -109,9 +109,7 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
interval: zTimeInterval, interval: zTimeInterval,
}); });
export type IGetTopGenericSeriesInput = z.infer< export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
typeof zGetTopGenericSeriesInput
> & {
timezone: string; timezone: string;
}; };
@@ -736,7 +734,7 @@ export class OverviewService {
}>; }>;
}> { }> {
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null; const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
const TOP_LIMIT = 500; const TOP_LIMIT = 15;
const fillConfig = this.getFillConfig(interval, startDate, endDate); const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Step 1: Get top 15 items // Step 1: Get top 15 items

View File

@@ -1,6 +1,5 @@
import { Button as EmailButton } from '@react-email/components'; import { Button as EmailButton } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation> import type * as React from 'react';
import React from 'react';
export function Button({ export function Button({
href, href,

View File

@@ -7,8 +7,7 @@ import {
Section, Section,
Tailwind, Tailwind,
} from '@react-email/components'; } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation> import type React from 'react';
import React from 'react';
import { Footer } from './footer'; import { Footer } from './footer';
type Props = { type Props = {

View File

@@ -1,6 +1,4 @@
import { Text } from '@react-email/components'; import { Text } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
export function List({ items }: { items: React.ReactNode[] }) { export function List({ items }: { items: React.ReactNode[] }) {
return ( return (

View File

@@ -1 +0,0 @@
export * from './src/index';

View File

@@ -1,21 +0,0 @@
{
"name": "@openpanel/js-runtime",
"version": "0.0.1",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
},
"scripts": {
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@babel/parser": "^7.26.0"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"typescript": "catalog:",
"vitest": "^2.1.8"
}
}

View File

@@ -1,165 +0,0 @@
import { ALLOWED_GLOBALS, ALLOWED_INSTANCE_METHODS, ALLOWED_METHODS } from './constants';
/**
* Simple recursive AST walker that doesn't require @babel/traverse
*/
export function walkNode(
node: unknown,
visitor: (
node: Record<string, unknown>,
parent?: Record<string, unknown>,
) => void,
parent?: Record<string, unknown>,
): void {
if (!node || typeof node !== 'object') {
return;
}
// Handle arrays
if (Array.isArray(node)) {
for (const child of node) {
walkNode(child, visitor, parent);
}
return;
}
const nodeObj = node as Record<string, unknown>;
// Only visit AST nodes (they have a 'type' property)
if (typeof nodeObj.type === 'string') {
visitor(nodeObj, parent);
}
// Recursively walk all properties
for (const key of Object.keys(nodeObj)) {
const value = nodeObj[key];
if (value && typeof value === 'object') {
walkNode(value, visitor, nodeObj);
}
}
}
/**
* Track declared variables/parameters to know what identifiers are "local"
*/
export function collectDeclaredIdentifiers(ast: unknown): Set<string> {
const declared = new Set<string>();
walkNode(ast, (node) => {
// Variable declarations: const x = ..., let y = ..., var z = ...
if (node.type === 'VariableDeclarator') {
const id = node.id as Record<string, unknown>;
if (id.type === 'Identifier') {
declared.add(id.name as string);
}
// Handle destructuring patterns
if (id.type === 'ObjectPattern') {
collectPatternIdentifiers(id, declared);
}
if (id.type === 'ArrayPattern') {
collectPatternIdentifiers(id, declared);
}
}
// Function parameters
if (
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration'
) {
const params = node.params as Array<Record<string, unknown>>;
for (const param of params) {
collectPatternIdentifiers(param, declared);
}
}
});
return declared;
}
/**
* Collect identifiers from destructuring patterns
*/
function collectPatternIdentifiers(
pattern: Record<string, unknown>,
declared: Set<string>,
): void {
if (pattern.type === 'Identifier') {
declared.add(pattern.name as string);
} else if (pattern.type === 'ObjectPattern') {
const properties = pattern.properties as Array<Record<string, unknown>>;
for (const prop of properties) {
if (prop.type === 'ObjectProperty') {
collectPatternIdentifiers(
prop.value as Record<string, unknown>,
declared,
);
} else if (prop.type === 'RestElement') {
collectPatternIdentifiers(
prop.argument as Record<string, unknown>,
declared,
);
}
}
} else if (pattern.type === 'ArrayPattern') {
const elements = pattern.elements as Array<Record<string, unknown> | null>;
for (const elem of elements) {
if (elem) {
collectPatternIdentifiers(elem, declared);
}
}
} else if (pattern.type === 'RestElement') {
collectPatternIdentifiers(
pattern.argument as Record<string, unknown>,
declared,
);
} else if (pattern.type === 'AssignmentPattern') {
// Default parameter values: (x = 5) => ...
collectPatternIdentifiers(
pattern.left as Record<string, unknown>,
declared,
);
}
}
/**
* Check if an identifier is used as a property key (not a value reference)
*/
export function isPropertyKey(
node: Record<string, unknown>,
parent?: Record<string, unknown>,
): boolean {
if (!parent) return false;
// Property in object literal: { foo: value } - foo is a key
if (
parent.type === 'ObjectProperty' &&
parent.key === node &&
!parent.computed
) {
return true;
}
// Property access: obj.foo - foo is a property access, not a global reference
if (
parent.type === 'MemberExpression' &&
parent.property === node &&
!parent.computed
) {
return true;
}
// Optional chaining: obj?.foo - foo is a property access
if (
parent.type === 'OptionalMemberExpression' &&
parent.property === node &&
!parent.computed
) {
return true;
}
// Arrow function parameter used in callback: t => t.toUpperCase()
// The 't' in arrow function is already collected as declared identifier
return false;
}

View File

@@ -1,141 +0,0 @@
/**
* Allowed global identifiers - super restricted allowlist
*/
export const ALLOWED_GLOBALS = new Set([
// Basic values for comparisons
'undefined',
'null',
// Type coercion functions
'parseInt',
'parseFloat',
'isNaN',
'isFinite',
// Safe built-in objects (for static methods only)
'Math',
'Date',
'JSON',
]);
/**
* Allowed methods on built-in objects (static methods)
*/
export const ALLOWED_METHODS: Record<string, Set<string>> = {
// Math methods
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'random']),
// JSON methods
JSON: new Set(['parse', 'stringify']),
// Date static methods
Date: new Set(['now']),
};
/**
* Allowed instance methods (methods called on values, not on global objects)
* These are safe methods that can be called on any value
*/
export const ALLOWED_INSTANCE_METHODS = new Set([
// Array instance methods
'map',
'filter',
'reduce',
'reduceRight',
'forEach',
'find',
'findIndex',
'findLast',
'findLastIndex',
'some',
'every',
'includes',
'indexOf',
'lastIndexOf',
'slice',
'concat',
'join',
'flat',
'flatMap',
'sort',
'reverse',
'fill',
'at',
'with',
'toSorted',
'toReversed',
'toSpliced',
// String instance methods
'toLowerCase',
'toUpperCase',
'toLocaleLowerCase',
'toLocaleUpperCase',
'trim',
'trimStart',
'trimEnd',
'padStart',
'padEnd',
'repeat',
'replace',
'replaceAll',
'split',
'substring',
'substr',
'charAt',
'charCodeAt',
'codePointAt',
'startsWith',
'endsWith',
'match',
'matchAll',
'search',
'normalize',
'localeCompare',
// Number instance methods
'toFixed',
'toPrecision',
'toExponential',
'toLocaleString',
// Date instance methods
'getTime',
'getFullYear',
'getMonth',
'getDate',
'getDay',
'getHours',
'getMinutes',
'getSeconds',
'getMilliseconds',
'getUTCFullYear',
'getUTCMonth',
'getUTCDate',
'getUTCDay',
'getUTCHours',
'getUTCMinutes',
'getUTCSeconds',
'getUTCMilliseconds',
'getTimezoneOffset',
'toISOString',
'toJSON',
'toDateString',
'toTimeString',
'toLocaleDateString',
'toLocaleTimeString',
'valueOf',
// Object instance methods
'hasOwnProperty',
'toString',
'valueOf',
// RegExp instance methods
'test',
'exec',
// Common property access (length, etc.)
'length',
'size',
]);

View File

@@ -1,31 +0,0 @@
/**
* Executes a JavaScript function template
* @param code - JavaScript function code (arrow function or function expression)
* @param payload - Payload object to pass to the function
* @returns The result of executing the function
*/
export function execute(
code: string,
payload: Record<string, unknown>,
): unknown {
try {
// Create the function code that will be executed
// 'use strict' ensures 'this' is undefined (not global object)
const funcCode = `
'use strict';
return (${code})(payload);
`;
// Create function with safe globals in scope
const func = new Function('payload', funcCode);
// Execute the function
return func(payload);
} catch (error) {
throw new Error(
`Error executing JavaScript template: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

View File

@@ -1,2 +0,0 @@
export { validate } from './validate';
export { execute } from './execute';

View File

@@ -1,332 +0,0 @@
import { describe, expect, it } from 'vitest';
import { execute, validate } from './index';
describe('validate', () => {
describe('Valid templates', () => {
it('should accept arrow function', () => {
const result = validate('(payload) => ({ event: payload.name })');
expect(result.valid).toBe(true);
});
it('should reject function expression', () => {
// Function expressions are not allowed - only arrow functions
const result = validate(
'(function(payload) { return { event: payload.name }; })',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('arrow functions');
});
it('should accept complex transformations', () => {
const code = `(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString()
})`;
const result = validate(code);
expect(result.valid).toBe(true);
});
it('should accept array operations', () => {
const code = `(payload) => ({
tags: payload.tags?.map(t => t.toUpperCase()) || []
})`;
const result = validate(code);
expect(result.valid).toBe(true);
});
});
describe('Blocked operations', () => {
it('should block fetch calls', () => {
const result = validate('(payload) => fetch("https://evil.com")');
expect(result.valid).toBe(false);
expect(result.error).toContain('fetch');
});
it('should block XMLHttpRequest', () => {
const result = validate('(payload) => new XMLHttpRequest()');
expect(result.valid).toBe(false);
expect(result.error).toContain('XMLHttpRequest');
});
it('should block require calls', () => {
const result = validate('(payload) => require("fs")');
expect(result.valid).toBe(false);
expect(result.error).toContain('require');
});
it('should block import statements', () => {
const result = validate('import fs from "fs"; (payload) => ({})');
expect(result.valid).toBe(false);
// Import statements cause multiple statements error first
expect(result.error).toContain('single function');
});
it('should block eval calls', () => {
const result = validate('(payload) => eval("evil")');
expect(result.valid).toBe(false);
expect(result.error).toContain('eval');
});
it('should block setTimeout', () => {
const result = validate('(payload) => setTimeout(() => {}, 1000)');
expect(result.valid).toBe(false);
expect(result.error).toContain('setTimeout');
});
it('should block process access', () => {
const result = validate('(payload) => process.env.SECRET');
expect(result.valid).toBe(false);
expect(result.error).toContain('process');
});
it('should block while loops', () => {
const result = validate(
'(payload) => { while(true) {} return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('Loops are not allowed');
});
it('should block for loops', () => {
const result = validate(
'(payload) => { for(let i = 0; i < 10; i++) {} return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('Loops are not allowed');
});
it('should block try/catch', () => {
const result = validate(
'(payload) => { try { return payload; } catch(e) {} }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('try/catch');
});
it('should block async/await', () => {
const result = validate(
'async (payload) => { await something(); return payload; }',
);
expect(result.valid).toBe(false);
expect(result.error).toContain('async/await');
});
it('should block classes', () => {
const result = validate('(payload) => { class Foo {} return payload; }');
expect(result.valid).toBe(false);
expect(result.error).toContain('Class');
});
it('should block new Array()', () => {
const result = validate('(payload) => new Array(10)');
expect(result.valid).toBe(false);
expect(result.error).toContain('new Array()');
});
it('should block new Object()', () => {
const result = validate('(payload) => new Object()');
expect(result.valid).toBe(false);
expect(result.error).toContain('new Object()');
});
it('should allow new Date()', () => {
const result = validate(
'(payload) => ({ timestamp: new Date().toISOString() })',
);
expect(result.valid).toBe(true);
});
});
describe('Invalid syntax', () => {
it('should reject non-function code', () => {
const result = validate('const x = 1;');
expect(result.valid).toBe(false);
expect(result.error).toContain('function');
});
it('should reject invalid JavaScript', () => {
const result = validate('(payload) => { invalid syntax }');
expect(result.valid).toBe(false);
expect(result.error).toContain('Parse error');
});
});
});
describe('execute', () => {
const basePayload = {
name: 'page_view',
profileId: 'user-123',
country: 'US',
city: 'New York',
device: 'desktop',
os: 'Windows',
browser: 'Chrome',
longitude: -73.935242,
latitude: 40.73061,
createdAt: '2024-01-15T10:30:00Z',
properties: {
plan: 'premium',
userId: 'user-456',
metadata: {
source: 'web',
campaign: 'summer-sale',
},
},
profile: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
},
tags: ['tag1', 'tag2'],
};
describe('Basic transformations', () => {
it('should execute simple arrow function', () => {
const code = '(payload) => ({ event: payload.name })';
const result = execute(code, basePayload);
expect(result).toEqual({ event: 'page_view' });
});
it('should access nested properties', () => {
const code = '(payload) => ({ plan: payload.properties.plan })';
const result = execute(code, basePayload);
expect(result).toEqual({ plan: 'premium' });
});
it('should handle multiple properties', () => {
const code = `(payload) => ({
event: payload.name,
user: payload.profileId,
location: payload.city
})`;
const result = execute(code, basePayload);
expect(result).toEqual({
event: 'page_view',
user: 'user-123',
location: 'New York',
});
});
});
describe('Date operations', () => {
it('should format dates', () => {
const code = `(payload) => ({
timestamp: new Date(payload.createdAt).toISOString()
})`;
const result = execute(code, basePayload);
expect(result).toHaveProperty('timestamp');
expect((result as { timestamp: string }).timestamp).toBe(
'2024-01-15T10:30:00.000Z',
);
});
});
describe('Array operations', () => {
it('should transform arrays', () => {
const code = `(payload) => ({
tags: payload.tags?.map(t => t.toUpperCase()) || []
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ tags: ['TAG1', 'TAG2'] });
});
it('should filter arrays', () => {
const code = `(payload) => ({
filtered: payload.tags?.filter(t => t.includes('tag1')) || []
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ filtered: ['tag1'] });
});
});
describe('String operations', () => {
it('should use template literals', () => {
const code = `(payload) => ({
location: \`\${payload.city}, \${payload.country}\`
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ location: 'New York, US' });
});
it('should use string methods', () => {
const code = `(payload) => ({
upperName: payload.name.toUpperCase()
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ upperName: 'PAGE_VIEW' });
});
});
describe('Math operations', () => {
it('should use Math functions', () => {
const code = `(payload) => ({
roundedLng: Math.round(payload.longitude)
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ roundedLng: -74 });
});
});
describe('Conditional logic', () => {
it('should handle ternary operators', () => {
const code = `(payload) => ({
plan: payload.properties?.plan || 'free'
})`;
const result = execute(code, basePayload);
expect(result).toEqual({ plan: 'premium' });
});
it('should handle if conditions', () => {
const code = `(payload) => {
const result = { event: payload.name };
if (payload.properties?.plan === 'premium') {
result.plan = 'premium';
}
return result;
}`;
const result = execute(code, basePayload);
expect(result).toEqual({ event: 'page_view', plan: 'premium' });
});
});
describe('Complex transformations', () => {
it('should handle nested object construction', () => {
const code = `(payload) => ({
event: payload.name,
user: {
id: payload.profileId,
name: \`\${payload.profile?.firstName} \${payload.profile?.lastName}\`
},
data: payload.properties,
meta: {
location: \`\${payload.city}, \${payload.country}\`,
device: payload.device
}
})`;
const result = execute(code, basePayload);
expect(result).toEqual({
event: 'page_view',
user: {
id: 'user-123',
name: 'John Doe',
},
data: basePayload.properties,
meta: {
location: 'New York, US',
device: 'desktop',
},
});
});
});
describe('Error handling', () => {
it('should handle runtime errors gracefully', () => {
const code = '(payload) => payload.nonexistent.property';
expect(() => {
execute(code, basePayload);
}).toThrow('Error executing JavaScript template');
});
});
});

View File

@@ -1,280 +0,0 @@
import { parse } from '@babel/parser';
import {
ALLOWED_GLOBALS,
ALLOWED_INSTANCE_METHODS,
ALLOWED_METHODS,
} from './constants';
import {
collectDeclaredIdentifiers,
isPropertyKey,
walkNode,
} from './ast-walker';
/**
* Validates that a JavaScript function is safe to execute
* by checking the AST for allowed operations only (allowlist approach)
*/
export function validate(code: string): {
valid: boolean;
error?: string;
} {
if (!code || typeof code !== 'string') {
return { valid: false, error: 'Code must be a non-empty string' };
}
try {
// Parse the code to AST
const ast = parse(code, {
sourceType: 'module',
allowReturnOutsideFunction: true,
plugins: ['typescript'],
});
// Validate root structure: must be exactly one function expression
const program = ast.program;
const body = program.body;
if (body.length === 0) {
return { valid: false, error: 'Code cannot be empty' };
}
if (body.length > 1) {
return {
valid: false,
error:
'Code must contain only a single function. Multiple statements are not allowed.',
};
}
const rootStatement = body[0]!;
// Must be an expression statement containing a function
if (rootStatement.type !== 'ExpressionStatement') {
if (rootStatement.type === 'VariableDeclaration') {
return {
valid: false,
error:
'Variable declarations (const, let, var) are not allowed. Use a direct function expression instead.',
};
}
if (rootStatement.type === 'FunctionDeclaration') {
return {
valid: false,
error:
'Function declarations are not allowed. Use an arrow function or function expression instead: (payload) => { ... } or function(payload) { ... }',
};
}
return {
valid: false,
error: 'Code must be a function expression or arrow function',
};
}
const rootExpression = rootStatement.expression;
if (rootExpression.type !== 'ArrowFunctionExpression') {
if (rootExpression.type === 'FunctionExpression') {
return {
valid: false,
error:
'Function expressions are not allowed. Use arrow functions instead: (payload) => { ... }',
};
}
return {
valid: false,
error: 'Code must be an arrow function, e.g.: (payload) => { ... }',
};
}
// Collect all declared identifiers (variables, parameters)
const declaredIdentifiers = collectDeclaredIdentifiers(ast);
let validationError: string | undefined;
// Walk the AST to check for allowed patterns only
walkNode(ast, (node, parent) => {
// Skip if we already found an error
if (validationError) {
return;
}
// Block import/export declarations
if (
node.type === 'ImportDeclaration' ||
node.type === 'ExportDeclaration'
) {
validationError = 'import/export statements are not allowed';
return;
}
// Block function declarations inside the function body
// (FunctionDeclaration creates a named function, not allowed)
if (node.type === 'FunctionDeclaration') {
validationError =
'Named function declarations are not allowed inside the function body.';
return;
}
// Block loops - use array methods like .map(), .filter() instead
if (
node.type === 'WhileStatement' ||
node.type === 'DoWhileStatement' ||
node.type === 'ForStatement' ||
node.type === 'ForInStatement' ||
node.type === 'ForOfStatement'
) {
validationError =
'Loops are not allowed. Use array methods like .map(), .filter(), .reduce() instead.';
return;
}
// Block advanced/dangerous features
if (node.type === 'TryStatement') {
validationError = 'try/catch statements are not allowed';
return;
}
if (node.type === 'ThrowStatement') {
validationError = 'throw statements are not allowed';
return;
}
if (node.type === 'WithStatement') {
validationError = 'with statements are not allowed';
return;
}
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
validationError = 'Class definitions are not allowed';
return;
}
if (node.type === 'AwaitExpression') {
validationError = 'async/await is not allowed';
return;
}
if (node.type === 'YieldExpression') {
validationError = 'Generators are not allowed';
return;
}
// Block 'this' keyword - arrow functions don't have their own 'this'
// but we block it entirely to prevent any scope leakage
if (node.type === 'ThisExpression') {
validationError =
"'this' keyword is not allowed. Use the payload parameter instead.";
return;
}
// Check identifiers that reference globals
if (node.type === 'Identifier') {
const name = node.name as string;
// Block 'arguments' - not available in arrow functions anyway
// but explicitly block to prevent any confusion
if (name === 'arguments') {
validationError =
"'arguments' is not allowed. Use explicit parameters instead.";
return;
}
// Skip if it's a property key (not a value reference)
if (isPropertyKey(node, parent)) {
return;
}
// Skip if it's a declared local variable/parameter
if (declaredIdentifiers.has(name)) {
return;
}
// Check if it's an allowed global
if (!ALLOWED_GLOBALS.has(name)) {
validationError = `Use of '${name}' is not allowed. Only safe built-in functions are permitted.`;
return;
}
}
// Check method calls on global objects (like Math.random, JSON.parse)
// Handles both regular calls and optional chaining (?.)
if (
node.type === 'CallExpression' ||
node.type === 'OptionalCallExpression'
) {
const callee = node.callee as Record<string, unknown>;
const isMemberExpr =
callee.type === 'MemberExpression' ||
callee.type === 'OptionalMemberExpression';
if (isMemberExpr) {
const obj = callee.object as Record<string, unknown>;
const prop = callee.property as Record<string, unknown>;
const computed = callee.computed as boolean;
// Static method call on global object: Math.random(), JSON.parse()
if (
obj.type === 'Identifier' &&
prop.type === 'Identifier' &&
!computed
) {
const objName = obj.name as string;
const methodName = prop.name as string;
// Check if it's a call on an allowed global object
if (ALLOWED_GLOBALS.has(objName) && ALLOWED_METHODS[objName]) {
if (!ALLOWED_METHODS[objName].has(methodName)) {
validationError = `Method '${objName}.${methodName}' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
// Instance method call: arr.map(), str.toLowerCase(), arr?.map()
// We allow these if the method name is in ALLOWED_INSTANCE_METHODS
if (prop.type === 'Identifier' && !computed) {
const methodName = prop.name as string;
// If calling on something other than an allowed global,
// check if the method is in the allowed instance methods
if (
obj.type !== 'Identifier' ||
!ALLOWED_GLOBALS.has(obj.name as string)
) {
if (!ALLOWED_INSTANCE_METHODS.has(methodName)) {
validationError = `Method '.${methodName}()' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
}
}
// Check 'new' expressions - only allow new Date()
if (node.type === 'NewExpression') {
const callee = node.callee as Record<string, unknown>;
if (callee.type === 'Identifier') {
const name = callee.name as string;
if (name !== 'Date') {
validationError = `'new ${name}()' is not allowed. Only 'new Date()' is permitted.`;
return;
}
}
}
});
if (validationError) {
return { valid: false, error: validationError };
}
return { valid: true };
} catch (error) {
return {
valid: false,
error:
error instanceof Error
? `Parse error: ${error.message}`
: 'Unknown parse error',
};
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -1,221 +0,0 @@
import { db } from '@openpanel/db';
import { Polar } from '@polar-sh/sdk';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { getSuccessUrl } from '..';
// Register the autocomplete prompt
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
interface Answers {
isProduction: boolean;
polarApiKey: string;
productId: string;
organizationId: string;
}
async function promptForInput() {
// Get all organizations first
const organizations = await db.organization.findMany({
select: {
id: true,
name: true,
},
});
// Step 1: Collect Polar credentials first
const polarCredentials = await inquirer.prompt<{
isProduction: boolean;
polarApiKey: string;
polarOrganizationId: string;
}>([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
]);
// Step 2: Initialize Polar client and fetch products
const polar = new Polar({
accessToken: polarCredentials.polarApiKey,
server: polarCredentials.isProduction ? 'production' : 'sandbox',
});
console.log('Fetching products from Polar...');
const productsResponse = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
const products = productsResponse.result.items;
if (products.length === 0) {
throw new Error('No products found in Polar');
}
// Step 3: Continue with product selection and organization selection
const restOfAnswers = await inquirer.prompt<{
productId: string;
organizationId: string;
}>([
{
type: 'autocomplete',
name: 'productId',
message: 'Select product:',
source: (answersSoFar: any, input = '') => {
return products
.filter(
(product) =>
product.name.toLowerCase().includes(input.toLowerCase()) ||
product.id.toLowerCase().includes(input.toLowerCase()),
)
.map((product) => {
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
return {
name: `${product.name} (${priceStr})`,
value: product.id,
};
});
},
},
{
type: 'autocomplete',
name: 'organizationId',
message: 'Select organization:',
source: (answersSoFar: any, input = '') => {
return organizations
.filter(
(org) =>
org.name.toLowerCase().includes(input.toLowerCase()) ||
org.id.toLowerCase().includes(input.toLowerCase()),
)
.map((org) => ({
name: `${org.name} (${org.id})`,
value: org.id,
}));
},
},
]);
return {
...polarCredentials,
...restOfAnswers,
};
}
async function main() {
console.log('Assigning existing product to organization...');
const input = await promptForInput();
const polar = new Polar({
accessToken: input.polarApiKey,
server: input.isProduction ? 'production' : 'sandbox',
});
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
id: true,
name: true,
createdBy: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
projects: {
select: {
id: true,
},
},
},
});
if (!organization.createdBy) {
throw new Error(
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
);
}
const user = organization.createdBy;
// Fetch product details for review
const product = await polar.products.get({ id: input.productId });
const price = product.prices?.[0];
const priceStr =
price && 'priceAmount' in price && price.priceAmount
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
: 'No price';
console.log('\nReview the following settings:');
console.table({
product: product.name,
price: priceStr,
organization: organization.name,
email: user.email,
name:
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
});
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed?',
default: false,
},
]);
if (!confirmed) {
console.log('Operation canceled');
return;
}
const checkoutLink = await polar.checkoutLinks.create({
paymentProcessor: 'stripe',
productId: input.productId,
allowDiscountCodes: false,
metadata: {
organizationId: organization.id,
userId: user.id,
},
successUrl: getSuccessUrl(
input.isProduction
? 'https://dashboard.openpanel.dev'
: 'http://localhost:3000',
organization.id,
),
});
console.log('\nCheckout link created:');
console.table(checkoutLink);
console.log('\nProduct assigned successfully!');
}
main()
.catch(console.error)
.finally(() => db.$disconnect());

View File

@@ -9,7 +9,6 @@ const options: RedisOptions = {
export { Redis }; export { Redis };
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const REDIS_QUEUE_URL = process.env.REDIS_QUEUE_URL || REDIS_URL;
export interface ExtendedRedis extends Redis { export interface ExtendedRedis extends Redis {
getJson: <T = any>(key: string) => Promise<T | null>; getJson: <T = any>(key: string) => Promise<T | null>;
@@ -75,9 +74,7 @@ export function getRedisCache() {
let redisSub: ExtendedRedis; let redisSub: ExtendedRedis;
export function getRedisSub() { export function getRedisSub() {
if (!redisSub) { if (!redisSub) {
// In multi-region setup, pub/sub uses central Redis so subscribers redisSub = createRedisClient(REDIS_URL, options);
// in any region can receive events published from any region
redisSub = createRedisClient(REDIS_QUEUE_URL, options);
} }
return redisSub; return redisSub;
@@ -86,9 +83,7 @@ export function getRedisSub() {
let redisPub: ExtendedRedis; let redisPub: ExtendedRedis;
export function getRedisPub() { export function getRedisPub() {
if (!redisPub) { if (!redisPub) {
// In multi-region setup, pub/sub uses central Redis so publishers redisPub = createRedisClient(REDIS_URL, options);
// in any region can reach subscribers in any region
redisPub = createRedisClient(REDIS_QUEUE_URL, options);
} }
return redisPub; return redisPub;
@@ -98,8 +93,7 @@ let redisQueue: ExtendedRedis;
export function getRedisQueue() { export function getRedisQueue() {
if (!redisQueue) { if (!redisQueue) {
// Use different redis for queues (self-hosting will re-use the same redis instance) // Use different redis for queues (self-hosting will re-use the same redis instance)
// In multi-region setup, this points to central EU Redis for queues redisQueue = createRedisClient(REDIS_URL, {
redisQueue = createRedisClient(REDIS_QUEUE_URL, {
...options, ...options,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
@@ -114,8 +108,7 @@ let redisGroupQueue: ExtendedRedis;
export function getRedisGroupQueue() { export function getRedisGroupQueue() {
if (!redisGroupQueue) { if (!redisGroupQueue) {
// Dedicated Redis connection for GroupWorker to avoid blocking BullMQ // Dedicated Redis connection for GroupWorker to avoid blocking BullMQ
// In multi-region setup, this points to central EU Redis for queues redisGroupQueue = createRedisClient(REDIS_URL, {
redisGroupQueue = createRedisClient(REDIS_QUEUE_URL, {
...options, ...options,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,

View File

@@ -14,7 +14,6 @@
"@openpanel/db": "workspace:*", "@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*", "@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^", "@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*",
"@openpanel/payments": "workspace:^", "@openpanel/payments": "workspace:^",
"@openpanel/redis": "workspace:*", "@openpanel/redis": "workspace:*",
"@openpanel/validation": "workspace:*", "@openpanel/validation": "workspace:*",

View File

@@ -27,10 +27,10 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
type IChartEvent, type IChartEvent,
zReportInput,
zChartSeries, zChartSeries,
zCriteria, zCriteria,
zRange, zRange,
zReportInput,
zTimeInterval, zTimeInterval,
} from '@openpanel/validation'; } from '@openpanel/validation';
@@ -338,7 +338,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and( zReportInput.and(
z.object({ z.object({
shareId: z.string().optional(), shareId: z.string().optional(),
id: z.string().optional(), reportId: z.string().optional(),
}), }),
), ),
) )
@@ -347,14 +347,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) { if (input.shareId) {
// Require reportId when shareId provided // Require reportId when shareId provided
if (!input.id) { if (!input.reportId) {
throw new Error('reportId required with shareId'); throw new Error('reportId required with shareId');
} }
// Validate share access // Validate share access
const shareValidation = await validateShareAccess( const shareValidation = await validateShareAccess(
input.shareId, input.shareId,
input.id, input.reportId,
{ {
cookies: ctx.cookies, cookies: ctx.cookies,
session: ctx.session?.userId session: ctx.session?.userId
@@ -367,7 +367,7 @@ export const chartRouter = createTRPCRouter({
} }
// Fetch report and merge date overrides // Fetch report and merge date overrides
const report = await getReportById(input.id); const report = await getReportById(input.reportId);
if (!report) { if (!report) {
throw TRPCAccessError('Report not found'); throw TRPCAccessError('Report not found');
} }
@@ -420,7 +420,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and( zReportInput.and(
z.object({ z.object({
shareId: z.string().optional(), shareId: z.string().optional(),
id: z.string().optional(), reportId: z.string().optional(),
}), }),
), ),
) )
@@ -429,14 +429,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) { if (input.shareId) {
// Require reportId when shareId provided // Require reportId when shareId provided
if (!input.id) { if (!input.reportId) {
throw new Error('reportId required with shareId'); throw new Error('reportId required with shareId');
} }
// Validate share access // Validate share access
const shareValidation = await validateShareAccess( const shareValidation = await validateShareAccess(
input.shareId, input.shareId,
input.id, input.reportId,
{ {
cookies: ctx.cookies, cookies: ctx.cookies,
session: ctx.session?.userId session: ctx.session?.userId
@@ -449,7 +449,7 @@ export const chartRouter = createTRPCRouter({
} }
// Fetch report and merge date overrides // Fetch report and merge date overrides
const report = await getReportById(input.id); const report = await getReportById(input.reportId);
if (!report) { if (!report) {
throw TRPCAccessError('Report not found'); throw TRPCAccessError('Report not found');
} }
@@ -549,24 +549,23 @@ export const chartRouter = createTRPCRouter({
zReportInput.and( zReportInput.and(
z.object({ z.object({
shareId: z.string().optional(), shareId: z.string().optional(),
id: z.string().optional(), reportId: z.string().optional(),
}), }),
), ),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
let chartInput = input; let chartInput = input;
console.log('input', input);
if (input.shareId) { if (input.shareId) {
// Require reportId when shareId provided // Require reportId when shareId provided
if (!input.id) { if (!input.reportId) {
throw new Error('reportId required with shareId'); throw new Error('reportId required with shareId');
} }
// Validate share access // Validate share access
const shareValidation = await validateShareAccess( const shareValidation = await validateShareAccess(
input.shareId, input.shareId,
input.id, input.reportId,
ctx, ctx,
); );
@@ -575,7 +574,7 @@ export const chartRouter = createTRPCRouter({
} }
// Fetch report and merge date overrides // Fetch report and merge date overrides
const report = await getReportById(input.id); const report = await getReportById(input.reportId);
if (!report) { if (!report) {
throw TRPCAccessError('Report not found'); throw TRPCAccessError('Report not found');
} }
@@ -610,7 +609,7 @@ export const chartRouter = createTRPCRouter({
zReportInput.and( zReportInput.and(
z.object({ z.object({
shareId: z.string().optional(), shareId: z.string().optional(),
id: z.string().optional(), reportId: z.string().optional(),
}), }),
), ),
) )
@@ -619,14 +618,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) { if (input.shareId) {
// Require reportId when shareId provided // Require reportId when shareId provided
if (!input.id) { if (!input.reportId) {
throw new Error('reportId required with shareId'); throw new Error('reportId required with shareId');
} }
// Validate share access // Validate share access
const shareValidation = await validateShareAccess( const shareValidation = await validateShareAccess(
input.shareId, input.shareId,
input.id, input.reportId,
{ {
cookies: ctx.cookies, cookies: ctx.cookies,
session: ctx.session?.userId session: ctx.session?.userId
@@ -639,7 +638,7 @@ export const chartRouter = createTRPCRouter({
} }
// Fetch report and merge date overrides // Fetch report and merge date overrides
const report = await getReportById(input.id); const report = await getReportById(input.reportId);
if (!report) { if (!report) {
throw TRPCAccessError('Report not found'); throw TRPCAccessError('Report not found');
} }
@@ -681,7 +680,7 @@ export const chartRouter = createTRPCRouter({
interval: zTimeInterval.default('day'), interval: zTimeInterval.default('day'),
range: zRange, range: zRange,
shareId: z.string().optional(), shareId: z.string().optional(),
id: z.string().optional(), reportId: z.string().optional(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -696,14 +695,14 @@ export const chartRouter = createTRPCRouter({
if (input.shareId) { if (input.shareId) {
// Require reportId when shareId provided // Require reportId when shareId provided
if (!input.id) { if (!input.reportId) {
throw new Error('reportId required with shareId'); throw new Error('reportId required with shareId');
} }
// Validate share access // Validate share access
const shareValidation = await validateShareAccess( const shareValidation = await validateShareAccess(
input.shareId, input.shareId,
input.id, input.reportId,
{ {
cookies: ctx.cookies, cookies: ctx.cookies,
session: ctx.session?.userId session: ctx.session?.userId
@@ -716,14 +715,13 @@ export const chartRouter = createTRPCRouter({
} }
// Fetch report and extract events // Fetch report and extract events
const report = await getReportById(input.id); const report = await getReportById(input.reportId);
if (!report) { if (!report) {
throw TRPCAccessError('Report not found'); throw TRPCAccessError('Report not found');
} }
projectId = report.projectId; projectId = report.projectId;
const retentionOptions = const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
report.options?.type === 'retention' ? report.options : undefined;
criteria = retentionOptions?.criteria ?? criteria; criteria = retentionOptions?.criteria ?? criteria;
dateRange = input.range ?? report.range; dateRange = input.range ?? report.range;
startDate = input.startDate ?? report.startDate; startDate = input.startDate ?? report.startDate;

View File

@@ -10,9 +10,8 @@ import {
zCreateWebhookIntegration, zCreateWebhookIntegration,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { getOrganizationAccess } from '../access'; import { getOrganizationAccess } from '../access';
import { TRPCAccessError, TRPCBadRequestError } from '../errors'; import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc'; import { createTRPCRouter, protectedProcedure } from '../trpc';
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
get: protectedProcedure get: protectedProcedure
@@ -94,22 +93,6 @@ export const integrationRouter = createTRPCRouter({
createOrUpdate: protectedProcedure createOrUpdate: protectedProcedure
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration])) .input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
// Validate JavaScript template if mode is javascript
if (
input.config.type === 'webhook' &&
input.config.mode === 'javascript' &&
input.config.javascriptTemplate
) {
const validation = validateJavaScriptTemplate(
input.config.javascriptTemplate,
);
if (!validation.valid) {
throw TRPCBadRequestError(
`Invalid JavaScript template: ${validation.error}`,
);
}
}
if (input.id) { if (input.id) {
return db.integration.update({ return db.integration.update({
where: { where: {

View File

@@ -9,7 +9,6 @@ import {
eventBuffer, eventBuffer,
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { import {
zCounterWidgetOptions, zCounterWidgetOptions,
zRealtimeWidgetOptions, zRealtimeWidgetOptions,
@@ -145,49 +144,6 @@ export const widgetRouter = createTRPCRouter({
}; };
}), }),
badge: publicProcedure
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {
const widget = await db.shareWidget.findUnique({
where: {
id: input.shareId,
},
});
if (!widget || !widget.public) {
throw TRPCNotFoundError('Widget not found');
}
if (widget.options.type !== 'counter') {
throw TRPCNotFoundError('Invalid widget type');
}
const { projectId } = widget;
const { timezone } = await getSettingsForProject(projectId);
// Cache for 5 minutes since this queries 30 days of data
const cacheKey = `widget:badge:${projectId}`;
const visitors = await getCache(
cacheKey,
5 * 60, // 5 minutes
async () => {
const uniqueVisitorsQuery = clix(ch, timezone)
.select<{ count: number }>(['uniq(profile_id) as count'])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'));
const result = await uniqueVisitorsQuery.execute();
return result[0]?.count || 0;
},
);
return {
projectId,
visitors,
};
}),
realtimeData: publicProcedure realtimeData: publicProcedure
.input(z.object({ shareId: z.string() })) .input(z.object({ shareId: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {

View File

@@ -369,8 +369,6 @@ export const zWebhookConfig = z.object({
url: z.string().url(), url: z.string().url(),
headers: z.record(z.string()), headers: z.record(z.string()),
payload: z.record(z.string(), z.unknown()).optional(), payload: z.record(z.string(), z.unknown()).optional(),
mode: z.enum(['message', 'javascript']).default('message'),
javascriptTemplate: z.string().optional(),
}); });
export type IWebhookConfig = z.infer<typeof zWebhookConfig>; export type IWebhookConfig = z.infer<typeof zWebhookConfig>;

526
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff