Compare commits
14 Commits
dashboard
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee1463d4f | ||
|
|
2dc622cbf2 | ||
|
|
995f32c5d8 | ||
|
|
fa78e63bc8 | ||
|
|
e6d0b6544b | ||
|
|
058c3621df | ||
|
|
c2d12c556d | ||
|
|
05a2fb5846 | ||
|
|
8fd8b9319d | ||
|
|
0b5d4fa0d1 | ||
|
|
0cfccd549b | ||
|
|
289ffb7d6d | ||
|
|
90881e5ffb | ||
|
|
765e4aa107 |
@@ -63,6 +63,7 @@ async function main() {
|
|||||||
imported_at: null,
|
imported_at: null,
|
||||||
sdk_name: 'test-script',
|
sdk_name: 'test-script',
|
||||||
sdk_version: '1.0.0',
|
sdk_version: '1.0.0',
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
|||||||
import {
|
import {
|
||||||
getProfileById,
|
getProfileById,
|
||||||
getSalts,
|
getSalts,
|
||||||
|
groupBuffer,
|
||||||
replayBuffer,
|
replayBuffer,
|
||||||
upsertProfile,
|
upsertProfile,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
@@ -13,7 +14,9 @@ import {
|
|||||||
} from '@openpanel/queue';
|
} from '@openpanel/queue';
|
||||||
import { getRedisCache } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
import {
|
import {
|
||||||
|
type IAssignGroupPayload,
|
||||||
type IDecrementPayload,
|
type IDecrementPayload,
|
||||||
|
type IGroupPayload,
|
||||||
type IIdentifyPayload,
|
type IIdentifyPayload,
|
||||||
type IIncrementPayload,
|
type IIncrementPayload,
|
||||||
type IReplayPayload,
|
type IReplayPayload,
|
||||||
@@ -218,6 +221,7 @@ async function handleTrack(
|
|||||||
headers,
|
headers,
|
||||||
event: {
|
event: {
|
||||||
...payload,
|
...payload,
|
||||||
|
groups: payload.groups ?? [],
|
||||||
timestamp: timestamp.value,
|
timestamp: timestamp.value,
|
||||||
isTimestampFromThePast: timestamp.isFromPast,
|
isTimestampFromThePast: timestamp.isFromPast,
|
||||||
},
|
},
|
||||||
@@ -333,6 +337,36 @@ async function handleReplay(
|
|||||||
await replayBuffer.add(row);
|
await replayBuffer.add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGroup(
|
||||||
|
payload: IGroupPayload,
|
||||||
|
context: TrackContext
|
||||||
|
): Promise<void> {
|
||||||
|
const { id, type, name, properties = {} } = payload;
|
||||||
|
await groupBuffer.add({
|
||||||
|
id,
|
||||||
|
projectId: context.projectId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignGroup(
|
||||||
|
payload: IAssignGroupPayload,
|
||||||
|
context: TrackContext
|
||||||
|
): Promise<void> {
|
||||||
|
const profileId = payload.profileId ?? context.deviceId;
|
||||||
|
if (!profileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await upsertProfile({
|
||||||
|
id: String(profileId),
|
||||||
|
projectId: context.projectId,
|
||||||
|
isExternal: !!payload.profileId,
|
||||||
|
groups: payload.groupIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function handler(
|
export async function handler(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload;
|
Body: ITrackHandlerPayload;
|
||||||
@@ -381,6 +415,12 @@ export async function handler(
|
|||||||
case 'replay':
|
case 'replay':
|
||||||
await handleReplay(validatedBody.payload, context);
|
await handleReplay(validatedBody.payload, context);
|
||||||
break;
|
break;
|
||||||
|
case 'group':
|
||||||
|
await handleGroup(validatedBody.payload, context);
|
||||||
|
break;
|
||||||
|
case 'assign_group':
|
||||||
|
await handleAssignGroup(validatedBody.payload, context);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ app.listen(3000, () => {
|
|||||||
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
||||||
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
||||||
|
|
||||||
|
## Working with Groups
|
||||||
|
|
||||||
|
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
|
||||||
|
|
||||||
|
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post('/login', async (req, res) => {
|
||||||
|
const user = await loginUser(req.body);
|
||||||
|
|
||||||
|
// Identify the user
|
||||||
|
req.op.identify({ profileId: user.id, email: user.email });
|
||||||
|
|
||||||
|
// Create/update the group entity
|
||||||
|
req.op.upsertGroup({
|
||||||
|
id: user.organizationId,
|
||||||
|
type: 'company',
|
||||||
|
name: user.organizationName,
|
||||||
|
properties: { plan: user.plan },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign the user to the group
|
||||||
|
req.op.setGroup(user.organizationId);
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Typescript
|
## Typescript
|
||||||
|
|
||||||
If `req.op` is not typed you can extend the `Request` interface.
|
If `req.op` is not typed you can extend the `Request` interface.
|
||||||
|
|||||||
@@ -116,9 +116,38 @@ op.decrement({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Working with Groups
|
||||||
|
|
||||||
|
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||||
|
|
||||||
|
**Create or update a group:**
|
||||||
|
|
||||||
|
```js title="index.js"
|
||||||
|
import { op } from './op.ts'
|
||||||
|
|
||||||
|
op.upsertGroup({
|
||||||
|
id: 'org_acme',
|
||||||
|
type: 'company',
|
||||||
|
name: 'Acme Inc',
|
||||||
|
properties: { plan: 'enterprise' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assign the current user to a group** (call after `identify`):
|
||||||
|
|
||||||
|
```js title="index.js"
|
||||||
|
import { op } from './op.ts'
|
||||||
|
|
||||||
|
op.setGroup('org_acme');
|
||||||
|
// or multiple groups:
|
||||||
|
op.setGroups(['org_acme', 'team_eng']);
|
||||||
|
```
|
||||||
|
|
||||||
|
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||||
|
|
||||||
### Clearing User Data
|
### Clearing User Data
|
||||||
|
|
||||||
To clear the current user's data:
|
To clear the current user's data (including groups):
|
||||||
|
|
||||||
```js title="index.js"
|
```js title="index.js"
|
||||||
import { op } from './op.ts'
|
import { op } from './op.ts'
|
||||||
|
|||||||
@@ -227,9 +227,32 @@ useOpenPanel().decrement({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Working with Groups
|
||||||
|
|
||||||
|
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||||
|
|
||||||
|
**Create or update a group:**
|
||||||
|
|
||||||
|
```tsx title="app/login/page.tsx"
|
||||||
|
useOpenPanel().upsertGroup({
|
||||||
|
id: 'org_acme',
|
||||||
|
type: 'company',
|
||||||
|
name: 'Acme Inc',
|
||||||
|
properties: { plan: 'enterprise' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assign the current user to a group** (call after `identify`):
|
||||||
|
|
||||||
|
```tsx title="app/login/page.tsx"
|
||||||
|
useOpenPanel().setGroup('org_acme');
|
||||||
|
```
|
||||||
|
|
||||||
|
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||||
|
|
||||||
### Clearing User Data
|
### Clearing User Data
|
||||||
|
|
||||||
To clear the current user's data:
|
To clear the current user's data (including groups):
|
||||||
|
|
||||||
```js title="index.js"
|
```js title="index.js"
|
||||||
useOpenPanel().clear()
|
useOpenPanel().clear()
|
||||||
|
|||||||
@@ -174,9 +174,37 @@ function MyComponent() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Working with Groups
|
||||||
|
|
||||||
|
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { op } from '@/openpanel';
|
||||||
|
|
||||||
|
function LoginComponent() {
|
||||||
|
const handleLogin = async (user: User) => {
|
||||||
|
// 1. Identify the user
|
||||||
|
op.identify({ profileId: user.id, email: user.email });
|
||||||
|
|
||||||
|
// 2. Create/update the group entity (only when data changes)
|
||||||
|
op.upsertGroup({
|
||||||
|
id: user.organizationId,
|
||||||
|
type: 'company',
|
||||||
|
name: user.organizationName,
|
||||||
|
properties: { plan: user.plan },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Link the user to their group — tags all future events
|
||||||
|
op.setGroup(user.organizationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={() => handleLogin(user)}>Login</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Clearing User Data
|
### Clearing User Data
|
||||||
|
|
||||||
To clear the current user's data:
|
To clear the current user's data (including groups):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { op } from '@/openpanel';
|
import { op } from '@/openpanel';
|
||||||
|
|||||||
@@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
|
|||||||
|
|
||||||
## Insights
|
## Insights
|
||||||
|
|
||||||
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
|
A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
|
||||||
|
|
||||||
|
Each card shows:
|
||||||
|
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
|
||||||
|
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
|
||||||
|
- **Percentage change**: How much that property grew or declined relative to its own previous value
|
||||||
|
|
||||||
|
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
|
||||||
|
|
||||||
|
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
208
apps/public/content/docs/get-started/groups.mdx
Normal file
208
apps/public/content/docs/get-started/groups.mdx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
title: Groups
|
||||||
|
description: Track analytics at the account, company, or team level — not just individual users.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
|
||||||
|
|
||||||
|
This is especially useful for B2B SaaS products where a single paying account has many users.
|
||||||
|
|
||||||
|
## How Groups work
|
||||||
|
|
||||||
|
There are two separate concepts:
|
||||||
|
|
||||||
|
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
|
||||||
|
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
|
||||||
|
|
||||||
|
## Creating or updating a group
|
||||||
|
|
||||||
|
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.upsertGroup({
|
||||||
|
id: 'org_acme', // Your group's unique ID
|
||||||
|
type: 'company', // Group type (company, workspace, team, etc.)
|
||||||
|
name: 'Acme Inc', // Display name
|
||||||
|
properties: {
|
||||||
|
plan: 'enterprise',
|
||||||
|
seats: 25,
|
||||||
|
industry: 'logistics',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group payload
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | `string` | Yes | Unique identifier for the group |
|
||||||
|
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||||
|
| `name` | `string` | Yes | Human-readable display name |
|
||||||
|
| `properties` | `object` | No | Custom metadata about the group |
|
||||||
|
|
||||||
|
## Managing groups in the dashboard
|
||||||
|
|
||||||
|
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
|
||||||
|
|
||||||
|
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
|
||||||
|
|
||||||
|
<Callout>
|
||||||
|
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Assigning a user to a group
|
||||||
|
|
||||||
|
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After login
|
||||||
|
op.identify({ profileId: 'user_123' });
|
||||||
|
|
||||||
|
// Link the user to their organization
|
||||||
|
op.setGroup('org_acme');
|
||||||
|
```
|
||||||
|
|
||||||
|
For users that belong to multiple groups:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.setGroups(['org_acme', 'team_engineering']);
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout>
|
||||||
|
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Full login flow example
|
||||||
|
|
||||||
|
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Identify the user
|
||||||
|
op.identify({
|
||||||
|
profileId: 'user_123',
|
||||||
|
firstName: 'Jane',
|
||||||
|
email: 'jane@acme.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Assign the user to the group — the group doesn't need to exist yet
|
||||||
|
op.setGroup('org_acme');
|
||||||
|
|
||||||
|
// 3. All subsequent events are now tagged with the group
|
||||||
|
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
|
||||||
|
op.track('report_exported'); // → includes groups: ['org_acme']
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
|
||||||
|
|
||||||
|
// Sync group metadata from your backend
|
||||||
|
op.upsertGroup({
|
||||||
|
id: 'org_acme',
|
||||||
|
type: 'company',
|
||||||
|
name: 'Acme Inc',
|
||||||
|
properties: { plan: 'pro' },
|
||||||
|
});
|
||||||
|
|
||||||
|
op.setGroup('org_acme');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-event group override
|
||||||
|
|
||||||
|
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.track('file_shared', {
|
||||||
|
filename: 'q4-report.pdf',
|
||||||
|
groups: ['org_acme', 'org_partner'], // Only applies to this event
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
|
||||||
|
|
||||||
|
## Clearing groups on logout
|
||||||
|
|
||||||
|
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleLogout() {
|
||||||
|
op.clear();
|
||||||
|
// redirect to login...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common patterns
|
||||||
|
|
||||||
|
### B2B SaaS — company accounts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// On login
|
||||||
|
op.identify({ profileId: user.id, email: user.email });
|
||||||
|
op.upsertGroup({
|
||||||
|
id: user.organizationId,
|
||||||
|
type: 'company',
|
||||||
|
name: user.organizationName,
|
||||||
|
properties: { plan: user.plan, mrr: user.mrr },
|
||||||
|
});
|
||||||
|
op.setGroup(user.organizationId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-tenant — workspaces
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When user switches workspace
|
||||||
|
op.upsertGroup({
|
||||||
|
id: workspace.id,
|
||||||
|
type: 'workspace',
|
||||||
|
name: workspace.name,
|
||||||
|
});
|
||||||
|
op.setGroup(workspace.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Teams within a company
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// User belongs to a company and a specific team
|
||||||
|
op.setGroups([user.organizationId, user.teamId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### `upsertGroup(payload)`
|
||||||
|
|
||||||
|
Creates the group if it doesn't exist, or merges properties into the existing group.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.upsertGroup({
|
||||||
|
id: string; // Required
|
||||||
|
type: string; // Required
|
||||||
|
name: string; // Required
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### `setGroup(groupId)`
|
||||||
|
|
||||||
|
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.setGroup('org_acme');
|
||||||
|
```
|
||||||
|
|
||||||
|
### `setGroups(groupIds)`
|
||||||
|
|
||||||
|
Same as `setGroup()` but for multiple group IDs at once.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
op.setGroups(['org_acme', 'team_engineering']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to avoid
|
||||||
|
|
||||||
|
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
|
||||||
|
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
|
||||||
|
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
|
||||||
|
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"install-openpanel",
|
"install-openpanel",
|
||||||
"track-events",
|
"track-events",
|
||||||
"identify-users",
|
"identify-users",
|
||||||
|
"groups",
|
||||||
"revenue-tracking"
|
"revenue-tracking"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export function useColumns() {
|
|||||||
<span className="flex min-w-0 flex-1 gap-2">
|
<span className="flex min-w-0 flex-1 gap-2">
|
||||||
<button
|
<button
|
||||||
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
|
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
|
||||||
title={fullTitle}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pushModal('EventDetails', {
|
pushModal('EventDetails', {
|
||||||
id: row.original.id,
|
id: row.original.id,
|
||||||
@@ -84,6 +83,7 @@ export function useColumns() {
|
|||||||
projectId: row.original.projectId,
|
projectId: row.original.projectId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
title={fullTitle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="block truncate">{renderName()}</span>
|
<span className="block truncate">{renderName()}</span>
|
||||||
@@ -204,6 +204,32 @@ export function useColumns() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'groups',
|
||||||
|
header: 'Groups',
|
||||||
|
size: 200,
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
cell({ row }) {
|
||||||
|
const { groups } = row.original;
|
||||||
|
if (!groups?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<span
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||||
|
key={g}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'properties',
|
accessorKey: 'properties',
|
||||||
header: 'Properties',
|
header: 'Properties',
|
||||||
|
|||||||
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { TrendingUpIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||||
|
import {
|
||||||
|
useXAxisProps,
|
||||||
|
useYAxisProps,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Widget, WidgetBody } from '@/components/widget';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: { date: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function Tooltip(props: any) {
|
||||||
|
const number = useNumber();
|
||||||
|
const formatDate = useFormatDateInterval({ interval: 'day', short: false });
|
||||||
|
const payload = props.payload?.[0]?.payload;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{formatDate(new Date(payload.timestamp))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-10 w-1 rounded-full"
|
||||||
|
style={{ background: getChartColor(0) }}
|
||||||
|
/>
|
||||||
|
<div className="col gap-1">
|
||||||
|
<div className="text-muted-foreground text-sm">Total members</div>
|
||||||
|
<div
|
||||||
|
className="font-semibold text-lg"
|
||||||
|
style={{ color: getChartColor(0) }}
|
||||||
|
>
|
||||||
|
{number.format(payload.cumulative)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{payload.count > 0 && (
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
+{number.format(payload.count)} new
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupMemberGrowth({ data }: Props) {
|
||||||
|
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||||
|
const yAxisProps = useYAxisProps({});
|
||||||
|
const color = getChartColor(0);
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
const chartData = data.map((item) => {
|
||||||
|
cumulative += item.count;
|
||||||
|
return {
|
||||||
|
date: item.date,
|
||||||
|
timestamp: new Date(item.date).getTime(),
|
||||||
|
count: item.count,
|
||||||
|
cumulative,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const gradientId = 'memberGrowthGradient';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||||
|
No data yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-[200px] w-full">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<RechartTooltip
|
||||||
|
content={<Tooltip />}
|
||||||
|
cursor={{ stroke: color, strokeOpacity: 0.3 }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="cumulative"
|
||||||
|
dot={false}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
<YAxis {...yAxisProps} />
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal={true}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/start/src/components/groups/table/columns.tsx
Normal file
76
apps/start/src/components/groups/table/columns.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import type { IServiceGroup } from '@openpanel/db';
|
||||||
|
|
||||||
|
export type IServiceGroupWithStats = IServiceGroup & {
|
||||||
|
memberCount: number;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
|
||||||
|
const { organizationId, projectId } = useAppParams();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const group = row.original;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
params={{ organizationId, projectId, groupId: group.id }}
|
||||||
|
to="/$organizationId/$projectId/groups/$groupId"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{row.original.id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">{row.original.type}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
header: 'Members',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.memberCount}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'lastActiveAt',
|
||||||
|
header: 'Last active',
|
||||||
|
size: ColumnCreatedAt.size,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.lastActiveAt ? (
|
||||||
|
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Created',
|
||||||
|
size: ColumnCreatedAt.size,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
114
apps/start/src/components/groups/table/index.tsx
Normal file
114
apps/start/src/components/groups/table/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { IServiceGroup } from '@openpanel/db';
|
||||||
|
import type { UseQueryResult } from '@tanstack/react-query';
|
||||||
|
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||||
|
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { type IServiceGroupWithStats, useGroupColumns } from './columns';
|
||||||
|
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||||
|
import {
|
||||||
|
useDataTableColumnVisibility,
|
||||||
|
useDataTablePagination,
|
||||||
|
} from '@/components/ui/data-table/data-table-hooks';
|
||||||
|
import {
|
||||||
|
AnimatedSearchInput,
|
||||||
|
DataTableToolbarContainer,
|
||||||
|
} from '@/components/ui/data-table/data-table-toolbar';
|
||||||
|
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||||
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
query: UseQueryResult<RouterOutputs['group']['list'], unknown>;
|
||||||
|
pageSize?: number;
|
||||||
|
toolbarLeft?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroupWithStats[];
|
||||||
|
|
||||||
|
export const GroupsTable = memo(
|
||||||
|
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
|
||||||
|
const { data, isLoading } = query;
|
||||||
|
const columns = useGroupColumns();
|
||||||
|
|
||||||
|
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
||||||
|
const {
|
||||||
|
columnVisibility,
|
||||||
|
setColumnVisibility,
|
||||||
|
columnOrder,
|
||||||
|
setColumnOrder,
|
||||||
|
} = useDataTableColumnVisibility(columns, 'groups');
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: isLoading ? LOADING_DATA : (data?.data ?? []),
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
manualFiltering: true,
|
||||||
|
manualSorting: true,
|
||||||
|
columns,
|
||||||
|
rowCount: data?.meta.count,
|
||||||
|
pageCount: Math.ceil(
|
||||||
|
(data?.meta.count || 0) / (pagination.pageSize || 1)
|
||||||
|
),
|
||||||
|
filterFns: {
|
||||||
|
isWithinRange: () => true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
columnVisibility,
|
||||||
|
columnOrder,
|
||||||
|
},
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onColumnOrderChange: setColumnOrder,
|
||||||
|
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
|
||||||
|
const nextPagination =
|
||||||
|
typeof updaterOrValue === 'function'
|
||||||
|
? updaterOrValue(pagination)
|
||||||
|
: updaterOrValue;
|
||||||
|
setPage(nextPagination.pageIndex + 1);
|
||||||
|
},
|
||||||
|
getRowId: (row, index) => row.id ?? `loading-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GroupsTableToolbar table={table} toolbarLeft={toolbarLeft} />
|
||||||
|
<DataTable
|
||||||
|
empty={{
|
||||||
|
title: 'No groups found',
|
||||||
|
description:
|
||||||
|
'Groups represent companies, teams, or other entities that events belong to.',
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
arePropsEqual(['query.isLoading', 'query.data', 'pageSize', 'toolbarLeft'])
|
||||||
|
);
|
||||||
|
|
||||||
|
function GroupsTableToolbar({
|
||||||
|
table,
|
||||||
|
toolbarLeft,
|
||||||
|
}: {
|
||||||
|
table: Table<IServiceGroupWithStats>;
|
||||||
|
toolbarLeft?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { search, setSearch } = useSearchQueryState();
|
||||||
|
return (
|
||||||
|
<DataTableToolbarContainer>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{toolbarLeft}
|
||||||
|
<AnimatedSearchInput
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search groups..."
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</DataTableToolbarContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
<XAxis {...xAxisProps} dataKey="date" />
|
<XAxis {...xAxisProps} dataKey="date" />
|
||||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.5}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Widget } from '@/components/widget';
|
import { ZapIcon } from 'lucide-react';
|
||||||
|
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -6,28 +7,32 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MostEvents = ({ data }: Props) => {
|
export const MostEvents = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.map((item) => item.count));
|
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||||
return (
|
return (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<WidgetTitle>Popular events</WidgetTitle>
|
<WidgetTitle>Popular events</WidgetTitle>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<div className="flex flex-col gap-1 p-1">
|
{data.length === 0 ? (
|
||||||
{data.slice(0, 5).map((item) => (
|
<WidgetEmptyState icon={ZapIcon} text="No events yet" />
|
||||||
<div key={item.name} className="relative px-3 py-2">
|
) : (
|
||||||
<div
|
<div className="flex flex-col gap-1 p-1">
|
||||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
{data.slice(0, 5).map((item) => (
|
||||||
style={{
|
<div key={item.name} className="relative px-3 py-2">
|
||||||
width: `${(item.count / max) * 100}%`,
|
<div
|
||||||
}}
|
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||||
/>
|
style={{
|
||||||
<div className="relative flex justify-between ">
|
width: `${(item.count / max) * 100}%`,
|
||||||
<div>{item.name}</div>
|
}}
|
||||||
<div>{item.count}</div>
|
/>
|
||||||
|
<div className="relative flex justify-between ">
|
||||||
|
<div>{item.name}</div>
|
||||||
|
<div>{item.count}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Widget } from '@/components/widget';
|
import { RouteIcon } from 'lucide-react';
|
||||||
|
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -6,28 +7,32 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PopularRoutes = ({ data }: Props) => {
|
export const PopularRoutes = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.map((item) => item.count));
|
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||||
return (
|
return (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<WidgetTitle>Most visted pages</WidgetTitle>
|
<WidgetTitle>Most visted pages</WidgetTitle>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<div className="flex flex-col gap-1 p-1">
|
{data.length === 0 ? (
|
||||||
{data.slice(0, 5).map((item) => (
|
<WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
|
||||||
<div key={item.path} className="relative px-3 py-2">
|
) : (
|
||||||
<div
|
<div className="flex flex-col gap-1 p-1">
|
||||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
{data.slice(0, 5).map((item) => (
|
||||||
style={{
|
<div key={item.path} className="relative px-3 py-2">
|
||||||
width: `${(item.count / max) * 100}%`,
|
<div
|
||||||
}}
|
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||||
/>
|
style={{
|
||||||
<div className="relative flex justify-between ">
|
width: `${(item.count / max) * 100}%`,
|
||||||
<div>{item.path}</div>
|
}}
|
||||||
<div>{item.count}</div>
|
/>
|
||||||
|
<div className="relative flex justify-between ">
|
||||||
|
<div>{item.path}</div>
|
||||||
|
<div>{item.count}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { UsersIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profileId: string;
|
||||||
|
projectId: string;
|
||||||
|
groups: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileGroups = ({ projectId, groups }: Props) => {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const query = useQuery(
|
||||||
|
trpc.group.listByIds.queryOptions({
|
||||||
|
projectId,
|
||||||
|
ids: groups,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groups.length === 0 || !query.data?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
|
||||||
|
<UsersIcon className="size-3.5" />
|
||||||
|
Groups
|
||||||
|
</span>
|
||||||
|
{query.data.map((group) => (
|
||||||
|
<ProjectLink
|
||||||
|
key={group.id}
|
||||||
|
href={`/groups/${encodeURIComponent(group.id)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{group.name}</span>
|
||||||
|
<span className="text-muted-foreground">{group.type}</span>
|
||||||
|
</ProjectLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
|
import type { IServiceProfile } from '@openpanel/db';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ProfileAvatar } from '../profile-avatar';
|
||||||
|
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||||
import { ProjectLink } from '@/components/links';
|
import { ProjectLink } from '@/components/links';
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { Tooltiper } from '@/components/ui/tooltip';
|
|
||||||
import { formatDateTime, formatTime } from '@/utils/date';
|
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { isToday } from 'date-fns';
|
|
||||||
|
|
||||||
import type { IServiceProfile } from '@openpanel/db';
|
|
||||||
|
|
||||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
|
||||||
import { ProfileAvatar } from '../profile-avatar';
|
|
||||||
|
|
||||||
export function useColumns(type: 'profiles' | 'power-users') {
|
export function useColumns(type: 'profiles' | 'power-users') {
|
||||||
const columns: ColumnDef<IServiceProfile>[] = [
|
const columns: ColumnDef<IServiceProfile>[] = [
|
||||||
@@ -20,8 +15,8 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
|||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
|
||||||
className="flex items-center gap-2 font-medium"
|
className="flex items-center gap-2 font-medium"
|
||||||
|
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||||
title={getProfileName(profile, false)}
|
title={getProfileName(profile, false)}
|
||||||
>
|
>
|
||||||
<ProfileAvatar size="sm" {...profile} />
|
<ProfileAvatar size="sm" {...profile} />
|
||||||
@@ -100,13 +95,40 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
header: 'Last seen',
|
header: 'First seen',
|
||||||
size: ColumnCreatedAt.size,
|
size: ColumnCreatedAt.size,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
|
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'groups',
|
||||||
|
header: 'Groups',
|
||||||
|
size: 200,
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
cell({ row }) {
|
||||||
|
const { groups } = row.original;
|
||||||
|
if (!groups?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<ProjectLink
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline"
|
||||||
|
href={`/groups/${encodeURIComponent(g)}`}
|
||||||
|
key={g}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</ProjectLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (type === 'power-users') {
|
if (type === 'power-users') {
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ export function Tables({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
options: funnelOptions,
|
options: funnelOptions,
|
||||||
},
|
},
|
||||||
stepIndex, // Pass the step index for funnel queries
|
stepIndex,
|
||||||
|
breakdownValues: breakdowns,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { chartSegments } from '@openpanel/constants';
|
||||||
|
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
|
Building2Icon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
EqualApproximatelyIcon,
|
EqualApproximatelyIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -10,10 +13,7 @@ import {
|
|||||||
UserCheckIcon,
|
UserCheckIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
import { chartSegments } from '@openpanel/constants';
|
|
||||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { Button } from '../ui/button';
|
|
||||||
|
|
||||||
interface ReportChartTypeProps {
|
interface ReportChartTypeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -46,6 +45,7 @@ export function ReportSegment({
|
|||||||
event: ActivityIcon,
|
event: ActivityIcon,
|
||||||
user: UsersIcon,
|
user: UsersIcon,
|
||||||
session: ClockIcon,
|
session: ClockIcon,
|
||||||
|
group: Building2Icon,
|
||||||
user_average: UserCheck2Icon,
|
user_average: UserCheck2Icon,
|
||||||
one_event_per_user: UserCheckIcon,
|
one_event_per_user: UserCheckIcon,
|
||||||
property_sum: SigmaIcon,
|
property_sum: SigmaIcon,
|
||||||
@@ -58,9 +58,9 @@ export function ReportSegment({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
icon={Icons[value]}
|
|
||||||
className={cn('justify-start text-sm', className)}
|
className={cn('justify-start text-sm', className)}
|
||||||
|
icon={Icons[value]}
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{items.find((item) => item.value === value)?.label}
|
{items.find((item) => item.value === value)?.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -74,13 +74,13 @@ export function ReportSegment({
|
|||||||
const Icon = Icons[item.value];
|
const Icon = Icons[item.value];
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="group"
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => onChange(item.value)}
|
onClick={() => onChange(item.value)}
|
||||||
className="group"
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<DropdownMenuShortcut>
|
<DropdownMenuShortcut>
|
||||||
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
<Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||||
</DropdownMenuShortcut>
|
</DropdownMenuShortcut>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
import type { IChartEvent } from '@openpanel/validation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
Building2Icon,
|
||||||
|
DatabaseIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import VirtualList from 'rc-virtual-list';
|
||||||
|
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,11 +21,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
|
|
||||||
import VirtualList from 'rc-virtual-list';
|
|
||||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface PropertiesComboboxProps {
|
interface PropertiesComboboxProps {
|
||||||
event?: IChartEvent;
|
event?: IChartEvent;
|
||||||
@@ -40,15 +47,15 @@ function SearchHeader({
|
|||||||
return (
|
return (
|
||||||
<div className="row items-center gap-1">
|
<div className="row items-center gap-1">
|
||||||
{!!onBack && (
|
{!!onBack && (
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button onClick={onBack} size="icon" variant="ghost">
|
||||||
<ArrowLeftIcon className="size-4" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
|
|||||||
exclude = [],
|
exclude = [],
|
||||||
}: PropertiesComboboxProps) {
|
}: PropertiesComboboxProps) {
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const properties = useEventProperties({
|
const properties = useEventProperties({
|
||||||
event: event?.name,
|
event: event?.name,
|
||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
const groupPropertiesQuery = useQuery(
|
||||||
|
trpc.group.properties.queryOptions({ projectId })
|
||||||
|
);
|
||||||
|
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
|
||||||
|
'index'
|
||||||
|
);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
|
setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
|
||||||
}
|
}
|
||||||
}, [open, mode]);
|
}, [open, mode]);
|
||||||
|
|
||||||
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock data for the lists
|
// Fixed group properties: name, type, plus dynamic property keys
|
||||||
|
const groupActions = [
|
||||||
|
{ value: 'group.name', label: 'name', description: 'group' },
|
||||||
|
{ value: 'group.type', label: 'type', description: 'group' },
|
||||||
|
...(groupPropertiesQuery.data ?? []).map((key) => ({
|
||||||
|
value: `group.properties.${key}`,
|
||||||
|
label: key,
|
||||||
|
description: 'group.properties',
|
||||||
|
})),
|
||||||
|
].filter((a) => shouldShowProperty(a.value));
|
||||||
|
|
||||||
const profileActions = properties
|
const profileActions = properties
|
||||||
.filter(
|
.filter(
|
||||||
(property) =>
|
(property) =>
|
||||||
property.startsWith('profile') && shouldShowProperty(property),
|
property.startsWith('profile') && shouldShowProperty(property)
|
||||||
)
|
)
|
||||||
.map((property) => ({
|
.map((property) => ({
|
||||||
value: property,
|
value: property,
|
||||||
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
|
|||||||
const eventActions = properties
|
const eventActions = properties
|
||||||
.filter(
|
.filter(
|
||||||
(property) =>
|
(property) =>
|
||||||
!property.startsWith('profile') && shouldShowProperty(property),
|
!property.startsWith('profile') && shouldShowProperty(property)
|
||||||
)
|
)
|
||||||
.map((property) => ({
|
.map((property) => ({
|
||||||
value: property,
|
value: property,
|
||||||
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
|
|||||||
description: property.split('.').slice(0, -1).join('.'),
|
description: property.split('.').slice(0, -1).join('.'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
|
const handleStateChange = (
|
||||||
|
newState: 'index' | 'event' | 'profile' | 'group'
|
||||||
|
) => {
|
||||||
setDirection(newState === 'index' ? 'backward' : 'forward');
|
setDirection(newState === 'index' ? 'backward' : 'forward');
|
||||||
setState(newState);
|
setState(newState);
|
||||||
};
|
};
|
||||||
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Event properties
|
Event properties
|
||||||
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
<DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="group justify-between gap-2"
|
className="group justify-between gap-2"
|
||||||
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Profile properties
|
Profile properties
|
||||||
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
<UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="group justify-between gap-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStateChange('group');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Group properties
|
||||||
|
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
);
|
);
|
||||||
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
|
|||||||
const filteredActions = eventActions.filter(
|
const filteredActions = eventActions.filter(
|
||||||
(action) =>
|
(action) =>
|
||||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
action.description.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
|
|||||||
/>
|
/>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<VirtualList
|
<VirtualList
|
||||||
height={300}
|
|
||||||
data={filteredActions}
|
data={filteredActions}
|
||||||
|
height={300}
|
||||||
itemHeight={40}
|
itemHeight={40}
|
||||||
itemKey="id"
|
itemKey="id"
|
||||||
>
|
>
|
||||||
{(action) => (
|
{(action) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
onClick={() => handleSelect(action)}
|
onClick={() => handleSelect(action)}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{action.label}</div>
|
<div className="font-medium">{action.label}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
{action.description}
|
{action.description}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
|
|||||||
const filteredActions = profileActions.filter(
|
const filteredActions = profileActions.filter(
|
||||||
(action) =>
|
(action) =>
|
||||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
action.description.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
|
|||||||
/>
|
/>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<VirtualList
|
<VirtualList
|
||||||
height={300}
|
|
||||||
data={filteredActions}
|
data={filteredActions}
|
||||||
|
height={300}
|
||||||
itemHeight={40}
|
itemHeight={40}
|
||||||
itemKey="id"
|
itemKey="id"
|
||||||
>
|
>
|
||||||
{(action) => (
|
{(action) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
onClick={() => handleSelect(action)}
|
onClick={() => handleSelect(action)}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{action.label}</div>
|
<div className="font-medium">{action.label}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{action.description}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</VirtualList>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroup = () => {
|
||||||
|
const filteredActions = groupActions.filter(
|
||||||
|
(action) =>
|
||||||
|
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
action.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<SearchHeader
|
||||||
|
onBack={() => handleStateChange('index')}
|
||||||
|
onSearch={setSearch}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<VirtualList
|
||||||
|
data={filteredActions}
|
||||||
|
height={Math.min(300, filteredActions.length * 40 + 8)}
|
||||||
|
itemHeight={40}
|
||||||
|
itemKey="value"
|
||||||
|
>
|
||||||
|
{(action) => (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
onClick={() => handleSelect(action)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{action.label}</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
{action.description}
|
{action.description}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
}}
|
}}
|
||||||
|
open={open}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="max-w-80" align="start">
|
<DropdownMenuContent align="start" className="max-w-80">
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{state === 'index' && (
|
{state === 'index' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="index"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
key="index"
|
||||||
transition={{ duration: 0.05 }}
|
transition={{ duration: 0.05 }}
|
||||||
>
|
>
|
||||||
{renderIndex()}
|
{renderIndex()}
|
||||||
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
|
|||||||
)}
|
)}
|
||||||
{state === 'event' && (
|
{state === 'event' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="event"
|
|
||||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||||
|
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||||
|
key="event"
|
||||||
transition={{ duration: 0.05 }}
|
transition={{ duration: 0.05 }}
|
||||||
>
|
>
|
||||||
{renderEvent()}
|
{renderEvent()}
|
||||||
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
|
|||||||
)}
|
)}
|
||||||
{state === 'profile' && (
|
{state === 'profile' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="profile"
|
|
||||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||||
|
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||||
|
key="profile"
|
||||||
transition={{ duration: 0.05 }}
|
transition={{ duration: 0.05 }}
|
||||||
>
|
>
|
||||||
{renderProfile()}
|
{renderProfile()}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{state === 'group' && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||||
|
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||||
|
key="group"
|
||||||
|
transition={{ duration: 0.05 }}
|
||||||
|
>
|
||||||
|
{renderGroup()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -270,6 +270,27 @@ export function useColumns() {
|
|||||||
header: 'Device ID',
|
header: 'Device ID',
|
||||||
size: 120,
|
size: 120,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'groups',
|
||||||
|
header: 'Groups',
|
||||||
|
size: 200,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { groups } = row.original;
|
||||||
|
if (!groups?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<span
|
||||||
|
key={g}
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
Building2Icon,
|
||||||
ChartLineIcon,
|
ChartLineIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
|
|||||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||||
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||||
|
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
|
||||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||||
Manage
|
Manage
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Column, Table } from '@tanstack/react-table';
|
import type { Column, Table } from '@tanstack/react-table';
|
||||||
import { SearchIcon, X, XIcon } from 'lucide-react';
|
import { SearchIcon, X, XIcon } from 'lucide-react';
|
||||||
import * as React from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
|
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
|
||||||
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
|
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
|
||||||
@@ -23,12 +22,12 @@ export function DataTableToolbarContainer({
|
|||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-1 items-start justify-between gap-2 mb-2',
|
'mb-2 flex flex-1 items-start justify-between gap-2',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
role="toolbar"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -47,12 +46,12 @@ export function DataTableToolbar<TData>({
|
|||||||
});
|
});
|
||||||
const isFiltered = table.getState().columnFilters.length > 0;
|
const isFiltered = table.getState().columnFilters.length > 0;
|
||||||
|
|
||||||
const columns = React.useMemo(
|
const columns = useMemo(
|
||||||
() => table.getAllColumns().filter((column) => column.getCanFilter()),
|
() => table.getAllColumns().filter((column) => column.getCanFilter()),
|
||||||
[table],
|
[table]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onReset = React.useCallback(() => {
|
const onReset = useCallback(() => {
|
||||||
table.resetColumnFilters();
|
table.resetColumnFilters();
|
||||||
}, [table]);
|
}, [table]);
|
||||||
|
|
||||||
@@ -61,23 +60,23 @@ export function DataTableToolbar<TData>({
|
|||||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
{globalSearchKey && (
|
{globalSearchKey && (
|
||||||
<AnimatedSearchInput
|
<AnimatedSearchInput
|
||||||
|
onChange={setSearch}
|
||||||
placeholder={globalSearchPlaceholder ?? 'Search'}
|
placeholder={globalSearchPlaceholder ?? 'Search'}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<DataTableToolbarFilter key={column.id} column={column} />
|
<DataTableToolbarFilter column={column} key={column.id} />
|
||||||
))}
|
))}
|
||||||
{isFiltered && (
|
{isFiltered && (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Reset filters"
|
aria-label="Reset filters"
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-dashed"
|
className="border-dashed"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4 mr-2" />
|
<XIcon className="mr-2 size-4" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -99,20 +98,22 @@ function DataTableToolbarFilter<TData>({
|
|||||||
{
|
{
|
||||||
const columnMeta = column.columnDef.meta;
|
const columnMeta = column.columnDef.meta;
|
||||||
|
|
||||||
const getTitle = React.useCallback(() => {
|
const getTitle = useCallback(() => {
|
||||||
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
|
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
|
||||||
}, [columnMeta, column]);
|
}, [columnMeta, column]);
|
||||||
|
|
||||||
const onFilterRender = React.useCallback(() => {
|
const onFilterRender = useCallback(() => {
|
||||||
if (!columnMeta?.variant) return null;
|
if (!columnMeta?.variant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
switch (columnMeta.variant) {
|
switch (columnMeta.variant) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<AnimatedSearchInput
|
<AnimatedSearchInput
|
||||||
|
onChange={(value) => column.setFilterValue(value)}
|
||||||
placeholder={columnMeta.placeholder ?? columnMeta.label}
|
placeholder={columnMeta.placeholder ?? columnMeta.label}
|
||||||
value={(column.getFilterValue() as string) ?? ''}
|
value={(column.getFilterValue() as string) ?? ''}
|
||||||
onChange={(value) => column.setFilterValue(value)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,12 +121,12 @@ function DataTableToolbarFilter<TData>({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder={getTitle()}
|
|
||||||
value={(column.getFilterValue() as string) ?? ''}
|
|
||||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
|
||||||
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
|
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||||
|
placeholder={getTitle()}
|
||||||
|
type="number"
|
||||||
|
value={(column.getFilterValue() as string) ?? ''}
|
||||||
/>
|
/>
|
||||||
{columnMeta.unit && (
|
{columnMeta.unit && (
|
||||||
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
||||||
@@ -143,8 +144,8 @@ function DataTableToolbarFilter<TData>({
|
|||||||
return (
|
return (
|
||||||
<DataTableDateFilter
|
<DataTableDateFilter
|
||||||
column={column}
|
column={column}
|
||||||
title={getTitle()}
|
|
||||||
multiple={columnMeta.variant === 'dateRange'}
|
multiple={columnMeta.variant === 'dateRange'}
|
||||||
|
title={getTitle()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,9 +154,9 @@ function DataTableToolbarFilter<TData>({
|
|||||||
return (
|
return (
|
||||||
<DataTableFacetedFilter
|
<DataTableFacetedFilter
|
||||||
column={column}
|
column={column}
|
||||||
title={getTitle()}
|
|
||||||
options={columnMeta.options ?? []}
|
|
||||||
multiple={columnMeta.variant === 'multiSelect'}
|
multiple={columnMeta.variant === 'multiSelect'}
|
||||||
|
options={columnMeta.options ?? []}
|
||||||
|
title={getTitle()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,11 +180,11 @@ export function AnimatedSearchInput({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: AnimatedSearchInputProps) {
|
}: AnimatedSearchInputProps) {
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const isExpanded = isFocused || (value?.length ?? 0) > 0;
|
const isExpanded = isFocused || (value?.length ?? 0) > 0;
|
||||||
|
|
||||||
const handleClear = React.useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onChange('');
|
onChange('');
|
||||||
// Re-focus after clearing
|
// Re-focus after clearing
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
@@ -191,34 +192,35 @@ export function AnimatedSearchInput({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
aria-label={placeholder ?? 'Search'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
|
'relative flex items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
|
||||||
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||||
isExpanded ? 'w-56 lg:w-72' : 'w-32',
|
'h-8 min-h-8',
|
||||||
|
isExpanded ? 'w-56 lg:w-72' : 'w-32'
|
||||||
)}
|
)}
|
||||||
role="search"
|
role="search"
|
||||||
aria-label={placeholder ?? 'Search'}
|
|
||||||
>
|
>
|
||||||
<SearchIcon className="size-4 ml-2 shrink-0" />
|
<SearchIcon className="ml-2 size-4 shrink-0" />
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none',
|
'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none',
|
||||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||||
'transition-opacity duration-200',
|
'transition-opacity duration-200',
|
||||||
'font-medium text-[14px] truncate align-baseline',
|
'truncate align-baseline font-medium text-[14px]'
|
||||||
)}
|
)}
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isExpanded && value && (
|
{isExpanded && value && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -226,6 +228,7 @@ export function AnimatedSearchInput({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleClear();
|
handleClear();
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
@@ -32,12 +31,12 @@ function SelectTrigger({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-8 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
data-size={size}
|
||||||
|
data-slot="select-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -57,13 +56,13 @@ function SelectContent({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
data-slot="select-content"
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -72,7 +71,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'p-1',
|
'p-1',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -89,8 +88,8 @@ function SelectLabel({
|
|||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
|
className={cn('px-2 py-1.5 text-muted-foreground text-xs', className)}
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -103,11 +102,11 @@ function SelectItem({
|
|||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
data-slot="select-item"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
@@ -126,8 +125,8 @@ function SelectSeparator({
|
|||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
|
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -139,11 +138,11 @@ function SelectScrollUpButton({
|
|||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-default items-center justify-center py-1',
|
'flex cursor-default items-center justify-center py-1',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
@@ -157,11 +156,11 @@ function SelectScrollDownButton({
|
|||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-default items-center justify-center py-1',
|
'flex cursor-default items-center justify-center py-1',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
|||||||
return <div className={cn('p-4', className)}>{children}</div>;
|
return <div className={cn('p-4', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetEmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||||
|
<Icon size={28} strokeWidth={1.5} />
|
||||||
|
<p className="text-sm">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WidgetProps {
|
export interface WidgetProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
139
apps/start/src/modals/add-group.tsx
Normal file
139
apps/start/src/modals/add-group.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { zCreateGroup } from '@openpanel/validation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
|
const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
|
||||||
|
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||||
|
});
|
||||||
|
type IForm = z.infer<typeof zForm>;
|
||||||
|
|
||||||
|
export default function AddGroup() {
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, formState } = useForm<IForm>({
|
||||||
|
resolver: zodResolver(zForm),
|
||||||
|
defaultValues: {
|
||||||
|
id: '',
|
||||||
|
type: '',
|
||||||
|
name: '',
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'properties',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation(
|
||||||
|
trpc.group.create.mutationOptions({
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||||
|
queryClient.invalidateQueries(trpc.group.types.pathFilter());
|
||||||
|
toast('Success', { description: 'Group created.' });
|
||||||
|
popModal();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader title="Add group" />
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={handleSubmit(({ properties, ...values }) => {
|
||||||
|
const props = Object.fromEntries(
|
||||||
|
properties
|
||||||
|
.filter((p) => p.key.trim() !== '')
|
||||||
|
.map((p) => [p.key.trim(), String(p.value)])
|
||||||
|
);
|
||||||
|
mutation.mutate({ projectId, ...values, properties: props });
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<InputWithLabel
|
||||||
|
label="ID"
|
||||||
|
placeholder="acme-corp"
|
||||||
|
{...register('id')}
|
||||||
|
autoFocus
|
||||||
|
error={formState.errors.id?.message}
|
||||||
|
/>
|
||||||
|
<InputWithLabel
|
||||||
|
label="Name"
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
{...register('name')}
|
||||||
|
error={formState.errors.name?.message}
|
||||||
|
/>
|
||||||
|
<InputWithLabel
|
||||||
|
label="Type"
|
||||||
|
placeholder="company"
|
||||||
|
{...register('type')}
|
||||||
|
error={formState.errors.type?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">Properties</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => append({ key: '', value: '' })}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1 size-3" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div className="flex gap-2" key={field.id}>
|
||||||
|
<input
|
||||||
|
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="key"
|
||||||
|
{...register(`properties.${index}.key`)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="value"
|
||||||
|
{...register(`properties.${index}.value`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!formState.isDirty || mutation.isPending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/start/src/modals/edit-group.tsx
Normal file
147
apps/start/src/modals/edit-group.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type { IServiceGroup } from '@openpanel/db';
|
||||||
|
import { zUpdateGroup } from '@openpanel/validation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
|
const zForm = zUpdateGroup
|
||||||
|
.omit({ id: true, projectId: true, properties: true })
|
||||||
|
.extend({
|
||||||
|
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||||
|
});
|
||||||
|
type IForm = z.infer<typeof zForm>;
|
||||||
|
|
||||||
|
type EditGroupProps = Pick<
|
||||||
|
IServiceGroup,
|
||||||
|
'id' | 'projectId' | 'name' | 'type' | 'properties'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function EditGroup({
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
properties,
|
||||||
|
}: EditGroupProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, formState } = useForm<IForm>({
|
||||||
|
resolver: zodResolver(zForm),
|
||||||
|
defaultValues: {
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
properties: Object.entries(properties as Record<string, string>).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: String(value),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'properties',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation(
|
||||||
|
trpc.group.update.mutationOptions({
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||||
|
queryClient.invalidateQueries(trpc.group.byId.pathFilter());
|
||||||
|
queryClient.invalidateQueries(trpc.group.types.pathFilter());
|
||||||
|
toast('Success', { description: 'Group updated.' });
|
||||||
|
popModal();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader title="Edit group" />
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={handleSubmit(({ properties: formProps, ...values }) => {
|
||||||
|
const props = Object.fromEntries(
|
||||||
|
formProps
|
||||||
|
.filter((p) => p.key.trim() !== '')
|
||||||
|
.map((p) => [p.key.trim(), String(p.value)])
|
||||||
|
);
|
||||||
|
mutation.mutate({ id, projectId, ...values, properties: props });
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<InputWithLabel
|
||||||
|
label="Name"
|
||||||
|
{...register('name')}
|
||||||
|
error={formState.errors.name?.message}
|
||||||
|
/>
|
||||||
|
<InputWithLabel
|
||||||
|
label="Type"
|
||||||
|
{...register('type')}
|
||||||
|
error={formState.errors.type?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">Properties</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => append({ key: '', value: '' })}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1 size-3" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div className="flex gap-2" key={field.id}>
|
||||||
|
<input
|
||||||
|
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="key"
|
||||||
|
{...register(`properties.${index}.key`)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="value"
|
||||||
|
{...register(`properties.${index}.value`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!formState.isDirty || mutation.isPending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import PageDetails from './page-details';
|
|
||||||
import { createPushModal } from 'pushmodal';
|
import { createPushModal } from 'pushmodal';
|
||||||
import AddClient from './add-client';
|
import AddClient from './add-client';
|
||||||
import AddDashboard from './add-dashboard';
|
import AddDashboard from './add-dashboard';
|
||||||
|
import AddGroup from './add-group';
|
||||||
import AddImport from './add-import';
|
import AddImport from './add-import';
|
||||||
import AddIntegration from './add-integration';
|
import AddIntegration from './add-integration';
|
||||||
import AddNotificationRule from './add-notification-rule';
|
import AddNotificationRule from './add-notification-rule';
|
||||||
@@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker';
|
|||||||
import EditClient from './edit-client';
|
import EditClient from './edit-client';
|
||||||
import EditDashboard from './edit-dashboard';
|
import EditDashboard from './edit-dashboard';
|
||||||
import EditEvent from './edit-event';
|
import EditEvent from './edit-event';
|
||||||
|
import EditGroup from './edit-group';
|
||||||
import EditMember from './edit-member';
|
import EditMember from './edit-member';
|
||||||
import EditReference from './edit-reference';
|
import EditReference from './edit-reference';
|
||||||
import EditReport from './edit-report';
|
import EditReport from './edit-report';
|
||||||
@@ -23,6 +24,7 @@ import EventDetails from './event-details';
|
|||||||
import Instructions from './Instructions';
|
import Instructions from './Instructions';
|
||||||
import OverviewChartDetails from './overview-chart-details';
|
import OverviewChartDetails from './overview-chart-details';
|
||||||
import OverviewFilters from './overview-filters';
|
import OverviewFilters from './overview-filters';
|
||||||
|
import PageDetails from './page-details';
|
||||||
import RequestPasswordReset from './request-reset-password';
|
import RequestPasswordReset from './request-reset-password';
|
||||||
import SaveReport from './save-report';
|
import SaveReport from './save-report';
|
||||||
import SelectBillingPlan from './select-billing-plan';
|
import SelectBillingPlan from './select-billing-plan';
|
||||||
@@ -36,6 +38,8 @@ import { op } from '@/utils/op';
|
|||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
PageDetails,
|
PageDetails,
|
||||||
|
AddGroup,
|
||||||
|
EditGroup,
|
||||||
OverviewTopPagesModal,
|
OverviewTopPagesModal,
|
||||||
OverviewTopGenericModal,
|
OverviewTopGenericModal,
|
||||||
RequestPasswordReset,
|
RequestPasswordReset,
|
||||||
|
|||||||
@@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
|||||||
interface FunnelUsersViewProps {
|
interface FunnelUsersViewProps {
|
||||||
report: IReportInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
|
breakdownValues?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const [showDropoffs, setShowDropoffs] = useState(false);
|
const [showDropoffs, setShowDropoffs] = useState(false);
|
||||||
|
|
||||||
@@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
|||||||
? report.options.funnelGroup
|
? report.options.funnelGroup
|
||||||
: undefined,
|
: undefined,
|
||||||
breakdowns: report.breakdowns,
|
breakdowns: report.breakdowns,
|
||||||
|
breakdownValues: breakdownValues,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: stepIndex !== undefined,
|
enabled: stepIndex !== undefined,
|
||||||
@@ -384,13 +386,14 @@ type ViewChartUsersProps =
|
|||||||
type: 'funnel';
|
type: 'funnel';
|
||||||
report: IReportInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
|
breakdownValues?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main component that routes to the appropriate view
|
// Main component that routes to the appropriate view
|
||||||
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
||||||
if (props.type === 'funnel') {
|
if (props.type === 'funnel') {
|
||||||
return (
|
return (
|
||||||
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} breakdownValues={props.breakdownValues} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
|
|||||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||||
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
||||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||||
|
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
|
||||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
||||||
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
||||||
@@ -82,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
|
|||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
|
||||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
|
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
|
||||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
|
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
|
||||||
|
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
|
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||||
|
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||||
|
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members'
|
||||||
|
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events'
|
||||||
|
|
||||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||||
'/_app/$organizationId/profile',
|
'/_app/$organizationId/profile',
|
||||||
@@ -113,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
|
|||||||
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId',
|
'/_app/$organizationId/$projectId/profiles/$profileId',
|
||||||
)()
|
)()
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId',
|
||||||
|
)()
|
||||||
|
|
||||||
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
||||||
id: '/unsubscribe',
|
id: '/unsubscribe',
|
||||||
@@ -350,6 +358,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
|
|||||||
path: '/insights',
|
path: '/insights',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsRouteImport.update({
|
||||||
|
id: '/groups',
|
||||||
|
path: '/groups',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdDashboardsRoute =
|
const AppOrganizationIdProjectIdDashboardsRoute =
|
||||||
AppOrganizationIdProjectIdDashboardsRouteImport.update({
|
AppOrganizationIdProjectIdDashboardsRouteImport.update({
|
||||||
id: '/dashboards',
|
id: '/dashboards',
|
||||||
@@ -368,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
|
|||||||
path: '/$profileId',
|
path: '/$profileId',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
|
||||||
|
id: '/groups_/$groupId',
|
||||||
|
path: '/groups/$groupId',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProfileTabsIndexRoute =
|
const AppOrganizationIdProfileTabsIndexRoute =
|
||||||
AppOrganizationIdProfileTabsIndexRouteImport.update({
|
AppOrganizationIdProfileTabsIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@@ -555,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
|
|||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
|
||||||
|
id: '/_tabs',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdEventsTabsStatsRoute =
|
const AppOrganizationIdProjectIdEventsTabsStatsRoute =
|
||||||
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
|
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
|
||||||
id: '/stats',
|
id: '/stats',
|
||||||
@@ -579,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
||||||
id: '/sessions',
|
id: '/sessions',
|
||||||
@@ -591,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
|||||||
path: '/events',
|
path: '/events',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({
|
||||||
|
id: '/members',
|
||||||
|
path: '/members',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||||
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({
|
||||||
|
id: '/events',
|
||||||
|
path: '/events',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -615,6 +658,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
|
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
@@ -646,6 +690,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||||
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||||
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
|
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
|
||||||
@@ -663,8 +708,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -688,6 +736,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
|
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
@@ -716,6 +765,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||||
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||||
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
@@ -729,6 +779,8 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
}
|
}
|
||||||
@@ -760,6 +812,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
|
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||||
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||||
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
@@ -798,6 +851,8 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||||
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||||
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
|
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
|
||||||
@@ -816,8 +871,11 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
@@ -845,6 +903,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/'
|
| '/$organizationId/'
|
||||||
| '/$organizationId/$projectId/chat'
|
| '/$organizationId/$projectId/chat'
|
||||||
| '/$organizationId/$projectId/dashboards'
|
| '/$organizationId/$projectId/dashboards'
|
||||||
|
| '/$organizationId/$projectId/groups'
|
||||||
| '/$organizationId/$projectId/insights'
|
| '/$organizationId/$projectId/insights'
|
||||||
| '/$organizationId/$projectId/pages'
|
| '/$organizationId/$projectId/pages'
|
||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
@@ -876,6 +935,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/events/conversions'
|
| '/$organizationId/$projectId/events/conversions'
|
||||||
| '/$organizationId/$projectId/events/events'
|
| '/$organizationId/$projectId/events/events'
|
||||||
| '/$organizationId/$projectId/events/stats'
|
| '/$organizationId/$projectId/events/stats'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId'
|
||||||
| '/$organizationId/$projectId/notifications/notifications'
|
| '/$organizationId/$projectId/notifications/notifications'
|
||||||
| '/$organizationId/$projectId/notifications/rules'
|
| '/$organizationId/$projectId/notifications/rules'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId'
|
| '/$organizationId/$projectId/profiles/$profileId'
|
||||||
@@ -893,8 +953,11 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/notifications/'
|
| '/$organizationId/$projectId/notifications/'
|
||||||
| '/$organizationId/$projectId/profiles/'
|
| '/$organizationId/$projectId/profiles/'
|
||||||
| '/$organizationId/$projectId/settings/'
|
| '/$organizationId/$projectId/settings/'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId/events'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId/members'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId/'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/'
|
| '/$organizationId/$projectId/profiles/$profileId/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -918,6 +981,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId'
|
| '/$organizationId'
|
||||||
| '/$organizationId/$projectId/chat'
|
| '/$organizationId/$projectId/chat'
|
||||||
| '/$organizationId/$projectId/dashboards'
|
| '/$organizationId/$projectId/dashboards'
|
||||||
|
| '/$organizationId/$projectId/groups'
|
||||||
| '/$organizationId/$projectId/insights'
|
| '/$organizationId/$projectId/insights'
|
||||||
| '/$organizationId/$projectId/pages'
|
| '/$organizationId/$projectId/pages'
|
||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
@@ -946,6 +1010,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/events/conversions'
|
| '/$organizationId/$projectId/events/conversions'
|
||||||
| '/$organizationId/$projectId/events/events'
|
| '/$organizationId/$projectId/events/events'
|
||||||
| '/$organizationId/$projectId/events/stats'
|
| '/$organizationId/$projectId/events/stats'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId'
|
||||||
| '/$organizationId/$projectId/notifications/notifications'
|
| '/$organizationId/$projectId/notifications/notifications'
|
||||||
| '/$organizationId/$projectId/notifications/rules'
|
| '/$organizationId/$projectId/notifications/rules'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId'
|
| '/$organizationId/$projectId/profiles/$profileId'
|
||||||
@@ -959,6 +1024,8 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/tracking'
|
| '/$organizationId/$projectId/settings/tracking'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId/events'
|
||||||
|
| '/$organizationId/$projectId/groups/$groupId/members'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
id:
|
id:
|
||||||
@@ -989,6 +1056,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/'
|
| '/_app/$organizationId/'
|
||||||
| '/_app/$organizationId/$projectId/chat'
|
| '/_app/$organizationId/$projectId/chat'
|
||||||
| '/_app/$organizationId/$projectId/dashboards'
|
| '/_app/$organizationId/$projectId/dashboards'
|
||||||
|
| '/_app/$organizationId/$projectId/groups'
|
||||||
| '/_app/$organizationId/$projectId/insights'
|
| '/_app/$organizationId/$projectId/insights'
|
||||||
| '/_app/$organizationId/$projectId/pages'
|
| '/_app/$organizationId/$projectId/pages'
|
||||||
| '/_app/$organizationId/$projectId/realtime'
|
| '/_app/$organizationId/$projectId/realtime'
|
||||||
@@ -1027,6 +1095,8 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||||
|
| '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||||
|
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||||
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
|
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
|
||||||
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
|
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId'
|
| '/_app/$organizationId/$projectId/profiles/$profileId'
|
||||||
@@ -1045,8 +1115,11 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
||||||
|
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||||
|
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||||
|
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
@@ -1378,6 +1451,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups'
|
||||||
|
path: '/groups'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/dashboards': {
|
'/_app/$organizationId/$projectId/dashboards': {
|
||||||
id: '/_app/$organizationId/$projectId/dashboards'
|
id: '/_app/$organizationId/$projectId/dashboards'
|
||||||
path: '/dashboards'
|
path: '/dashboards'
|
||||||
@@ -1399,6 +1479,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
|
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||||
|
path: '/groups/$groupId'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups/$groupId'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/profile/_tabs/': {
|
'/_app/$organizationId/profile/_tabs/': {
|
||||||
id: '/_app/$organizationId/profile/_tabs/'
|
id: '/_app/$organizationId/profile/_tabs/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -1623,6 +1710,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||||
|
path: '/groups/$groupId'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups/$groupId'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/stats': {
|
'/_app/$organizationId/$projectId/events/_tabs/stats': {
|
||||||
id: '/_app/$organizationId/$projectId/events/_tabs/stats'
|
id: '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||||
path: '/stats'
|
path: '/stats'
|
||||||
@@ -1651,6 +1745,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups/$groupId/'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
|
||||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||||
path: '/sessions'
|
path: '/sessions'
|
||||||
@@ -1665,6 +1766,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||||
|
path: '/members'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups/$groupId/members'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||||
|
}
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': {
|
||||||
|
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||||
|
path: '/events'
|
||||||
|
fullPath: '/$organizationId/$projectId/groups/$groupId/events'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,9 +1987,46 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
|
|||||||
AppOrganizationIdProjectIdSettingsRouteChildren,
|
AppOrganizationIdProjectIdSettingsRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren {
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren =
|
||||||
|
{
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute,
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute,
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren(
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren {
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren =
|
||||||
|
{
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren =
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren(
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface AppOrganizationIdProjectIdRouteChildren {
|
interface AppOrganizationIdProjectIdRouteChildren {
|
||||||
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
|
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
|
||||||
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
|
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
|
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
|
||||||
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
||||||
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
||||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
@@ -1890,6 +2042,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
|||||||
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||||
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
|
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
|
||||||
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
|
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
|
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
|
||||||
@@ -1897,6 +2050,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
|||||||
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
|
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
|
||||||
AppOrganizationIdProjectIdDashboardsRoute:
|
AppOrganizationIdProjectIdDashboardsRoute:
|
||||||
AppOrganizationIdProjectIdDashboardsRoute,
|
AppOrganizationIdProjectIdDashboardsRoute,
|
||||||
|
AppOrganizationIdProjectIdGroupsRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsRoute,
|
||||||
AppOrganizationIdProjectIdInsightsRoute:
|
AppOrganizationIdProjectIdInsightsRoute:
|
||||||
AppOrganizationIdProjectIdInsightsRoute,
|
AppOrganizationIdProjectIdInsightsRoute,
|
||||||
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
||||||
@@ -1924,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
|||||||
AppOrganizationIdProjectIdSessionsSessionIdRoute,
|
AppOrganizationIdProjectIdSessionsSessionIdRoute,
|
||||||
AppOrganizationIdProjectIdSettingsRoute:
|
AppOrganizationIdProjectIdSettingsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsRouteWithChildren,
|
AppOrganizationIdProjectIdSettingsRouteWithChildren,
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRoute:
|
||||||
|
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppOrganizationIdProjectIdRouteWithChildren =
|
const AppOrganizationIdProjectIdRouteWithChildren =
|
||||||
|
|||||||
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
import { parseAsString, useQueryState } from 'nuqs';
|
||||||
|
import { GroupsTable } from '@/components/groups/table';
|
||||||
|
import { PageContainer } from '@/components/page-container';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
|
||||||
|
{
|
||||||
|
component: Component,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('Groups') }],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { debouncedSearch } = useSearchQueryState();
|
||||||
|
const [typeFilter, setTypeFilter] = useQueryState(
|
||||||
|
'type',
|
||||||
|
parseAsString.withDefault('')
|
||||||
|
);
|
||||||
|
const { page } = useDataTablePagination(PAGE_SIZE);
|
||||||
|
|
||||||
|
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
|
||||||
|
|
||||||
|
const groupsQuery = useQuery(
|
||||||
|
trpc.group.list.queryOptions(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
cursor: (page - 1) * PAGE_SIZE,
|
||||||
|
},
|
||||||
|
{ placeholderData: keepPreviousData }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const types = typesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => pushModal('AddGroup')}>
|
||||||
|
<PlusIcon className="mr-2 size-4" />
|
||||||
|
Add group
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
className="mb-8"
|
||||||
|
description="Groups represent companies, teams, or other entities that events belong to."
|
||||||
|
title="Groups"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupsTable
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
query={groupsQuery}
|
||||||
|
toolbarLeft={
|
||||||
|
types.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
|
||||||
|
value={typeFilter || 'all'}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
{types.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { EventsTable } from '@/components/events/table';
|
||||||
|
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
|
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||||
|
)({
|
||||||
|
component: Component,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('Group events') }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId, groupId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||||
|
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
|
const [eventNames] = useEventQueryNamesFilter();
|
||||||
|
const columnVisibility = useReadColumnVisibility('events');
|
||||||
|
|
||||||
|
const query = useInfiniteQuery(
|
||||||
|
trpc.event.events.infiniteQueryOptions(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
groupId,
|
||||||
|
filters: [], // Always scope to group only; date + event names from toolbar still apply
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
events: eventNames,
|
||||||
|
columnVisibility: columnVisibility ?? {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: columnVisibility !== null,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return <EventsTable query={query} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
|
import { UsersIcon } from 'lucide-react';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { GroupMemberGrowth } from '@/components/groups/group-member-growth';
|
||||||
|
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||||
|
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
|
||||||
|
import { MostEvents } from '@/components/profiles/most-events';
|
||||||
|
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
||||||
|
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
||||||
|
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||||
|
import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget';
|
||||||
|
import { WidgetTable } from '@/components/widget-table';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
|
||||||
|
const MEMBERS_PREVIEW_LIMIT = 13;
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||||
|
)({
|
||||||
|
component: Component,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
await Promise.all([
|
||||||
|
context.queryClient.prefetchQuery(
|
||||||
|
context.trpc.group.activity.queryOptions({
|
||||||
|
id: params.groupId,
|
||||||
|
projectId: params.projectId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
pendingComponent: FullPageLoadingState,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('Group') }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId, organizationId, groupId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const group = useSuspenseQuery(
|
||||||
|
trpc.group.byId.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const metrics = useSuspenseQuery(
|
||||||
|
trpc.group.metrics.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const activity = useSuspenseQuery(
|
||||||
|
trpc.group.activity.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const members = useSuspenseQuery(
|
||||||
|
trpc.group.members.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const mostEvents = useSuspenseQuery(
|
||||||
|
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const popularRoutes = useSuspenseQuery(
|
||||||
|
trpc.group.popularRoutes.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
const memberGrowth = useSuspenseQuery(
|
||||||
|
trpc.group.memberGrowth.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
|
||||||
|
const g = group.data;
|
||||||
|
const m = metrics.data;
|
||||||
|
|
||||||
|
if (!g) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = g.properties as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{/* Metrics */}
|
||||||
|
{m && (
|
||||||
|
<div className="col-span-1 md:col-span-2">
|
||||||
|
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={[]}
|
||||||
|
id="totalEvents"
|
||||||
|
isLoading={false}
|
||||||
|
label="Total Events"
|
||||||
|
metric={{ current: m.totalEvents, previous: null }}
|
||||||
|
unit=""
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={[]}
|
||||||
|
id="uniqueMembers"
|
||||||
|
isLoading={false}
|
||||||
|
label="Unique Members"
|
||||||
|
metric={{ current: m.uniqueProfiles, previous: null }}
|
||||||
|
unit=""
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={[]}
|
||||||
|
id="firstSeen"
|
||||||
|
isLoading={false}
|
||||||
|
label="First Seen"
|
||||||
|
metric={{
|
||||||
|
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
|
||||||
|
previous: null,
|
||||||
|
}}
|
||||||
|
unit="timeAgo"
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={[]}
|
||||||
|
id="lastSeen"
|
||||||
|
isLoading={false}
|
||||||
|
label="Last Seen"
|
||||||
|
metric={{
|
||||||
|
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
|
||||||
|
previous: null,
|
||||||
|
}}
|
||||||
|
unit="timeAgo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
<div className="col-span-1 md:col-span-2">
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<div className="title">Group Information</div>
|
||||||
|
</WidgetHead>
|
||||||
|
<KeyValueGrid
|
||||||
|
className="border-0"
|
||||||
|
columns={3}
|
||||||
|
copyable
|
||||||
|
data={[
|
||||||
|
{ name: 'id', value: g.id },
|
||||||
|
{ name: 'name', value: g.name },
|
||||||
|
{ name: 'type', value: g.type },
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
value: formatDateTime(new Date(g.createdAt)),
|
||||||
|
},
|
||||||
|
...Object.entries(properties)
|
||||||
|
.filter(([, v]) => v !== undefined && v !== '')
|
||||||
|
.map(([k, v]) => ({
|
||||||
|
name: k,
|
||||||
|
value: String(v),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity heatmap */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<ProfileActivity data={activity.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member growth */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<GroupMemberGrowth data={memberGrowth.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top events */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<MostEvents data={mostEvents.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popular routes */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<PopularRoutes data={popularRoutes.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members preview */}
|
||||||
|
<div className="col-span-1 md:col-span-2">
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="p-0">
|
||||||
|
{members.data.length === 0 ? (
|
||||||
|
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
|
||||||
|
) : (
|
||||||
|
<WidgetTable
|
||||||
|
columnClassName="px-2"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
name: 'Profile',
|
||||||
|
width: 'w-full',
|
||||||
|
render: (member) => (
|
||||||
|
<Link
|
||||||
|
className="font-mono text-xs hover:underline"
|
||||||
|
params={{
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
profileId: member.profileId,
|
||||||
|
}}
|
||||||
|
to="/$organizationId/$projectId/profiles/$profileId"
|
||||||
|
>
|
||||||
|
{member.profileId}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'events',
|
||||||
|
name: 'Events',
|
||||||
|
width: '60px',
|
||||||
|
className: 'text-muted-foreground',
|
||||||
|
render: (member) => member.eventCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastSeen',
|
||||||
|
name: 'Last Seen',
|
||||||
|
width: '150px',
|
||||||
|
className: 'text-muted-foreground',
|
||||||
|
render: (member) =>
|
||||||
|
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
|
||||||
|
keyExtractor={(member) => member.profileId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
|
||||||
|
<p className="border-t py-2 text-center text-muted-foreground text-xs">
|
||||||
|
{`${members.data.length} members found. View all in Members tab`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ProfilesTable } from '@/components/profiles/table';
|
||||||
|
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||||
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||||
|
)({
|
||||||
|
component: Component,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('Group members') }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId, groupId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { debouncedSearch } = useSearchQueryState();
|
||||||
|
const { page } = useDataTablePagination(50);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
...trpc.group.listProfiles.queryOptions({
|
||||||
|
projectId,
|
||||||
|
groupId,
|
||||||
|
cursor: (page - 1) * 50,
|
||||||
|
take: 50,
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfilesTable
|
||||||
|
pageSize={50}
|
||||||
|
query={query as Parameters<typeof ProfilesTable>[0]['query']}
|
||||||
|
type="profiles"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
Outlet,
|
||||||
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
|
} from '@tanstack/react-router';
|
||||||
|
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { PageContainer } from '@/components/page-container';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { usePageTabs } from '@/hooks/use-page-tabs';
|
||||||
|
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal, showConfirm } from '@/modals';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||||
|
)({
|
||||||
|
component: Component,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
await Promise.all([
|
||||||
|
context.queryClient.prefetchQuery(
|
||||||
|
context.trpc.group.byId.queryOptions({
|
||||||
|
id: params.groupId,
|
||||||
|
projectId: params.projectId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
context.queryClient.prefetchQuery(
|
||||||
|
context.trpc.group.metrics.queryOptions({
|
||||||
|
id: params.groupId,
|
||||||
|
projectId: params.projectId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
pendingComponent: FullPageLoadingState,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('Group') }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { projectId, organizationId, groupId } = Route.useParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const group = useSuspenseQuery(
|
||||||
|
trpc.group.byId.queryOptions({ id: groupId, projectId })
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation(
|
||||||
|
trpc.group.delete.mutationOptions({
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||||
|
navigate({
|
||||||
|
to: '/$organizationId/$projectId/groups',
|
||||||
|
params: { organizationId, projectId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { activeTab, tabs } = usePageTabs([
|
||||||
|
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
|
||||||
|
{ id: 'members', label: 'Members' },
|
||||||
|
{ id: 'events', label: 'Events' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleTabChange = (tabId: string) => {
|
||||||
|
router.navigate({
|
||||||
|
from: Route.fullPath,
|
||||||
|
to: tabId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = group.data;
|
||||||
|
|
||||||
|
if (!g) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
|
||||||
|
<Building2Icon className="size-10 opacity-30" />
|
||||||
|
<p className="text-sm">Group not found</p>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="col">
|
||||||
|
<PageHeader
|
||||||
|
actions={
|
||||||
|
<div className="row gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('EditGroup', {
|
||||||
|
id: g.id,
|
||||||
|
projectId: g.projectId,
|
||||||
|
name: g.name,
|
||||||
|
type: g.type,
|
||||||
|
properties: g.properties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PencilIcon className="mr-2 size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
showConfirm({
|
||||||
|
title: 'Delete group',
|
||||||
|
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
|
||||||
|
onConfirm: () =>
|
||||||
|
deleteMutation.mutate({ id: g.id, projectId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div className="row min-w-0 items-center gap-3">
|
||||||
|
<Building2Icon className="size-6 shrink-0" />
|
||||||
|
<span className="truncate">{g.name}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
className="mt-2 mb-8"
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
value={activeTab}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.id} value={tab.id}>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<Outlet />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
|
|||||||
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
||||||
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
||||||
import { ProfileCharts } from '@/components/profiles/profile-charts';
|
import { ProfileCharts } from '@/components/profiles/profile-charts';
|
||||||
|
import { ProfileGroups } from '@/components/profiles/profile-groups';
|
||||||
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
|
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
|
||||||
import { ProfileProperties } from '@/components/profiles/profile-properties';
|
import { ProfileProperties } from '@/components/profiles/profile-properties';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -103,8 +104,15 @@ function Component() {
|
|||||||
<ProfileMetrics data={metrics.data} />
|
<ProfileMetrics data={metrics.data} />
|
||||||
</div>
|
</div>
|
||||||
{/* Profile properties - full width */}
|
{/* Profile properties - full width */}
|
||||||
<div className="col-span-1 md:col-span-2">
|
<div className="col-span-1 flex flex-col gap-3 md:col-span-2">
|
||||||
<ProfileProperties profile={profile.data!} />
|
<ProfileProperties profile={profile.data!} />
|
||||||
|
{profile.data?.groups?.length ? (
|
||||||
|
<ProfileGroups
|
||||||
|
profileId={profileId}
|
||||||
|
projectId={projectId}
|
||||||
|
groups={profile.data.groups}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heatmap / Activity */}
|
{/* Heatmap / Activity */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
import { EventIcon } from '@/components/events/event-icon';
|
import { EventIcon } from '@/components/events/event-icon';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
@@ -165,6 +165,14 @@ function Component() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: sessionGroups } = useQuery({
|
||||||
|
...trpc.group.listByIds.queryOptions({
|
||||||
|
projectId,
|
||||||
|
ids: session.groups ?? [],
|
||||||
|
}),
|
||||||
|
enabled: (session.groups?.length ?? 0) > 0,
|
||||||
|
});
|
||||||
|
|
||||||
const fakeEvent = sessionToFakeEvent(session);
|
const fakeEvent = sessionToFakeEvent(session);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +332,35 @@ function Component() {
|
|||||||
</Widget>
|
</Widget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Group cards */}
|
||||||
|
{sessionGroups && sessionGroups.length > 0 && (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle>Groups</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="p-0">
|
||||||
|
{sessionGroups.map((group) => (
|
||||||
|
<Link
|
||||||
|
key={group.id}
|
||||||
|
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
|
||||||
|
params={{ organizationId, projectId, groupId: group.id }}
|
||||||
|
to="/$organizationId/$projectId/groups/$groupId"
|
||||||
|
>
|
||||||
|
<div className="col min-w-0 flex-1 gap-0.5">
|
||||||
|
<span className="truncate font-medium">{group.name}</span>
|
||||||
|
<span className="truncate text-muted-foreground text-sm font-mono">
|
||||||
|
{group.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||||
|
{group.type}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Visited pages */}
|
{/* Visited pages */}
|
||||||
<VisitedRoutes
|
<VisitedRoutes
|
||||||
paths={events
|
paths={events
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev';
|
|||||||
export function createTitle(
|
export function createTitle(
|
||||||
pageTitle: string,
|
pageTitle: string,
|
||||||
section?: string,
|
section?: string,
|
||||||
baseTitle = BASE_TITLE,
|
baseTitle = BASE_TITLE
|
||||||
): string {
|
): string {
|
||||||
const parts = [pageTitle];
|
const parts = [pageTitle];
|
||||||
if (section) {
|
if (section) {
|
||||||
@@ -25,7 +25,7 @@ export function createTitle(
|
|||||||
*/
|
*/
|
||||||
export function createOrganizationTitle(
|
export function createOrganizationTitle(
|
||||||
pageTitle: string,
|
pageTitle: string,
|
||||||
organizationName?: string,
|
organizationName?: string
|
||||||
): string {
|
): string {
|
||||||
if (organizationName) {
|
if (organizationName) {
|
||||||
return createTitle(pageTitle, organizationName);
|
return createTitle(pageTitle, organizationName);
|
||||||
@@ -39,7 +39,7 @@ export function createOrganizationTitle(
|
|||||||
export function createProjectTitle(
|
export function createProjectTitle(
|
||||||
pageTitle: string,
|
pageTitle: string,
|
||||||
projectName?: string,
|
projectName?: string,
|
||||||
organizationName?: string,
|
organizationName?: string
|
||||||
): string {
|
): string {
|
||||||
const parts = [pageTitle];
|
const parts = [pageTitle];
|
||||||
if (projectName) {
|
if (projectName) {
|
||||||
@@ -59,7 +59,7 @@ export function createEntityTitle(
|
|||||||
entityName: string,
|
entityName: string,
|
||||||
entityType: string,
|
entityType: string,
|
||||||
projectName?: string,
|
projectName?: string,
|
||||||
organizationName?: string,
|
organizationName?: string
|
||||||
): string {
|
): string {
|
||||||
const parts = [entityName, entityType];
|
const parts = [entityName, entityType];
|
||||||
if (projectName) {
|
if (projectName) {
|
||||||
@@ -95,6 +95,9 @@ export const PAGE_TITLES = {
|
|||||||
PROFILES: 'Profiles',
|
PROFILES: 'Profiles',
|
||||||
PROFILE_EVENTS: 'Profile events',
|
PROFILE_EVENTS: 'Profile events',
|
||||||
PROFILE_DETAILS: 'Profile details',
|
PROFILE_DETAILS: 'Profile details',
|
||||||
|
// Groups
|
||||||
|
GROUPS: 'Groups',
|
||||||
|
GROUP_DETAILS: 'Group details',
|
||||||
|
|
||||||
// Sub-sections
|
// Sub-sections
|
||||||
CONVERSIONS: 'Conversions',
|
CONVERSIONS: 'Conversions',
|
||||||
|
|||||||
4
apps/testbed/.gitignore
vendored
Normal file
4
apps/testbed/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
public/op1.js
|
||||||
|
.env
|
||||||
12
apps/testbed/index.html
Normal file
12
apps/testbed/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Testbed | OpenPanel SDK</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
apps/testbed/package.json
Normal file
24
apps/testbed/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@openpanel/testbed",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 3100",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"postinstall": "node scripts/copy-op1.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@openpanel/web": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/testbed/scripts/copy-op1.mjs
Normal file
16
apps/testbed/scripts/copy-op1.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const src = join(__dirname, '../../public/public/op1.js');
|
||||||
|
const dest = join(__dirname, '../public/op1.js');
|
||||||
|
|
||||||
|
mkdirSync(join(__dirname, '../public'), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
console.log('✓ Copied op1.js to public/');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠ Could not copy op1.js:', e.message);
|
||||||
|
}
|
||||||
217
apps/testbed/src/App.tsx
Normal file
217
apps/testbed/src/App.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
|
import { op } from './analytics';
|
||||||
|
import { CartPage } from './pages/Cart';
|
||||||
|
import { CheckoutPage } from './pages/Checkout';
|
||||||
|
import { LoginPage, PRESET_GROUPS } from './pages/Login';
|
||||||
|
import { ProductPage } from './pages/Product';
|
||||||
|
import { ShopPage } from './pages/Shop';
|
||||||
|
import type { CartItem, Product, User } from './types';
|
||||||
|
|
||||||
|
const PRODUCTS: Product[] = [
|
||||||
|
{ id: 'p1', name: 'Classic T-Shirt', price: 25, category: 'clothing' },
|
||||||
|
{ id: 'p2', name: 'Coffee Mug', price: 15, category: 'accessories' },
|
||||||
|
{ id: 'p3', name: 'Hoodie', price: 60, category: 'clothing' },
|
||||||
|
{ id: 'p4', name: 'Sticker Pack', price: 10, category: 'accessories' },
|
||||||
|
{ id: 'p5', name: 'Cap', price: 35, category: 'clothing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('op_testbed_user');
|
||||||
|
if (stored) {
|
||||||
|
const u = JSON.parse(stored) as User;
|
||||||
|
setUser(u);
|
||||||
|
op.identify({
|
||||||
|
profileId: u.id,
|
||||||
|
firstName: u.firstName,
|
||||||
|
lastName: u.lastName,
|
||||||
|
email: u.email,
|
||||||
|
});
|
||||||
|
applyGroups(u);
|
||||||
|
}
|
||||||
|
op.ready();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function applyGroups(u: User) {
|
||||||
|
op.setGroups(u.groupIds);
|
||||||
|
for (const id of u.groupIds) {
|
||||||
|
const meta = PRESET_GROUPS.find((g) => g.id === id);
|
||||||
|
if (meta) {
|
||||||
|
op.upsertGroup({ id, ...meta });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(u: User) {
|
||||||
|
localStorage.setItem('op_testbed_user', JSON.stringify(u));
|
||||||
|
setUser(u);
|
||||||
|
op.identify({
|
||||||
|
profileId: u.id,
|
||||||
|
firstName: u.firstName,
|
||||||
|
lastName: u.lastName,
|
||||||
|
email: u.email,
|
||||||
|
});
|
||||||
|
applyGroups(u);
|
||||||
|
op.track('user_login', { method: 'form', group_count: u.groupIds.length });
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('op_testbed_user');
|
||||||
|
op.clear();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToCart(product: Product) {
|
||||||
|
setCart((prev) => {
|
||||||
|
const existing = prev.find((i) => i.id === product.id);
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((i) =>
|
||||||
|
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...prev, { ...product, qty: 1 }];
|
||||||
|
});
|
||||||
|
op.track('add_to_cart', {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
category: product.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromCart(id: string) {
|
||||||
|
const item = cart.find((i) => i.id === id);
|
||||||
|
if (item) {
|
||||||
|
op.track('remove_from_cart', {
|
||||||
|
product_id: item.id,
|
||||||
|
product_name: item.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCart((prev) => prev.filter((i) => i.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCheckout() {
|
||||||
|
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||||
|
op.track('checkout_started', {
|
||||||
|
total,
|
||||||
|
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
|
||||||
|
items: cart.map((i) => i.id),
|
||||||
|
});
|
||||||
|
navigate('/checkout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pay(succeed: boolean) {
|
||||||
|
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||||
|
op.track('payment_attempted', { total, success: succeed });
|
||||||
|
|
||||||
|
if (succeed) {
|
||||||
|
op.revenue(total, {
|
||||||
|
items: cart.map((i) => i.id),
|
||||||
|
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
|
||||||
|
});
|
||||||
|
op.track('purchase_completed', { total });
|
||||||
|
setCart([]);
|
||||||
|
navigate('/success');
|
||||||
|
} else {
|
||||||
|
op.track('purchase_failed', { total, reason: 'declined' });
|
||||||
|
navigate('/error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartCount = cart.reduce((sum, i) => sum + i.qty, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<nav className="nav">
|
||||||
|
<Link className="nav-brand" to="/">
|
||||||
|
TESTSTORE
|
||||||
|
</Link>
|
||||||
|
<div className="nav-links">
|
||||||
|
<Link to="/">Shop</Link>
|
||||||
|
<Link to="/cart">Cart ({cartCount})</Link>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span className="nav-user">{user.firstName}</span>
|
||||||
|
<button onClick={logout} type="button">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login">Login</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
element={<ShopPage onAddToCart={addToCart} products={PRODUCTS} />}
|
||||||
|
path="/"
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProductPage onAddToCart={addToCart} products={PRODUCTS} />
|
||||||
|
}
|
||||||
|
path="/product/:id"
|
||||||
|
/>
|
||||||
|
<Route element={<LoginPage onLogin={login} />} path="/login" />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<CartPage
|
||||||
|
cart={cart}
|
||||||
|
onCheckout={startCheckout}
|
||||||
|
onRemove={removeFromCart}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
path="/cart"
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={<CheckoutPage cart={cart} onPay={pay} />}
|
||||||
|
path="/checkout"
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<div className="result-page">
|
||||||
|
<div className="result-icon">[OK]</div>
|
||||||
|
<div className="result-title">Payment successful</div>
|
||||||
|
<p>Your order has been placed. Thanks for testing!</p>
|
||||||
|
<div className="result-actions">
|
||||||
|
<Link to="/">
|
||||||
|
<button className="primary" type="button">
|
||||||
|
Continue shopping
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
path="/success"
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<div className="result-page">
|
||||||
|
<div className="result-icon">[ERR]</div>
|
||||||
|
<div className="result-title">Payment failed</div>
|
||||||
|
<p>Card declined. Try again or go back to cart.</p>
|
||||||
|
<div className="result-actions">
|
||||||
|
<Link to="/checkout">
|
||||||
|
<button type="button">Retry</button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/cart">
|
||||||
|
<button type="button">Back to cart</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
path="/error"
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/testbed/src/analytics.ts
Normal file
10
apps/testbed/src/analytics.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { OpenPanel } from '@openpanel/web';
|
||||||
|
|
||||||
|
export const op = new OpenPanel({
|
||||||
|
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client',
|
||||||
|
apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333',
|
||||||
|
trackScreenViews: true,
|
||||||
|
trackOutgoingLinks: true,
|
||||||
|
trackAttributes: true,
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
10
apps/testbed/src/main.tsx
Normal file
10
apps/testbed/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
63
apps/testbed/src/pages/Cart.tsx
Normal file
63
apps/testbed/src/pages/Cart.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { CartItem } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cart: CartItem[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onCheckout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CartPage({ cart, onRemove, onCheckout }: Props) {
|
||||||
|
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||||
|
|
||||||
|
if (cart.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Cart</div>
|
||||||
|
<div className="cart-empty">Your cart is empty.</div>
|
||||||
|
<Link to="/"><button type="button">← Back to shop</button></Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Cart</div>
|
||||||
|
<table className="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Subtotal</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cart.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>${item.price}</td>
|
||||||
|
<td>{item.qty}</td>
|
||||||
|
<td>${item.price * item.qty}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="danger" onClick={() => onRemove(item.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="cart-summary">
|
||||||
|
<div className="cart-total">Total: ${total}</div>
|
||||||
|
<div className="cart-actions">
|
||||||
|
<Link to="/"><button type="button">← Shop</button></Link>
|
||||||
|
<button type="button" className="primary" onClick={onCheckout}>
|
||||||
|
Checkout →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/testbed/src/pages/Checkout.tsx
Normal file
43
apps/testbed/src/pages/Checkout.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { CartItem } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cart: CartItem[];
|
||||||
|
onPay: (succeed: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CheckoutPage({ cart, onPay }: Props) {
|
||||||
|
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Checkout</div>
|
||||||
|
<div className="checkout-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="card">Card number</label>
|
||||||
|
<input id="card" defaultValue="4242 4242 4242 4242" readOnly />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="expiry">Expiry</label>
|
||||||
|
<input id="expiry" defaultValue="12/28" readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="cvc">CVC</label>
|
||||||
|
<input id="cvc" defaultValue="123" readOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="checkout-total">Total: ${total}</div>
|
||||||
|
<div className="checkout-pay-buttons">
|
||||||
|
<Link to="/cart"><button type="button">← Back</button></Link>
|
||||||
|
<button type="button" className="primary" onClick={() => onPay(true)}>
|
||||||
|
Pay ${total} (success)
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger" onClick={() => onPay(false)}>
|
||||||
|
Pay ${total} (fail)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
apps/testbed/src/pages/Login.tsx
Normal file
186
apps/testbed/src/pages/Login.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Group, User } from '../types';
|
||||||
|
|
||||||
|
export const PRESET_GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_acme',
|
||||||
|
name: 'Acme Corp',
|
||||||
|
properties: { plan: 'enterprise' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_globex',
|
||||||
|
name: 'Globex',
|
||||||
|
properties: { plan: 'pro' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_initech',
|
||||||
|
name: 'Initech',
|
||||||
|
properties: { plan: 'pro' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_umbrella',
|
||||||
|
name: 'Umbrella Ltd',
|
||||||
|
properties: { plan: 'enterprise' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_stark',
|
||||||
|
name: 'Stark Industries',
|
||||||
|
properties: { plan: 'enterprise' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_wayne',
|
||||||
|
name: 'Wayne Enterprises',
|
||||||
|
properties: { plan: 'pro' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_dunder',
|
||||||
|
name: 'Dunder Mifflin',
|
||||||
|
properties: { plan: 'free' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_pied',
|
||||||
|
name: 'Pied Piper',
|
||||||
|
properties: { plan: 'free' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_hooli',
|
||||||
|
name: 'Hooli',
|
||||||
|
properties: { plan: 'pro' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'company',
|
||||||
|
id: 'grp_vandelay',
|
||||||
|
name: 'Vandelay Industries',
|
||||||
|
properties: { plan: 'free' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack'];
|
||||||
|
const LAST_NAMES = ['Smith', 'Jones', 'Brown', 'Taylor', 'Wilson', 'Davis', 'Clark', 'Hall', 'Lewis', 'Young'];
|
||||||
|
|
||||||
|
function randomMock(): User {
|
||||||
|
const first = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
|
||||||
|
const last = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
|
||||||
|
const id = Math.random().toString(36).slice(2, 8);
|
||||||
|
return {
|
||||||
|
id: `usr_${id}`,
|
||||||
|
firstName: first,
|
||||||
|
lastName: last,
|
||||||
|
email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
|
||||||
|
groupIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onLogin: (user: User) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginPage({ onLogin }: Props) {
|
||||||
|
const [form, setForm] = useState<User>(randomMock);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onLogin(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(field: keyof User, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(id: string) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
groupIds: prev.groupIds.includes(id)
|
||||||
|
? prev.groupIds.filter((g) => g !== id)
|
||||||
|
: [...prev.groupIds, id],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Login</div>
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="id">
|
||||||
|
User ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
onChange={(e) => set('id', e.target.value)}
|
||||||
|
required
|
||||||
|
value={form.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="firstName">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
onChange={(e) => set('firstName', e.target.value)}
|
||||||
|
required
|
||||||
|
value={form.firstName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="lastName">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
onChange={(e) => set('lastName', e.target.value)}
|
||||||
|
required
|
||||||
|
value={form.lastName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
onChange={(e) => set('email', e.target.value)}
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-label" style={{ marginBottom: 8 }}>
|
||||||
|
Groups (optional)
|
||||||
|
</div>
|
||||||
|
<div className="group-picker">
|
||||||
|
{PRESET_GROUPS.map((group) => {
|
||||||
|
const selected = form.groupIds.includes(group.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={selected ? 'primary' : ''}
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => toggleGroup(group.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
<span className="group-plan">{group.plan}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="primary" style={{ width: '100%' }} type="submit">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/testbed/src/pages/Product.tsx
Normal file
61
apps/testbed/src/pages/Product.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { op } from '../analytics';
|
||||||
|
import type { Product } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
products: Product[];
|
||||||
|
onAddToCart: (product: Product) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductPage({ products, onAddToCart }: Props) {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const product = products.find((p) => p.id === id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (product) {
|
||||||
|
op.track('product_viewed', {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
category: product.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Product not found</div>
|
||||||
|
<Link to="/"><button type="button">← Back to shop</button></Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Link to="/">← Back to shop</Link>
|
||||||
|
</div>
|
||||||
|
<div className="product-detail">
|
||||||
|
<div className="product-detail-img">[img]</div>
|
||||||
|
<div className="product-detail-info">
|
||||||
|
<div className="product-card-category">{product.category}</div>
|
||||||
|
<div className="product-detail-name">{product.name}</div>
|
||||||
|
<div className="product-detail-price">${product.price}</div>
|
||||||
|
<p className="product-detail-desc">
|
||||||
|
A high quality {product.name.toLowerCase()} for testing purposes.
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipiscing elit.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => onAddToCart(product)}
|
||||||
|
>
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/testbed/src/pages/Shop.tsx
Normal file
36
apps/testbed/src/pages/Shop.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { Product } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
products: Product[];
|
||||||
|
onAddToCart: (product: Product) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShopPage({ products, onAddToCart }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Products</div>
|
||||||
|
<div className="product-grid">
|
||||||
|
{products.map((product) => (
|
||||||
|
<div key={product.id} className="product-card">
|
||||||
|
<div className="product-card-category">{product.category}</div>
|
||||||
|
<Link to={`/product/${product.id}`} className="product-card-name">
|
||||||
|
{product.name}
|
||||||
|
</Link>
|
||||||
|
<div className="product-card-price">${product.price}</div>
|
||||||
|
<div className="product-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onClick={() => onAddToCart(product)}
|
||||||
|
>
|
||||||
|
Add to cart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
apps/testbed/src/styles.css
Normal file
358
apps/testbed/src/styles.css
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--border: 1px solid #999;
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #fff;
|
||||||
|
--text: #111;
|
||||||
|
--muted: #666;
|
||||||
|
--accent: #1a1a1a;
|
||||||
|
--gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
border-color: #c00;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: #c00;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 6px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
border-bottom: var(--border);
|
||||||
|
padding: 12px var(--gap);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
text-decoration: none !important;
|
||||||
|
cursor: default !important;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--gap);
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page common */
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shop */
|
||||||
|
|
||||||
|
.product-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: var(--gap);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-category {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-price {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cart */
|
||||||
|
|
||||||
|
.cart-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table th,
|
||||||
|
.cart-table td {
|
||||||
|
border: var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table th {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: var(--border);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-total {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkout */
|
||||||
|
|
||||||
|
.checkout-form {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: var(--gap);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-total {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-pay-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: var(--gap);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product detail */
|
||||||
|
|
||||||
|
.product-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-img {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-price {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group picker */
|
||||||
|
|
||||||
|
.group-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-picker button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-plan {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-left: 1px solid currentColor;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result pages */
|
||||||
|
|
||||||
|
.result-page {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
23
apps/testbed/src/types.ts
Normal file
23
apps/testbed/src/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export type Product = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CartItem = Product & { qty: number };
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
groupIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
properties: Record<string, string>;
|
||||||
|
};
|
||||||
17
apps/testbed/tsconfig.json
Normal file
17
apps/testbed/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
9
apps/testbed/vite.config.ts
Normal file
9
apps/testbed/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
'process.env': {},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -68,6 +68,11 @@ export async function bootCron() {
|
|||||||
type: 'flushReplay',
|
type: 'flushReplay',
|
||||||
pattern: 1000 * 10,
|
pattern: 1000 * 10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'flush',
|
||||||
|
type: 'flushGroups',
|
||||||
|
pattern: 1000 * 10,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'insightsDaily',
|
name: 'insightsDaily',
|
||||||
type: 'insightsDaily',
|
type: 'insightsDaily',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
|
import { eventBuffer, groupBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
|
||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
@@ -30,6 +30,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'flushReplay': {
|
case 'flushReplay': {
|
||||||
return await replayBuffer.tryFlush();
|
return await replayBuffer.tryFlush();
|
||||||
}
|
}
|
||||||
|
case 'flushGroups': {
|
||||||
|
return await groupBuffer.tryFlush();
|
||||||
|
}
|
||||||
case 'ping': {
|
case 'ping': {
|
||||||
return await ping();
|
return await ping();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export async function incomingEvent(
|
|||||||
__hash: hash,
|
__hash: hash,
|
||||||
__query: query,
|
__query: query,
|
||||||
}),
|
}),
|
||||||
|
groups: body.groups ?? [],
|
||||||
createdAt,
|
createdAt,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
sdkName,
|
sdkName,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ describe('incomingEvent', () => {
|
|||||||
referrerType: '',
|
referrerType: '',
|
||||||
sdkName: jobData.headers['openpanel-sdk-name'],
|
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||||
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
(createEvent as Mock).mockReturnValue(event);
|
(createEvent as Mock).mockReturnValue(event);
|
||||||
@@ -242,6 +243,7 @@ describe('incomingEvent', () => {
|
|||||||
referrerType: '',
|
referrerType: '',
|
||||||
sdkName: jobData.headers['openpanel-sdk-name'],
|
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||||
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
|
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
|
||||||
@@ -312,6 +314,7 @@ describe('incomingEvent', () => {
|
|||||||
screen_views: [],
|
screen_views: [],
|
||||||
sign: 1,
|
sign: 1,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
groups: [],
|
||||||
} satisfies IClickhouseSession);
|
} satisfies IClickhouseSession);
|
||||||
|
|
||||||
await incomingEvent(jobData);
|
await incomingEvent(jobData);
|
||||||
@@ -349,6 +352,7 @@ describe('incomingEvent', () => {
|
|||||||
sdkName: 'server',
|
sdkName: 'server',
|
||||||
sdkVersion: '1.0.0',
|
sdkVersion: '1.0.0',
|
||||||
revenue: undefined,
|
revenue: undefined,
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
||||||
@@ -412,6 +416,7 @@ describe('incomingEvent', () => {
|
|||||||
referrerType: undefined,
|
referrerType: undefined,
|
||||||
sdkName: 'server',
|
sdkName: 'server',
|
||||||
sdkVersion: '1.0.0',
|
sdkVersion: '1.0.0',
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -63,7 +63,8 @@
|
|||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noDelete": "off",
|
"noDelete": "off",
|
||||||
"noAccumulatingSpread": "off"
|
"noAccumulatingSpread": "off",
|
||||||
|
"noBarrelFile": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ services:
|
|||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
op-ch:
|
op-ch:
|
||||||
image: clickhouse/clickhouse-server:25.10.2.65
|
image: clickhouse/clickhouse-server:26.1.3.52
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/data/op-ch-data:/var/lib/clickhouse
|
- ./docker/data/op-ch-data:/var/lib/clickhouse
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path, { dirname } from 'node:path';
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -44,6 +43,65 @@ const extraReferrers = {
|
|||||||
'squarespace.com': { type: 'commerce', name: 'Squarespace' },
|
'squarespace.com': { type: 'commerce', name: 'Squarespace' },
|
||||||
'stackoverflow.com': { type: 'tech', name: 'Stack Overflow' },
|
'stackoverflow.com': { type: 'tech', name: 'Stack Overflow' },
|
||||||
'teams.microsoft.com': { type: 'social', name: 'Microsoft Teams' },
|
'teams.microsoft.com': { type: 'social', name: 'Microsoft Teams' },
|
||||||
|
'chat.com': { type: 'ai', name: 'Chat.com' },
|
||||||
|
'chatgpt.com': { type: 'ai', name: 'ChatGPT' },
|
||||||
|
'openai.com': { type: 'ai', name: 'OpenAI' },
|
||||||
|
'anthropic.com': { type: 'ai', name: 'Anthropic' },
|
||||||
|
'claude.ai': { type: 'ai', name: 'Claude' },
|
||||||
|
'gemini.google.com': { type: 'ai', name: 'Google Gemini' },
|
||||||
|
'bard.google.com': { type: 'ai', name: 'Google Bard' },
|
||||||
|
'copilot.microsoft.com': { type: 'ai', name: 'Microsoft Copilot' },
|
||||||
|
'copilot.cloud.microsoft': { type: 'ai', name: 'Microsoft Copilot' },
|
||||||
|
'perplexity.ai': { type: 'ai', name: 'Perplexity' },
|
||||||
|
'you.com': { type: 'ai', name: 'You.com' },
|
||||||
|
'poe.com': { type: 'ai', name: 'Poe' },
|
||||||
|
'phind.com': { type: 'ai', name: 'Phind' },
|
||||||
|
'huggingface.co': { type: 'ai', name: 'Hugging Face' },
|
||||||
|
'hf.co': { type: 'ai', name: 'Hugging Face' },
|
||||||
|
'character.ai': { type: 'ai', name: 'Character.AI' },
|
||||||
|
'meta.ai': { type: 'ai', name: 'Meta AI' },
|
||||||
|
'mistral.ai': { type: 'ai', name: 'Mistral' },
|
||||||
|
'chat.mistral.ai': { type: 'ai', name: 'Mistral Le Chat' },
|
||||||
|
'deepseek.com': { type: 'ai', name: 'DeepSeek' },
|
||||||
|
'chat.deepseek.com': { type: 'ai', name: 'DeepSeek Chat' },
|
||||||
|
'pi.ai': { type: 'ai', name: 'Pi' },
|
||||||
|
'inflection.ai': { type: 'ai', name: 'Inflection' },
|
||||||
|
'cohere.com': { type: 'ai', name: 'Cohere' },
|
||||||
|
'coral.cohere.com': { type: 'ai', name: 'Cohere Coral' },
|
||||||
|
'jasper.ai': { type: 'ai', name: 'Jasper' },
|
||||||
|
'writesonic.com': { type: 'ai', name: 'Writesonic' },
|
||||||
|
'copy.ai': { type: 'ai', name: 'Copy.ai' },
|
||||||
|
'rytr.me': { type: 'ai', name: 'Rytr' },
|
||||||
|
'notion.ai': { type: 'ai', name: 'Notion AI' },
|
||||||
|
'grammarly.com': { type: 'ai', name: 'Grammarly' },
|
||||||
|
'grok.com': { type: 'ai', name: 'Grok' },
|
||||||
|
'x.ai': { type: 'ai', name: 'xAI' },
|
||||||
|
'aistudio.google.com': { type: 'ai', name: 'Google AI Studio' },
|
||||||
|
'labs.google.com': { type: 'ai', name: 'Google Labs' },
|
||||||
|
'ai.google': { type: 'ai', name: 'Google AI' },
|
||||||
|
'forefront.ai': { type: 'ai', name: 'Forefront' },
|
||||||
|
'together.ai': { type: 'ai', name: 'Together AI' },
|
||||||
|
'groq.com': { type: 'ai', name: 'Groq' },
|
||||||
|
'replicate.com': { type: 'ai', name: 'Replicate' },
|
||||||
|
'vercel.ai': { type: 'ai', name: 'Vercel AI' },
|
||||||
|
'v0.dev': { type: 'ai', name: 'v0' },
|
||||||
|
'bolt.new': { type: 'ai', name: 'Bolt' },
|
||||||
|
'replit.com': { type: 'ai', name: 'Replit' },
|
||||||
|
'cursor.com': { type: 'ai', name: 'Cursor' },
|
||||||
|
'tabnine.com': { type: 'ai', name: 'Tabnine' },
|
||||||
|
'codeium.com': { type: 'ai', name: 'Codeium' },
|
||||||
|
'sourcegraph.com': { type: 'ai', name: 'Sourcegraph Cody' },
|
||||||
|
'kimi.moonshot.cn': { type: 'ai', name: 'Kimi' },
|
||||||
|
'moonshot.ai': { type: 'ai', name: 'Moonshot AI' },
|
||||||
|
'doubao.com': { type: 'ai', name: 'Doubao' },
|
||||||
|
'tongyi.aliyun.com': { type: 'ai', name: 'Tongyi Qianwen' },
|
||||||
|
'yiyan.baidu.com': { type: 'ai', name: 'Ernie Bot' },
|
||||||
|
'chatglm.cn': { type: 'ai', name: 'ChatGLM' },
|
||||||
|
'zhipu.ai': { type: 'ai', name: 'Zhipu AI' },
|
||||||
|
'minimax.chat': { type: 'ai', name: 'MiniMax' },
|
||||||
|
'lmsys.org': { type: 'ai', name: 'LMSYS' },
|
||||||
|
'chat.lmsys.org': { type: 'ai', name: 'LMSYS Chat' },
|
||||||
|
'llama.meta.com': { type: 'ai', name: 'Meta Llama' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function transform(data: any) {
|
function transform(data: any) {
|
||||||
@@ -67,7 +125,7 @@ async function main() {
|
|||||||
// Get document, or throw exception on error
|
// Get document, or throw exception on error
|
||||||
try {
|
try {
|
||||||
const data = await fetch(
|
const data = await fetch(
|
||||||
'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json',
|
'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json'
|
||||||
).then((res) => res.json());
|
).then((res) => res.json());
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -82,11 +140,11 @@ async function main() {
|
|||||||
{
|
{
|
||||||
...transform(data),
|
...transform(data),
|
||||||
...extraReferrers,
|
...extraReferrers,
|
||||||
},
|
}
|
||||||
)} as const;`,
|
)} as const;`,
|
||||||
'export default referrers;',
|
'export default referrers;',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ export const average = (arr: (number | null)[], includeZero = false) => {
|
|||||||
export const sum = (arr: (number | null | undefined)[]): number =>
|
export const sum = (arr: (number | null | undefined)[]): number =>
|
||||||
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
|
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
|
||||||
|
|
||||||
export const min = (arr: (number | null | undefined)[]): number =>
|
export const min = (arr: (number | null | undefined)[]): number => {
|
||||||
Math.min(...arr.filter(isNumber));
|
const filtered = arr.filter(isNumber);
|
||||||
|
if (filtered.length === 0) return 0;
|
||||||
|
return filtered.reduce((a, b) => (b < a ? b : a), filtered[0]!);
|
||||||
|
};
|
||||||
|
|
||||||
export const max = (arr: (number | null | undefined)[]): number =>
|
export const max = (arr: (number | null | undefined)[]): number => {
|
||||||
Math.max(...arr.filter(isNumber));
|
const filtered = arr.filter(isNumber);
|
||||||
|
if (filtered.length === 0) return 0;
|
||||||
|
return filtered.reduce((a, b) => (b > a ? b : a), filtered[0]!);
|
||||||
|
};
|
||||||
|
|
||||||
export const isFloat = (n: number) => n % 1 !== 0;
|
export const isFloat = (n: number) => n % 1 !== 0;
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const chartSegments = {
|
|||||||
event: 'All events',
|
event: 'All events',
|
||||||
user: 'Unique users',
|
user: 'Unique users',
|
||||||
session: 'Unique sessions',
|
session: 'Unique sessions',
|
||||||
|
group: 'Unique groups',
|
||||||
user_average: 'Average users',
|
user_average: 'Average users',
|
||||||
one_event_per_user: 'One event per user',
|
one_event_per_user: 'One event per user',
|
||||||
property_sum: 'Sum of property',
|
property_sum: 'Sum of property',
|
||||||
@@ -195,7 +196,7 @@ export const metrics = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function isMinuteIntervalEnabledByRange(
|
export function isMinuteIntervalEnabledByRange(
|
||||||
range: keyof typeof timeWindows,
|
range: keyof typeof timeWindows
|
||||||
) {
|
) {
|
||||||
return range === '30min' || range === 'lastHour';
|
return range === '30min' || range === 'lastHour';
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,7 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultIntervalByRange(
|
export function getDefaultIntervalByRange(
|
||||||
range: keyof typeof timeWindows,
|
range: keyof typeof timeWindows
|
||||||
): keyof typeof intervals {
|
): keyof typeof intervals {
|
||||||
if (range === '30min' || range === 'lastHour') {
|
if (range === '30min' || range === 'lastHour') {
|
||||||
return 'minute';
|
return 'minute';
|
||||||
@@ -231,7 +232,7 @@ export function getDefaultIntervalByRange(
|
|||||||
|
|
||||||
export function getDefaultIntervalByDates(
|
export function getDefaultIntervalByDates(
|
||||||
startDate: string | null,
|
startDate: string | null,
|
||||||
endDate: string | null,
|
endDate: string | null
|
||||||
): null | keyof typeof intervals {
|
): null | keyof typeof intervals {
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
if (isSameDay(startDate, endDate)) {
|
if (isSameDay(startDate, endDate)) {
|
||||||
|
|||||||
66
packages/db/code-migrations/11-add-groups.ts
Normal file
66
packages/db/code-migrations/11-add-groups.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { TABLE_NAMES } from '../src/clickhouse/client';
|
||||||
|
import {
|
||||||
|
addColumns,
|
||||||
|
createTable,
|
||||||
|
runClickhouseMigrationCommands,
|
||||||
|
} from '../src/clickhouse/migration';
|
||||||
|
import { getIsCluster } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
const isClustered = getIsCluster();
|
||||||
|
|
||||||
|
const sqls: string[] = [
|
||||||
|
...addColumns(
|
||||||
|
'events',
|
||||||
|
['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER session_id'],
|
||||||
|
isClustered
|
||||||
|
),
|
||||||
|
...addColumns(
|
||||||
|
'sessions',
|
||||||
|
['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER device_id'],
|
||||||
|
isClustered
|
||||||
|
),
|
||||||
|
...addColumns(
|
||||||
|
'profiles',
|
||||||
|
['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER project_id'],
|
||||||
|
isClustered
|
||||||
|
),
|
||||||
|
...createTable({
|
||||||
|
name: TABLE_NAMES.groups,
|
||||||
|
columns: [
|
||||||
|
'`id` String',
|
||||||
|
'`project_id` String',
|
||||||
|
'`type` String',
|
||||||
|
'`name` String',
|
||||||
|
'`properties` Map(String, String)',
|
||||||
|
'`created_at` DateTime',
|
||||||
|
'`version` UInt64',
|
||||||
|
'`deleted` UInt8 DEFAULT 0',
|
||||||
|
],
|
||||||
|
engine: 'ReplacingMergeTree(version, deleted)',
|
||||||
|
orderBy: ['project_id', 'id'],
|
||||||
|
distributionHash: 'cityHash64(project_id, id)',
|
||||||
|
replicatedVersion: '1',
|
||||||
|
isClustered,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(import.meta.filename.replace('.ts', '.sql')),
|
||||||
|
sqls
|
||||||
|
.map((sql) =>
|
||||||
|
sql
|
||||||
|
.trim()
|
||||||
|
.replace(/;$/, '')
|
||||||
|
.replace(/\n{2,}/g, '\n')
|
||||||
|
.concat(';')
|
||||||
|
)
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!process.argv.includes('--dry')) {
|
||||||
|
await runClickhouseMigrationCommands(sqls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
export * from './src/prisma-client';
|
|
||||||
export * from './src/clickhouse/client';
|
|
||||||
export * from './src/sql-builder';
|
|
||||||
export * from './src/services/chart.service';
|
|
||||||
export * from './src/engine';
|
|
||||||
export * from './src/services/clients.service';
|
|
||||||
export * from './src/services/dashboard.service';
|
|
||||||
export * from './src/services/event.service';
|
|
||||||
export * from './src/services/organization.service';
|
|
||||||
export * from './src/services/profile.service';
|
|
||||||
export * from './src/services/project.service';
|
|
||||||
export * from './src/services/reports.service';
|
|
||||||
export * from './src/services/salt.service';
|
|
||||||
export * from './src/services/share.service';
|
|
||||||
export * from './src/services/session.service';
|
|
||||||
export * from './src/services/funnel.service';
|
|
||||||
export * from './src/services/conversion.service';
|
|
||||||
export * from './src/services/sankey.service';
|
|
||||||
export * from './src/services/user.service';
|
|
||||||
export * from './src/services/reference.service';
|
|
||||||
export * from './src/services/id.service';
|
|
||||||
export * from './src/services/retention.service';
|
|
||||||
export * from './src/services/notification.service';
|
|
||||||
export * from './src/services/access.service';
|
|
||||||
export * from './src/services/delete.service';
|
|
||||||
export * from './src/buffers';
|
export * from './src/buffers';
|
||||||
export * from './src/types';
|
export * from './src/clickhouse/client';
|
||||||
export * from './src/clickhouse/query-builder';
|
export * from './src/clickhouse/query-builder';
|
||||||
|
export * from './src/encryption';
|
||||||
|
export * from './src/engine';
|
||||||
|
export * from './src/engine';
|
||||||
|
export * from './src/gsc';
|
||||||
|
export * from './src/prisma-client';
|
||||||
|
export * from './src/services/access.service';
|
||||||
|
export * from './src/services/chart.service';
|
||||||
|
export * from './src/services/clients.service';
|
||||||
|
export * from './src/services/conversion.service';
|
||||||
|
export * from './src/services/dashboard.service';
|
||||||
|
export * from './src/services/delete.service';
|
||||||
|
export * from './src/services/event.service';
|
||||||
|
export * from './src/services/funnel.service';
|
||||||
|
export * from './src/services/group.service';
|
||||||
|
export * from './src/services/id.service';
|
||||||
export * from './src/services/import.service';
|
export * from './src/services/import.service';
|
||||||
|
export * from './src/services/insights';
|
||||||
|
export * from './src/services/notification.service';
|
||||||
|
export * from './src/services/organization.service';
|
||||||
export * from './src/services/overview.service';
|
export * from './src/services/overview.service';
|
||||||
export * from './src/services/pages.service';
|
export * from './src/services/pages.service';
|
||||||
export * from './src/services/insights';
|
export * from './src/services/profile.service';
|
||||||
|
export * from './src/services/project.service';
|
||||||
|
export * from './src/services/reference.service';
|
||||||
|
export * from './src/services/reports.service';
|
||||||
|
export * from './src/services/retention.service';
|
||||||
|
export * from './src/services/salt.service';
|
||||||
|
export * from './src/services/sankey.service';
|
||||||
|
export * from './src/services/session.service';
|
||||||
|
export * from './src/services/share.service';
|
||||||
|
export * from './src/services/user.service';
|
||||||
export * from './src/session-context';
|
export * from './src/session-context';
|
||||||
export * from './src/gsc';
|
export * from './src/sql-builder';
|
||||||
export * from './src/encryption';
|
export * from './src/types';
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ model Project {
|
|||||||
meta EventMeta[]
|
meta EventMeta[]
|
||||||
references Reference[]
|
references Reference[]
|
||||||
access ProjectAccess[]
|
access ProjectAccess[]
|
||||||
|
|
||||||
notificationRules NotificationRule[]
|
notificationRules NotificationRule[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
imports Import[]
|
imports Import[]
|
||||||
@@ -215,6 +214,7 @@ model Project {
|
|||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum AccessLevel {
|
enum AccessLevel {
|
||||||
read
|
read
|
||||||
write
|
write
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { getSafeJson } from '@openpanel/json';
|
import { getSafeJson } from '@openpanel/json';
|
||||||
import {
|
import { getRedisCache, publishEvent, type Redis } from '@openpanel/redis';
|
||||||
type Redis,
|
|
||||||
getRedisCache,
|
|
||||||
publishEvent,
|
|
||||||
} from '@openpanel/redis';
|
|
||||||
import { ch } from '../clickhouse/client';
|
import { ch } from '../clickhouse/client';
|
||||||
import { type IClickhouseEvent } from '../services/event.service';
|
import type { IClickhouseEvent } from '../services/event.service';
|
||||||
import { BaseBuffer } from './base-buffer';
|
import { BaseBuffer } from './base-buffer';
|
||||||
|
|
||||||
export class EventBuffer extends BaseBuffer {
|
export class EventBuffer extends BaseBuffer {
|
||||||
@@ -95,7 +91,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
this.incrementActiveVisitorCount(
|
this.incrementActiveVisitorCount(
|
||||||
multi,
|
multi,
|
||||||
event.project_id,
|
event.project_id,
|
||||||
event.profile_id,
|
event.profile_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +112,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
error,
|
error,
|
||||||
eventCount: eventsToFlush.length,
|
eventCount: eventsToFlush.length,
|
||||||
flushRetryCount: this.flushRetryCount,
|
flushRetryCount: this.flushRetryCount,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.isFlushing = false;
|
this.isFlushing = false;
|
||||||
@@ -137,7 +133,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
const queueEvents = await redis.lrange(
|
const queueEvents = await redis.lrange(
|
||||||
this.queueKey,
|
this.queueKey,
|
||||||
0,
|
0,
|
||||||
this.batchSize - 1,
|
this.batchSize - 1
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queueEvents.length === 0) {
|
if (queueEvents.length === 0) {
|
||||||
@@ -149,6 +145,9 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
for (const eventStr of queueEvents) {
|
for (const eventStr of queueEvents) {
|
||||||
const event = getSafeJson<IClickhouseEvent>(eventStr);
|
const event = getSafeJson<IClickhouseEvent>(eventStr);
|
||||||
if (event) {
|
if (event) {
|
||||||
|
if (!Array.isArray(event.groups)) {
|
||||||
|
event.groups = [];
|
||||||
|
}
|
||||||
eventsToClickhouse.push(event);
|
eventsToClickhouse.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +160,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
eventsToClickhouse.sort(
|
eventsToClickhouse.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.created_at || 0).getTime() -
|
new Date(a.created_at || 0).getTime() -
|
||||||
new Date(b.created_at || 0).getTime(),
|
new Date(b.created_at || 0).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.info('Inserting events into ClickHouse', {
|
this.logger.info('Inserting events into ClickHouse', {
|
||||||
@@ -181,7 +180,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
for (const event of eventsToClickhouse) {
|
for (const event of eventsToClickhouse) {
|
||||||
countByProject.set(
|
countByProject.set(
|
||||||
event.project_id,
|
event.project_id,
|
||||||
(countByProject.get(event.project_id) ?? 0) + 1,
|
(countByProject.get(event.project_id) ?? 0) + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const [projectId, count] of countByProject) {
|
for (const [projectId, count] of countByProject) {
|
||||||
@@ -222,7 +221,7 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
private incrementActiveVisitorCount(
|
private incrementActiveVisitorCount(
|
||||||
multi: ReturnType<Redis['multi']>,
|
multi: ReturnType<Redis['multi']>,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
profileId: string,
|
profileId: string
|
||||||
) {
|
) {
|
||||||
const key = `${projectId}:${profileId}`;
|
const key = `${projectId}:${profileId}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
195
packages/db/src/buffers/group-buffer.ts
Normal file
195
packages/db/src/buffers/group-buffer.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { toDots } from '@openpanel/common';
|
||||||
|
import { getSafeJson } from '@openpanel/json';
|
||||||
|
import { getRedisCache, type Redis } from '@openpanel/redis';
|
||||||
|
import shallowEqual from 'fast-deep-equal';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
|
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
|
||||||
|
import { BaseBuffer } from './base-buffer';
|
||||||
|
|
||||||
|
type IGroupBufferEntry = {
|
||||||
|
project_id: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
version: string;
|
||||||
|
deleted: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IGroupCacheEntry = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IGroupBufferInput = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GroupBuffer extends BaseBuffer {
|
||||||
|
private batchSize = process.env.GROUP_BUFFER_BATCH_SIZE
|
||||||
|
? Number.parseInt(process.env.GROUP_BUFFER_BATCH_SIZE, 10)
|
||||||
|
: 200;
|
||||||
|
private chunkSize = process.env.GROUP_BUFFER_CHUNK_SIZE
|
||||||
|
? Number.parseInt(process.env.GROUP_BUFFER_CHUNK_SIZE, 10)
|
||||||
|
: 1000;
|
||||||
|
private ttlInSeconds = process.env.GROUP_BUFFER_TTL_IN_SECONDS
|
||||||
|
? Number.parseInt(process.env.GROUP_BUFFER_TTL_IN_SECONDS, 10)
|
||||||
|
: 60 * 60;
|
||||||
|
|
||||||
|
private readonly redisKey = 'group-buffer';
|
||||||
|
private readonly redisCachePrefix = 'group-cache:';
|
||||||
|
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: 'group',
|
||||||
|
onFlush: async () => {
|
||||||
|
await this.processBuffer();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.redis = getRedisCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheKey(projectId: string, id: string) {
|
||||||
|
return `${this.redisCachePrefix}${projectId}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFromCache(
|
||||||
|
projectId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<IGroupCacheEntry | null> {
|
||||||
|
const raw = await this.redis.get(this.getCacheKey(projectId, id));
|
||||||
|
if (!raw) return null;
|
||||||
|
return getSafeJson<IGroupCacheEntry>(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFromClickhouse(
|
||||||
|
projectId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<IGroupCacheEntry | null> {
|
||||||
|
const rows = await chQuery<IGroupCacheEntry>(`
|
||||||
|
SELECT project_id, id, type, name, properties, created_at
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND id = ${sqlstring.escape(id)}
|
||||||
|
AND deleted = 0
|
||||||
|
`);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(input: IGroupBufferInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getCacheKey(input.projectId, input.id);
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
(await this.fetchFromCache(input.projectId, input.id)) ??
|
||||||
|
(await this.fetchFromClickhouse(input.projectId, input.id));
|
||||||
|
|
||||||
|
const mergedProperties = toDots({
|
||||||
|
...(existing?.properties ?? {}),
|
||||||
|
...(input.properties ?? {}),
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
const entry: IGroupBufferEntry = {
|
||||||
|
project_id: input.projectId,
|
||||||
|
id: input.id,
|
||||||
|
type: input.type,
|
||||||
|
name: input.name,
|
||||||
|
properties: mergedProperties,
|
||||||
|
created_at: formatClickhouseDate(
|
||||||
|
existing?.created_at ? new Date(existing.created_at) : new Date()
|
||||||
|
),
|
||||||
|
version: String(Date.now()),
|
||||||
|
deleted: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.type === entry.type &&
|
||||||
|
existing.name === entry.name &&
|
||||||
|
shallowEqual(existing.properties, entry.properties)
|
||||||
|
) {
|
||||||
|
this.logger.debug('Group not changed, skipping', { id: input.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheEntry: IGroupCacheEntry = {
|
||||||
|
id: entry.id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
type: entry.type,
|
||||||
|
name: entry.name,
|
||||||
|
properties: entry.properties,
|
||||||
|
created_at: entry.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.redis
|
||||||
|
.multi()
|
||||||
|
.set(cacheKey, JSON.stringify(cacheEntry), 'EX', this.ttlInSeconds)
|
||||||
|
.rpush(this.redisKey, JSON.stringify(entry))
|
||||||
|
.incr(this.bufferCounterKey)
|
||||||
|
.llen(this.redisKey)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
this.logger.error('Failed to add group to Redis', { input });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferLength = (result?.[3]?.[1] as number) ?? 0;
|
||||||
|
if (bufferLength >= this.batchSize) {
|
||||||
|
await this.tryFlush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to add group', { error, input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processBuffer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting group buffer processing');
|
||||||
|
const items = await this.redis.lrange(this.redisKey, 0, this.batchSize - 1);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
this.logger.debug('No groups to process');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Processing ${items.length} groups in buffer`);
|
||||||
|
const parsed = items.map((i) => getSafeJson<IGroupBufferEntry>(i));
|
||||||
|
|
||||||
|
for (const chunk of this.chunks(parsed, this.chunkSize)) {
|
||||||
|
await ch.insert({
|
||||||
|
table: TABLE_NAMES.groups,
|
||||||
|
values: chunk,
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis
|
||||||
|
.multi()
|
||||||
|
.ltrim(this.redisKey, items.length, -1)
|
||||||
|
.decrby(this.bufferCounterKey, items.length)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
this.logger.debug('Successfully completed group processing', {
|
||||||
|
totalGroups: items.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to process buffer', { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBufferSize(): Promise<number> {
|
||||||
|
return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BotBuffer as BotBufferRedis } from './bot-buffer';
|
import { BotBuffer as BotBufferRedis } from './bot-buffer';
|
||||||
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
||||||
|
import { GroupBuffer } from './group-buffer';
|
||||||
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
||||||
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
||||||
import { ReplayBuffer } from './replay-buffer';
|
import { ReplayBuffer } from './replay-buffer';
|
||||||
@@ -11,6 +12,7 @@ export const botBuffer = new BotBufferRedis();
|
|||||||
export const sessionBuffer = new SessionBuffer();
|
export const sessionBuffer = new SessionBuffer();
|
||||||
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
||||||
export const replayBuffer = new ReplayBuffer();
|
export const replayBuffer = new ReplayBuffer();
|
||||||
|
export const groupBuffer = new GroupBuffer();
|
||||||
|
|
||||||
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
||||||
export type { IClickhouseSessionReplayChunk } from './replay-buffer';
|
export type { IClickhouseSessionReplayChunk } from './replay-buffer';
|
||||||
|
|||||||
151
packages/db/src/buffers/profile-buffer.test.ts
Normal file
151
packages/db/src/buffers/profile-buffer.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getSafeJson } from '@openpanel/json';
|
||||||
|
import type { IClickhouseProfile } from '../services/profile.service';
|
||||||
|
|
||||||
|
// Mock chQuery to avoid hitting real ClickHouse
|
||||||
|
vi.mock('../clickhouse/client', () => ({
|
||||||
|
ch: {
|
||||||
|
insert: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
chQuery: vi.fn().mockResolvedValue([]),
|
||||||
|
TABLE_NAMES: {
|
||||||
|
profiles: 'profiles',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProfileBuffer } from './profile-buffer';
|
||||||
|
import { chQuery } from '../clickhouse/client';
|
||||||
|
|
||||||
|
const redis = getRedisCache();
|
||||||
|
|
||||||
|
function makeProfile(overrides: Partial<IClickhouseProfile>): IClickhouseProfile {
|
||||||
|
return {
|
||||||
|
id: 'profile-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
avatar: '',
|
||||||
|
properties: {},
|
||||||
|
is_external: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
groups: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await redis.flushdb();
|
||||||
|
vi.mocked(chQuery).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
await redis.quit();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProfileBuffer', () => {
|
||||||
|
let profileBuffer: ProfileBuffer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
profileBuffer = new ProfileBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a profile to the buffer', async () => {
|
||||||
|
const profile = makeProfile({ first_name: 'John', email: 'john@example.com' });
|
||||||
|
|
||||||
|
const sizeBefore = await profileBuffer.getBufferSize();
|
||||||
|
await profileBuffer.add(profile);
|
||||||
|
const sizeAfter = await profileBuffer.getBufferSize();
|
||||||
|
|
||||||
|
expect(sizeAfter).toBe(sizeBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges subsequent updates via cache (sequential calls)', async () => {
|
||||||
|
const identifyProfile = makeProfile({
|
||||||
|
first_name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupProfile = makeProfile({
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
groups: ['group-abc'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sequential: identify first, then group
|
||||||
|
await profileBuffer.add(identifyProfile);
|
||||||
|
await profileBuffer.add(groupProfile);
|
||||||
|
|
||||||
|
// Second add should read the cached identify profile and merge groups in
|
||||||
|
const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1');
|
||||||
|
expect(cached?.first_name).toBe('John');
|
||||||
|
expect(cached?.email).toBe('john@example.com');
|
||||||
|
expect(cached?.groups).toContain('group-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('race condition: concurrent identify + group calls preserve all data', async () => {
|
||||||
|
const identifyProfile = makeProfile({
|
||||||
|
first_name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupProfile = makeProfile({
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
groups: ['group-abc'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both calls run concurrently — the per-profile lock serializes them so the
|
||||||
|
// second one reads the first's result from cache and merges correctly.
|
||||||
|
await Promise.all([
|
||||||
|
profileBuffer.add(identifyProfile),
|
||||||
|
profileBuffer.add(groupProfile),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1');
|
||||||
|
|
||||||
|
expect(cached?.first_name).toBe('John');
|
||||||
|
expect(cached?.email).toBe('john@example.com');
|
||||||
|
expect(cached?.groups).toContain('group-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('race condition: concurrent writes produce one merged buffer entry', async () => {
|
||||||
|
const identifyProfile = makeProfile({
|
||||||
|
first_name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupProfile = makeProfile({
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
groups: ['group-abc'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeBefore = await profileBuffer.getBufferSize();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
profileBuffer.add(identifyProfile),
|
||||||
|
profileBuffer.add(groupProfile),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sizeAfter = await profileBuffer.getBufferSize();
|
||||||
|
|
||||||
|
// The second add merges into the first — only 2 buffer entries total
|
||||||
|
// (one from identify, one merged update with group)
|
||||||
|
expect(sizeAfter).toBe(sizeBefore + 2);
|
||||||
|
|
||||||
|
// The last entry in the buffer should have both name and group
|
||||||
|
const rawEntries = await redis.lrange('profile-buffer', 0, -1);
|
||||||
|
const entries = rawEntries.map((e) => getSafeJson<IClickhouseProfile>(e));
|
||||||
|
const lastEntry = entries[entries.length - 1];
|
||||||
|
|
||||||
|
expect(lastEntry?.first_name).toBe('John');
|
||||||
|
expect(lastEntry?.groups).toContain('group-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { deepMergeObjects } from '@openpanel/common';
|
import { deepMergeObjects } from '@openpanel/common';
|
||||||
|
import { generateSecureId } from '@openpanel/common/server';
|
||||||
import { getSafeJson } from '@openpanel/json';
|
import { getSafeJson } from '@openpanel/json';
|
||||||
import type { ILogger } from '@openpanel/logger';
|
import type { ILogger } from '@openpanel/logger';
|
||||||
import { getRedisCache, type Redis } from '@openpanel/redis';
|
import { getRedisCache, type Redis } from '@openpanel/redis';
|
||||||
import shallowEqual from 'fast-deep-equal';
|
import shallowEqual from 'fast-deep-equal';
|
||||||
import { omit } from 'ramda';
|
import { omit, uniq } from 'ramda';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client';
|
import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client';
|
||||||
import type { IClickhouseProfile } from '../services/profile.service';
|
import type { IClickhouseProfile } from '../services/profile.service';
|
||||||
@@ -24,6 +25,15 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
private readonly redisProfilePrefix = 'profile-cache:';
|
private readonly redisProfilePrefix = 'profile-cache:';
|
||||||
|
|
||||||
private redis: Redis;
|
private redis: Redis;
|
||||||
|
private releaseLockSha: string | null = null;
|
||||||
|
|
||||||
|
private readonly releaseLockScript = `
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
@@ -33,6 +43,9 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.redis = getRedisCache();
|
this.redis = getRedisCache();
|
||||||
|
this.redis.script('LOAD', this.releaseLockScript).then((sha) => {
|
||||||
|
this.releaseLockSha = sha as string;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProfileCacheKey({
|
private getProfileCacheKey({
|
||||||
@@ -45,6 +58,42 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
return `${this.redisProfilePrefix}${projectId}:${profileId}`;
|
return `${this.redisProfilePrefix}${projectId}:${profileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async withProfileLock<T>(
|
||||||
|
profileId: string,
|
||||||
|
projectId: string,
|
||||||
|
fn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const lockKey = `profile-lock:${projectId}:${profileId}`;
|
||||||
|
const lockId = generateSecureId('lock');
|
||||||
|
const maxRetries = 20;
|
||||||
|
const retryDelayMs = 50;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
const acquired = await this.redis.set(lockKey, lockId, 'EX', 5, 'NX');
|
||||||
|
if (acquired === 'OK') {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (this.releaseLockSha) {
|
||||||
|
await this.redis.evalsha(this.releaseLockSha, 1, lockKey, lockId);
|
||||||
|
} else {
|
||||||
|
await this.redis.eval(this.releaseLockScript, 1, lockKey, lockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
'Failed to acquire profile lock, proceeding without lock',
|
||||||
|
{
|
||||||
|
profileId,
|
||||||
|
projectId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
async alreadyExists(profile: IClickhouseProfile) {
|
async alreadyExists(profile: IClickhouseProfile) {
|
||||||
const cacheKey = this.getProfileCacheKey({
|
const cacheKey = this.getProfileCacheKey({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
@@ -67,83 +116,94 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingProfile = await this.fetchProfile(profile, logger);
|
await this.withProfileLock(profile.id, profile.project_id, async () => {
|
||||||
|
const existingProfile = await this.fetchProfile(profile, logger);
|
||||||
|
|
||||||
// Delete any properties that are not server related if we have a non-server profile
|
// Delete any properties that are not server related if we have a non-server profile
|
||||||
if (
|
if (
|
||||||
existingProfile?.properties.device !== 'server' &&
|
existingProfile?.properties.device !== 'server' &&
|
||||||
profile.properties.device === 'server'
|
profile.properties.device === 'server'
|
||||||
) {
|
) {
|
||||||
profile.properties = omit(
|
profile.properties = omit(
|
||||||
[
|
[
|
||||||
'city',
|
'city',
|
||||||
'country',
|
'country',
|
||||||
'region',
|
'region',
|
||||||
'longitude',
|
'longitude',
|
||||||
'latitude',
|
'latitude',
|
||||||
'os',
|
'os',
|
||||||
'osVersion',
|
'osVersion',
|
||||||
'browser',
|
'browser',
|
||||||
'device',
|
'device',
|
||||||
'isServer',
|
'isServer',
|
||||||
'os_version',
|
'os_version',
|
||||||
'browser_version',
|
'browser_version',
|
||||||
],
|
],
|
||||||
profile.properties
|
profile.properties
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedProfile: IClickhouseProfile = existingProfile
|
const mergedProfile: IClickhouseProfile = existingProfile
|
||||||
? deepMergeObjects(existingProfile, omit(['created_at'], profile))
|
? {
|
||||||
: profile;
|
...deepMergeObjects(
|
||||||
|
existingProfile,
|
||||||
|
omit(['created_at', 'groups'], profile)
|
||||||
|
),
|
||||||
|
groups: uniq([
|
||||||
|
...(existingProfile.groups ?? []),
|
||||||
|
...(profile.groups ?? []),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
: profile;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
profile &&
|
profile &&
|
||||||
existingProfile &&
|
existingProfile &&
|
||||||
shallowEqual(
|
shallowEqual(
|
||||||
omit(['created_at'], existingProfile),
|
omit(['created_at'], existingProfile),
|
||||||
omit(['created_at'], mergedProfile)
|
omit(['created_at'], mergedProfile)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.logger.debug('Profile not changed, skipping');
|
this.logger.debug('Profile not changed, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Merged profile will be inserted', {
|
this.logger.debug('Merged profile will be inserted', {
|
||||||
mergedProfile,
|
mergedProfile,
|
||||||
existingProfile,
|
existingProfile,
|
||||||
profile,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cacheKey = this.getProfileCacheKey({
|
|
||||||
profileId: profile.id,
|
|
||||||
projectId: profile.project_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await this.redis
|
|
||||||
.multi()
|
|
||||||
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds)
|
|
||||||
.rpush(this.redisKey, JSON.stringify(mergedProfile))
|
|
||||||
.incr(this.bufferCounterKey)
|
|
||||||
.llen(this.redisKey)
|
|
||||||
.exec();
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
this.logger.error('Failed to add profile to Redis', {
|
|
||||||
profile,
|
profile,
|
||||||
cacheKey,
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bufferLength = (result?.[3]?.[1] as number) ?? 0;
|
|
||||||
|
|
||||||
this.logger.debug('Current buffer length', {
|
const cacheKey = this.getProfileCacheKey({
|
||||||
bufferLength,
|
profileId: profile.id,
|
||||||
batchSize: this.batchSize,
|
projectId: profile.project_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.redis
|
||||||
|
.multi()
|
||||||
|
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds)
|
||||||
|
.rpush(this.redisKey, JSON.stringify(mergedProfile))
|
||||||
|
.incr(this.bufferCounterKey)
|
||||||
|
.llen(this.redisKey)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
this.logger.error('Failed to add profile to Redis', {
|
||||||
|
profile,
|
||||||
|
cacheKey,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bufferLength = (result?.[3]?.[1] as number) ?? 0;
|
||||||
|
|
||||||
|
this.logger.debug('Current buffer length', {
|
||||||
|
bufferLength,
|
||||||
|
batchSize: this.batchSize,
|
||||||
|
});
|
||||||
|
if (bufferLength >= this.batchSize) {
|
||||||
|
await this.tryFlush();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (bufferLength >= this.batchSize) {
|
|
||||||
await this.tryFlush();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to add profile', { error, profile });
|
this.logger.error('Failed to add profile', { error, profile });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
newSession.profile_id = event.profile_id;
|
newSession.profile_id = event.profile_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.groups) {
|
||||||
|
newSession.groups = [
|
||||||
|
...new Set([...newSession.groups, ...event.groups]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [newSession, oldSession];
|
return [newSession, oldSession];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +125,7 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
project_id: event.project_id,
|
project_id: event.project_id,
|
||||||
device_id: event.device_id,
|
device_id: event.device_id,
|
||||||
|
groups: event.groups,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
ended_at: event.created_at,
|
ended_at: event.created_at,
|
||||||
event_count: event.name === 'screen_view' ? 0 : 1,
|
event_count: event.name === 'screen_view' ? 0 : 1,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const TABLE_NAMES = {
|
|||||||
gsc_daily: 'gsc_daily',
|
gsc_daily: 'gsc_daily',
|
||||||
gsc_pages_daily: 'gsc_pages_daily',
|
gsc_pages_daily: 'gsc_pages_daily',
|
||||||
gsc_queries_daily: 'gsc_queries_daily',
|
gsc_queries_daily: 'gsc_queries_daily',
|
||||||
|
groups: 'groups',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,3 +299,11 @@ const ROLLUP_DATE_PREFIX = '1970-01-01';
|
|||||||
export function isClickhouseDefaultMinDate(date: string): boolean {
|
export function isClickhouseDefaultMinDate(date: string): boolean {
|
||||||
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
|
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
|
||||||
}
|
}
|
||||||
|
export function toNullIfDefaultMinDate(date?: string | null): Date | null {
|
||||||
|
if (!date) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isClickhouseDefaultMinDate(date)
|
||||||
|
? null
|
||||||
|
: convertClickhouseDateToJs(date);
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class Query<T = any> {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
private _skipNext = false;
|
private _skipNext = false;
|
||||||
|
private _rawJoins: string[] = [];
|
||||||
private _fill?: {
|
private _fill?: {
|
||||||
from: string | Date;
|
from: string | Date;
|
||||||
to: string | Date;
|
to: string | Date;
|
||||||
@@ -329,6 +330,12 @@ export class Query<T = any> {
|
|||||||
return this.joinWithType('CROSS', table, '', alias);
|
return this.joinWithType('CROSS', table, '', alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rawJoin(sql: string): this {
|
||||||
|
if (this._skipNext) return this;
|
||||||
|
this._rawJoins.push(sql);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
private joinWithType(
|
private joinWithType(
|
||||||
type: JoinType,
|
type: JoinType,
|
||||||
table: string | Expression | Query,
|
table: string | Expression | Query,
|
||||||
@@ -414,6 +421,10 @@ export class Query<T = any> {
|
|||||||
`${join.type} JOIN ${join.table instanceof Query ? `(${join.table.toSQL()})` : join.table instanceof Expression ? `(${join.table.toString()})` : join.table}${aliasClause}${conditionStr}`,
|
`${join.type} JOIN ${join.table instanceof Query ? `(${join.table.toSQL()})` : join.table instanceof Expression ? `(${join.table.toString()})` : join.table}${aliasClause}${conditionStr}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// Add raw joins (e.g. ARRAY JOIN)
|
||||||
|
this._rawJoins.forEach((join) => {
|
||||||
|
parts.push(join);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// WHERE
|
// WHERE
|
||||||
@@ -590,6 +601,7 @@ export class Query<T = any> {
|
|||||||
|
|
||||||
// Merge JOINS
|
// Merge JOINS
|
||||||
this._joins = [...this._joins, ...query._joins];
|
this._joins = [...this._joins, ...query._joins];
|
||||||
|
this._rawJoins = [...this._rawJoins, ...query._rawJoins];
|
||||||
|
|
||||||
// Merge settings
|
// Merge settings
|
||||||
this._settings = { ...this._settings, ...query._settings };
|
this._settings = { ...this._settings, ...query._settings };
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function format(
|
|||||||
}>,
|
}>,
|
||||||
includeAlphaIds: boolean,
|
includeAlphaIds: boolean,
|
||||||
previousSeries: ConcreteSeries[] | null = null,
|
previousSeries: ConcreteSeries[] | null = null,
|
||||||
limit: number | undefined = undefined,
|
limit: number | undefined = undefined
|
||||||
): FinalChart {
|
): FinalChart {
|
||||||
const series = concreteSeries.map((cs) => {
|
const series = concreteSeries.map((cs) => {
|
||||||
// Find definition for this series
|
// Find definition for this series
|
||||||
@@ -70,7 +70,7 @@ export function format(
|
|||||||
const previousSerie = previousSeries?.find(
|
const previousSerie = previousSeries?.find(
|
||||||
(ps) =>
|
(ps) =>
|
||||||
ps.definitionIndex === cs.definitionIndex &&
|
ps.definitionIndex === cs.definitionIndex &&
|
||||||
ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::'),
|
ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::')
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -89,24 +89,24 @@ export function format(
|
|||||||
previous: {
|
previous: {
|
||||||
sum: getPreviousMetric(
|
sum: getPreviousMetric(
|
||||||
metrics.sum,
|
metrics.sum,
|
||||||
sum(previousSerie.data.map((d) => d.count)),
|
sum(previousSerie.data.map((d) => d.count))
|
||||||
),
|
),
|
||||||
average: getPreviousMetric(
|
average: getPreviousMetric(
|
||||||
metrics.average,
|
metrics.average,
|
||||||
round(average(previousSerie.data.map((d) => d.count)), 2),
|
round(average(previousSerie.data.map((d) => d.count)), 2)
|
||||||
),
|
),
|
||||||
min: getPreviousMetric(
|
min: getPreviousMetric(
|
||||||
metrics.min,
|
metrics.min,
|
||||||
min(previousSerie.data.map((d) => d.count)),
|
min(previousSerie.data.map((d) => d.count))
|
||||||
),
|
),
|
||||||
max: getPreviousMetric(
|
max: getPreviousMetric(
|
||||||
metrics.max,
|
metrics.max,
|
||||||
max(previousSerie.data.map((d) => d.count)),
|
max(previousSerie.data.map((d) => d.count))
|
||||||
),
|
),
|
||||||
count: getPreviousMetric(
|
count: getPreviousMetric(
|
||||||
metrics.count ?? 0,
|
metrics.count ?? 0,
|
||||||
previousSerie.data.find((item) => !!item.total_count)
|
previousSerie.data.find((item) => !!item.total_count)
|
||||||
?.total_count ?? null,
|
?.total_count ?? null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ export function format(
|
|||||||
previous: previousSerie?.data[index]
|
previous: previousSerie?.data[index]
|
||||||
? getPreviousMetric(
|
? getPreviousMetric(
|
||||||
item.count,
|
item.count,
|
||||||
previousSerie.data[index]?.count ?? null,
|
previousSerie.data[index]?.count ?? null
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getPreviousMetric, groupByLabels } from '@openpanel/common';
|
|
||||||
import type { ISerieDataItem } from '@openpanel/common';
|
import type { ISerieDataItem } from '@openpanel/common';
|
||||||
|
import { groupByLabels } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
FinalChart,
|
FinalChart,
|
||||||
@@ -33,7 +33,7 @@ export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
|||||||
// Handle subscription end date limit
|
// Handle subscription end date limit
|
||||||
const endDate = await getOrganizationSubscriptionChartEndDate(
|
const endDate = await getOrganizationSubscriptionChartEndDate(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
normalized.endDate,
|
normalized.endDate
|
||||||
);
|
);
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
normalized.endDate = endDate;
|
normalized.endDate = endDate;
|
||||||
@@ -73,6 +73,7 @@ export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
|||||||
executionPlan.definitions,
|
executionPlan.definitions,
|
||||||
includeAlphaIds,
|
includeAlphaIds,
|
||||||
previousSeries,
|
previousSeries,
|
||||||
|
normalized.limit
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -83,7 +84,7 @@ export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
|||||||
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
||||||
*/
|
*/
|
||||||
export async function executeAggregateChart(
|
export async function executeAggregateChart(
|
||||||
input: IReportInput,
|
input: IReportInput
|
||||||
): Promise<FinalChart> {
|
): Promise<FinalChart> {
|
||||||
// Stage 1: Normalize input
|
// Stage 1: Normalize input
|
||||||
const normalized = await normalize(input);
|
const normalized = await normalize(input);
|
||||||
@@ -91,7 +92,7 @@ export async function executeAggregateChart(
|
|||||||
// Handle subscription end date limit
|
// Handle subscription end date limit
|
||||||
const endDate = await getOrganizationSubscriptionChartEndDate(
|
const endDate = await getOrganizationSubscriptionChartEndDate(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
normalized.endDate,
|
normalized.endDate
|
||||||
);
|
);
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
normalized.endDate = endDate;
|
normalized.endDate = endDate;
|
||||||
@@ -137,7 +138,7 @@ export async function executeAggregateChart(
|
|||||||
getAggregateChartSql(queryInput),
|
getAggregateChartSql(queryInput),
|
||||||
{
|
{
|
||||||
session_timezone: timezone,
|
session_timezone: timezone,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fallback: if no results with breakdowns, try without breakdowns
|
// Fallback: if no results with breakdowns, try without breakdowns
|
||||||
@@ -149,7 +150,7 @@ export async function executeAggregateChart(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
session_timezone: timezone,
|
session_timezone: timezone,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +263,7 @@ export async function executeAggregateChart(
|
|||||||
getAggregateChartSql(queryInput),
|
getAggregateChartSql(queryInput),
|
||||||
{
|
{
|
||||||
session_timezone: timezone,
|
session_timezone: timezone,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queryResult.length === 0 && normalized.breakdowns.length > 0) {
|
if (queryResult.length === 0 && normalized.breakdowns.length > 0) {
|
||||||
@@ -273,7 +274,7 @@ export async function executeAggregateChart(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
session_timezone: timezone,
|
session_timezone: timezone,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +345,7 @@ export async function executeAggregateChart(
|
|||||||
normalized.series,
|
normalized.series,
|
||||||
includeAlphaIds,
|
includeAlphaIds,
|
||||||
previousSeries,
|
previousSeries,
|
||||||
normalized.limit,
|
normalized.limit
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import sqlstring from 'sqlstring';
|
/** biome-ignore-all lint/style/useDefaultSwitchClause: switch cases are exhaustive by design */
|
||||||
|
|
||||||
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
||||||
import type {
|
import type {
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IReportInput,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IGetChartDataInput,
|
IGetChartDataInput,
|
||||||
|
IReportInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client';
|
import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
|
|
||||||
export function transformPropertyKey(property: string) {
|
export function transformPropertyKey(property: string) {
|
||||||
const propertyPatterns = ['properties', 'profile.properties'];
|
const propertyPatterns = ['properties', 'profile.properties'];
|
||||||
const match = propertyPatterns.find((pattern) =>
|
const match = propertyPatterns.find((pattern) =>
|
||||||
property.startsWith(`${pattern}.`),
|
property.startsWith(`${pattern}.`)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -32,21 +31,91 @@ export function transformPropertyKey(property: string) {
|
|||||||
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
|
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectPropertyKey(property: string) {
|
// Returns a SQL expression for a group property via the _g JOIN alias
|
||||||
|
// property format: "group.name", "group.type", "group.properties.plan"
|
||||||
|
export function getGroupPropertySql(property: string): string {
|
||||||
|
const withoutPrefix = property.replace(/^group\./, '');
|
||||||
|
if (withoutPrefix === 'name') {
|
||||||
|
return '_g.name';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'type') {
|
||||||
|
return '_g.type';
|
||||||
|
}
|
||||||
|
if (withoutPrefix.startsWith('properties.')) {
|
||||||
|
const propKey = withoutPrefix.replace(/^properties\./, '');
|
||||||
|
return `_g.properties[${sqlstring.escape(propKey)}]`;
|
||||||
|
}
|
||||||
|
return '_group_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the SELECT expression when querying the groups table directly (no join alias).
|
||||||
|
// Use for fetching distinct values for group.* properties.
|
||||||
|
export function getGroupPropertySelect(property: string): string {
|
||||||
|
const withoutPrefix = property.replace(/^group\./, '');
|
||||||
|
if (withoutPrefix === 'name') {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'type') {
|
||||||
|
return 'type';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'id') {
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
if (withoutPrefix.startsWith('properties.')) {
|
||||||
|
const propKey = withoutPrefix.replace(/^properties\./, '');
|
||||||
|
return `properties[${sqlstring.escape(propKey)}]`;
|
||||||
|
}
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the SELECT expression when querying the profiles table directly (no join alias).
|
||||||
|
// Use for fetching distinct values for profile.* properties.
|
||||||
|
export function getProfilePropertySelect(property: string): string {
|
||||||
|
const withoutPrefix = property.replace(/^profile\./, '');
|
||||||
|
if (withoutPrefix === 'id') {
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'first_name') {
|
||||||
|
return 'first_name';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'last_name') {
|
||||||
|
return 'last_name';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'email') {
|
||||||
|
return 'email';
|
||||||
|
}
|
||||||
|
if (withoutPrefix === 'avatar') {
|
||||||
|
return 'avatar';
|
||||||
|
}
|
||||||
|
if (withoutPrefix.startsWith('properties.')) {
|
||||||
|
const propKey = withoutPrefix.replace(/^properties\./, '');
|
||||||
|
return `properties[${sqlstring.escape(propKey)}]`;
|
||||||
|
}
|
||||||
|
return 'id';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectPropertyKey(property: string, projectId?: string) {
|
||||||
if (property === 'has_profile') {
|
if (property === 'has_profile') {
|
||||||
return `if(profile_id != device_id, 'true', 'false')`;
|
return `if(profile_id != device_id, 'true', 'false')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle group properties — requires ARRAY JOIN + _g JOIN to be present in query
|
||||||
|
if (property.startsWith('group.') && projectId) {
|
||||||
|
return getGroupPropertySql(property);
|
||||||
|
}
|
||||||
|
|
||||||
const propertyPatterns = ['properties', 'profile.properties'];
|
const propertyPatterns = ['properties', 'profile.properties'];
|
||||||
|
|
||||||
const match = propertyPatterns.find((pattern) =>
|
const match = propertyPatterns.find((pattern) =>
|
||||||
property.startsWith(`${pattern}.`),
|
property.startsWith(`${pattern}.`)
|
||||||
);
|
);
|
||||||
if (!match) return property;
|
if (!match) {
|
||||||
|
return property;
|
||||||
|
}
|
||||||
|
|
||||||
if (property.includes('*')) {
|
if (property.includes('*')) {
|
||||||
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
|
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
|
||||||
transformPropertyKey(property),
|
transformPropertyKey(property)
|
||||||
)})))`;
|
)})))`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +129,7 @@ export function getChartSql({
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
projectId,
|
projectId,
|
||||||
limit,
|
|
||||||
timezone,
|
timezone,
|
||||||
chartType,
|
|
||||||
}: IGetChartDataInput & { timezone: string }) {
|
}: IGetChartDataInput & { timezone: string }) {
|
||||||
const {
|
const {
|
||||||
sb,
|
sb,
|
||||||
@@ -78,22 +145,43 @@ export function getChartSql({
|
|||||||
with: addCte,
|
with: addCte,
|
||||||
} = createSqlBuilder();
|
} = createSqlBuilder();
|
||||||
|
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
sb.where = getEventFiltersWhereClause(event.filters, projectId);
|
||||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||||
|
|
||||||
if (event.name !== '*') {
|
if (event.name !== '*') {
|
||||||
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
|
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
|
||||||
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`;
|
sb.where.eventName = `e.name = ${sqlstring.escape(event.name)}`;
|
||||||
} else {
|
} else {
|
||||||
sb.select.label_0 = `'*' as label_0`;
|
sb.select.label_0 = `'*' as label_0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyFilterOnProfile = event.filters.some((filter) =>
|
const anyFilterOnProfile = event.filters.some((filter) =>
|
||||||
filter.name.startsWith('profile.'),
|
filter.name.startsWith('profile.')
|
||||||
);
|
);
|
||||||
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
||||||
breakdown.name.startsWith('profile.'),
|
breakdown.name.startsWith('profile.')
|
||||||
);
|
);
|
||||||
|
const anyFilterOnGroup = event.filters.some((filter) =>
|
||||||
|
filter.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
|
||||||
|
breakdown.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const anyMetricOnGroup = !!event.property?.startsWith('group.');
|
||||||
|
const needsGroupArrayJoin =
|
||||||
|
anyFilterOnGroup ||
|
||||||
|
anyBreakdownOnGroup ||
|
||||||
|
anyMetricOnGroup ||
|
||||||
|
event.segment === 'group';
|
||||||
|
|
||||||
|
if (needsGroupArrayJoin) {
|
||||||
|
addCte(
|
||||||
|
'_g',
|
||||||
|
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||||
|
);
|
||||||
|
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||||
|
sb.joins.groups_table = 'LEFT ANY JOIN _g ON _g.id = _group_id';
|
||||||
|
}
|
||||||
|
|
||||||
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
|
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
|
||||||
// Define this early so we can use it in CTE definitions
|
// Define this early so we can use it in CTE definitions
|
||||||
@@ -178,8 +266,8 @@ export function getChartSql({
|
|||||||
addCte(
|
addCte(
|
||||||
'profile',
|
'profile',
|
||||||
`SELECT ${selectFields.join(', ')}
|
`SELECT ${selectFields.join(', ')}
|
||||||
FROM ${TABLE_NAMES.profiles} FINAL
|
FROM ${TABLE_NAMES.profiles} FINAL
|
||||||
WHERE project_id = ${sqlstring.escape(projectId)}`,
|
WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use the CTE reference in the main query
|
// Use the CTE reference in the main query
|
||||||
@@ -225,31 +313,11 @@ export function getChartSql({
|
|||||||
sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`;
|
sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use CTE to define top breakdown values once, then reference in WHERE clause
|
|
||||||
if (breakdowns.length > 0 && limit) {
|
|
||||||
const breakdownSelects = breakdowns
|
|
||||||
.map((b) => getSelectPropertyKey(b.name))
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
// Add top_breakdowns CTE using the builder
|
|
||||||
addCte(
|
|
||||||
'top_breakdowns',
|
|
||||||
`SELECT ${breakdownSelects}
|
|
||||||
FROM ${TABLE_NAMES.events} e
|
|
||||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
|
|
||||||
GROUP BY ${breakdownSelects}
|
|
||||||
ORDER BY count(*) DESC
|
|
||||||
LIMIT ${limit}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter main query to only include top breakdown values
|
|
||||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
breakdowns.forEach((breakdown, index) => {
|
breakdowns.forEach((breakdown, index) => {
|
||||||
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
||||||
const key = `label_${index + 1}`;
|
const key = `label_${index + 1}`;
|
||||||
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
sb.select[key] =
|
||||||
|
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
|
||||||
sb.groupBy[key] = `${key}`;
|
sb.groupBy[key] = `${key}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,6 +329,10 @@ export function getChartSql({
|
|||||||
sb.select.count = 'countDistinct(session_id) as count';
|
sb.select.count = 'countDistinct(session_id) as count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.segment === 'group') {
|
||||||
|
sb.select.count = 'countDistinct(_group_id) as count';
|
||||||
|
}
|
||||||
|
|
||||||
if (event.segment === 'user_average') {
|
if (event.segment === 'user_average') {
|
||||||
sb.select.count =
|
sb.select.count =
|
||||||
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
||||||
@@ -287,9 +359,9 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'one_event_per_user') {
|
if (event.segment === 'one_event_per_user') {
|
||||||
sb.from = `(
|
sb.from = `(
|
||||||
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
|
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} e ${getJoins()} WHERE ${join(
|
||||||
sb.where,
|
sb.where,
|
||||||
' AND ',
|
' AND '
|
||||||
)}
|
)}
|
||||||
ORDER BY profile_id, created_at DESC
|
ORDER BY profile_id, created_at DESC
|
||||||
) as subQuery`;
|
) as subQuery`;
|
||||||
@@ -303,41 +375,52 @@ export function getChartSql({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: The profile CTE (if it exists) is available in subqueries, so we can reference it directly
|
// Note: The profile CTE (if it exists) is available in subqueries, so we can reference it directly
|
||||||
|
const subqueryGroupJoins = needsGroupArrayJoin
|
||||||
|
? 'ARRAY JOIN groups AS _group_id LEFT ANY JOIN _g ON _g.id = _group_id '
|
||||||
|
: '';
|
||||||
|
|
||||||
if (breakdowns.length > 0) {
|
if (breakdowns.length > 0) {
|
||||||
// Match breakdown properties in subquery with outer query's grouped values
|
// Pre-compute unique counts per breakdown group in a CTE, then JOIN it.
|
||||||
// Since outer query groups by label_X, we reference those in the correlation
|
// We can't use a correlated subquery because:
|
||||||
const breakdownMatches = breakdowns
|
// 1. ClickHouse expands label_X aliases to their underlying expressions,
|
||||||
|
// which resolve in the subquery's scope, making the condition a tautology.
|
||||||
|
// 2. Correlated subqueries aren't supported on distributed/remote tables.
|
||||||
|
const ucSelectParts: string[] = breakdowns.map((breakdown, index) => {
|
||||||
|
const propertyKey = getSelectPropertyKey(breakdown.name, projectId);
|
||||||
|
return `${propertyKey} as _uc_label_${index + 1}`;
|
||||||
|
});
|
||||||
|
ucSelectParts.push('uniq(profile_id) as total_count');
|
||||||
|
|
||||||
|
const ucGroupByParts = breakdowns.map(
|
||||||
|
(_, index) => `_uc_label_${index + 1}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const ucWhere = getWhereWithoutBar();
|
||||||
|
|
||||||
|
addCte(
|
||||||
|
'_uc',
|
||||||
|
`SELECT ${ucSelectParts.join(', ')} FROM ${TABLE_NAMES.events} e ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${ucWhere} GROUP BY ${ucGroupByParts.join(', ')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const ucJoinConditions = breakdowns
|
||||||
.map((b, index) => {
|
.map((b, index) => {
|
||||||
const propertyKey = getSelectPropertyKey(b.name);
|
const propertyKey = getSelectPropertyKey(b.name, projectId);
|
||||||
// Correlate: match the property expression with outer query's label_X value
|
return `_uc._uc_label_${index + 1} = ${propertyKey}`;
|
||||||
// ClickHouse allows referencing outer query columns in correlated subqueries
|
|
||||||
return `${propertyKey} = label_${index + 1}`;
|
|
||||||
})
|
})
|
||||||
.join(' AND ');
|
.join(' AND ');
|
||||||
|
|
||||||
// Build WHERE clause for subquery - replace table alias and keep profile CTE reference
|
sb.joins.unique_counts = `LEFT ANY JOIN _uc ON ${ucJoinConditions}`;
|
||||||
const subqueryWhere = getWhereWithoutBar()
|
sb.select.total_unique_count = 'any(_uc.total_count) as total_count';
|
||||||
.replace(/\be\./g, 'e2.')
|
|
||||||
.replace(/\bprofile\./g, 'profile.');
|
|
||||||
|
|
||||||
sb.select.total_unique_count = `(
|
|
||||||
SELECT uniq(profile_id)
|
|
||||||
FROM ${TABLE_NAMES.events} e2
|
|
||||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere}
|
|
||||||
AND ${breakdownMatches}
|
|
||||||
) as total_count`;
|
|
||||||
} else {
|
} else {
|
||||||
// No breakdowns: calculate unique count across all data
|
const ucWhere = getWhereWithoutBar();
|
||||||
// Build WHERE clause for subquery - replace table alias and keep profile CTE reference
|
|
||||||
const subqueryWhere = getWhereWithoutBar()
|
|
||||||
.replace(/\be\./g, 'e2.')
|
|
||||||
.replace(/\bprofile\./g, 'profile.');
|
|
||||||
|
|
||||||
sb.select.total_unique_count = `(
|
addCte(
|
||||||
SELECT uniq(profile_id)
|
'_uc',
|
||||||
FROM ${TABLE_NAMES.events} e2
|
`SELECT uniq(profile_id) as total_count FROM ${TABLE_NAMES.events} e ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${ucWhere}`
|
||||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere}
|
);
|
||||||
) as total_count`;
|
|
||||||
|
sb.select.total_unique_count =
|
||||||
|
'(SELECT total_count FROM _uc) as total_count';
|
||||||
}
|
}
|
||||||
|
|
||||||
const sql = `${getWith()}${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
|
const sql = `${getWith()}${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
|
||||||
@@ -359,31 +442,43 @@ export function getAggregateChartSql({
|
|||||||
}) {
|
}) {
|
||||||
const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder();
|
const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder();
|
||||||
|
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
sb.where = getEventFiltersWhereClause(event.filters, projectId);
|
||||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||||
|
|
||||||
if (event.name !== '*') {
|
if (event.name !== '*') {
|
||||||
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
|
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
|
||||||
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`;
|
sb.where.eventName = `e.name = ${sqlstring.escape(event.name)}`;
|
||||||
} else {
|
} else {
|
||||||
sb.select.label_0 = `'*' as label_0`;
|
sb.select.label_0 = `'*' as label_0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyFilterOnProfile = event.filters.some((filter) =>
|
const anyFilterOnProfile = event.filters.some((filter) =>
|
||||||
filter.name.startsWith('profile.'),
|
filter.name.startsWith('profile.')
|
||||||
);
|
);
|
||||||
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
||||||
breakdown.name.startsWith('profile.'),
|
breakdown.name.startsWith('profile.')
|
||||||
);
|
);
|
||||||
|
const anyFilterOnGroup = event.filters.some((filter) =>
|
||||||
|
filter.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
|
||||||
|
breakdown.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const anyMetricOnGroup = !!event.property?.startsWith('group.');
|
||||||
|
const needsGroupArrayJoin =
|
||||||
|
anyFilterOnGroup ||
|
||||||
|
anyBreakdownOnGroup ||
|
||||||
|
anyMetricOnGroup ||
|
||||||
|
event.segment === 'group';
|
||||||
|
|
||||||
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
|
if (needsGroupArrayJoin) {
|
||||||
const getWhereWithoutBar = () => {
|
addCte(
|
||||||
const whereWithoutBar = { ...sb.where };
|
'_g',
|
||||||
delete whereWithoutBar.bar;
|
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||||
return Object.keys(whereWithoutBar).length
|
);
|
||||||
? `WHERE ${join(whereWithoutBar, ' AND ')}`
|
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||||
: '';
|
sb.joins.groups_table = 'LEFT ANY JOIN _g ON _g.id = _group_id';
|
||||||
};
|
}
|
||||||
|
|
||||||
// Collect all profile fields used in filters and breakdowns
|
// Collect all profile fields used in filters and breakdowns
|
||||||
const getProfileFields = () => {
|
const getProfileFields = () => {
|
||||||
@@ -455,8 +550,8 @@ export function getAggregateChartSql({
|
|||||||
addCte(
|
addCte(
|
||||||
'profile',
|
'profile',
|
||||||
`SELECT ${selectFields.join(', ')}
|
`SELECT ${selectFields.join(', ')}
|
||||||
FROM ${TABLE_NAMES.profiles} FINAL
|
FROM ${TABLE_NAMES.profiles} FINAL
|
||||||
WHERE project_id = ${sqlstring.escape(projectId)}`,
|
WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
sb.joins.profiles = profilesJoinRef;
|
sb.joins.profiles = profilesJoinRef;
|
||||||
@@ -475,31 +570,12 @@ export function getAggregateChartSql({
|
|||||||
// Use startDate as the date value since we're aggregating across the entire range
|
// Use startDate as the date value since we're aggregating across the entire range
|
||||||
sb.select.date = `${sqlstring.escape(startDate)} as date`;
|
sb.select.date = `${sqlstring.escape(startDate)} as date`;
|
||||||
|
|
||||||
// Use CTE to define top breakdown values once, then reference in WHERE clause
|
|
||||||
if (breakdowns.length > 0 && limit) {
|
|
||||||
const breakdownSelects = breakdowns
|
|
||||||
.map((b) => getSelectPropertyKey(b.name))
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
addCte(
|
|
||||||
'top_breakdowns',
|
|
||||||
`SELECT ${breakdownSelects}
|
|
||||||
FROM ${TABLE_NAMES.events} e
|
|
||||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
|
|
||||||
GROUP BY ${breakdownSelects}
|
|
||||||
ORDER BY count(*) DESC
|
|
||||||
LIMIT ${limit}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter main query to only include top breakdown values
|
|
||||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add breakdowns to SELECT and GROUP BY
|
// Add breakdowns to SELECT and GROUP BY
|
||||||
breakdowns.forEach((breakdown, index) => {
|
breakdowns.forEach((breakdown, index) => {
|
||||||
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
||||||
const key = `label_${index + 1}`;
|
const key = `label_${index + 1}`;
|
||||||
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
sb.select[key] =
|
||||||
|
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
|
||||||
sb.groupBy[key] = `${key}`;
|
sb.groupBy[key] = `${key}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -518,6 +594,10 @@ export function getAggregateChartSql({
|
|||||||
sb.select.count = 'countDistinct(session_id) as count';
|
sb.select.count = 'countDistinct(session_id) as count';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.segment === 'group') {
|
||||||
|
sb.select.count = 'countDistinct(_group_id) as count';
|
||||||
|
}
|
||||||
|
|
||||||
if (event.segment === 'user_average') {
|
if (event.segment === 'user_average') {
|
||||||
sb.select.count =
|
sb.select.count =
|
||||||
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
||||||
@@ -531,7 +611,7 @@ export function getAggregateChartSql({
|
|||||||
}[event.segment as string];
|
}[event.segment as string];
|
||||||
|
|
||||||
if (mathFunction && event.property) {
|
if (mathFunction && event.property) {
|
||||||
const propertyKey = getSelectPropertyKey(event.property);
|
const propertyKey = getSelectPropertyKey(event.property, projectId);
|
||||||
|
|
||||||
if (isNumericColumn(event.property)) {
|
if (isNumericColumn(event.property)) {
|
||||||
sb.select.count = `${mathFunction}(${propertyKey}) as count`;
|
sb.select.count = `${mathFunction}(${propertyKey}) as count`;
|
||||||
@@ -544,9 +624,9 @@ export function getAggregateChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'one_event_per_user') {
|
if (event.segment === 'one_event_per_user') {
|
||||||
sb.from = `(
|
sb.from = `(
|
||||||
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
|
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} e ${getJoins()} WHERE ${join(
|
||||||
sb.where,
|
sb.where,
|
||||||
' AND ',
|
' AND '
|
||||||
)}
|
)}
|
||||||
ORDER BY profile_id, created_at DESC
|
ORDER BY profile_id, created_at DESC
|
||||||
) as subQuery`;
|
) as subQuery`;
|
||||||
@@ -579,7 +659,10 @@ function isNumericColumn(columnName: string): boolean {
|
|||||||
return numericColumns.includes(columnName);
|
return numericColumns.includes(columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
export function getEventFiltersWhereClause(
|
||||||
|
filters: IChartEventFilter[],
|
||||||
|
projectId?: string
|
||||||
|
) {
|
||||||
const where: Record<string, string> = {};
|
const where: Record<string, string> = {};
|
||||||
filters.forEach((filter, index) => {
|
filters.forEach((filter, index) => {
|
||||||
const id = `f${index}`;
|
const id = `f${index}`;
|
||||||
@@ -602,6 +685,67 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle group. prefixed filters (requires ARRAY JOIN + _g JOIN in query)
|
||||||
|
if (name.startsWith('group.') && projectId) {
|
||||||
|
const whereFrom = getGroupPropertySql(name);
|
||||||
|
switch (operator) {
|
||||||
|
case 'is': {
|
||||||
|
if (value.length === 1) {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
||||||
|
} else {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'isNot': {
|
||||||
|
if (value.length === 1) {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
||||||
|
} else {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} NOT IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'contains': {
|
||||||
|
where[id] =
|
||||||
|
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'doesNotContain': {
|
||||||
|
where[id] =
|
||||||
|
`(${value.map((val) => `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'startsWith': {
|
||||||
|
where[id] =
|
||||||
|
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'endsWith': {
|
||||||
|
where[id] =
|
||||||
|
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`).join(' OR ')})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'isNull': {
|
||||||
|
where[id] = `(${whereFrom} = '' OR ${whereFrom} IS NULL)`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'isNotNull': {
|
||||||
|
where[id] = `(${whereFrom} != '' AND ${whereFrom} IS NOT NULL)`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'regex': {
|
||||||
|
where[id] =
|
||||||
|
`(${value.map((val) => `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`).join(' OR ')})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
name.startsWith('properties.') ||
|
name.startsWith('properties.') ||
|
||||||
name.startsWith('profile.properties.')
|
name.startsWith('profile.properties.')
|
||||||
@@ -616,15 +760,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
|
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
|
} else if (value.length === 1) {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
||||||
} else {
|
} else {
|
||||||
if (value.length === 1) {
|
where[id] = `${whereFrom} IN (${value
|
||||||
where[id] =
|
.map((val) => sqlstring.escape(String(val).trim()))
|
||||||
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
.join(', ')})`;
|
||||||
} else {
|
|
||||||
where[id] = `${whereFrom} IN (${value
|
|
||||||
.map((val) => sqlstring.escape(String(val).trim()))
|
|
||||||
.join(', ')})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -633,15 +775,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
|
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
|
} else if (value.length === 1) {
|
||||||
|
where[id] =
|
||||||
|
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
||||||
} else {
|
} else {
|
||||||
if (value.length === 1) {
|
where[id] = `${whereFrom} NOT IN (${value
|
||||||
where[id] =
|
.map((val) => sqlstring.escape(String(val).trim()))
|
||||||
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
.join(', ')})`;
|
||||||
} else {
|
|
||||||
where[id] = `${whereFrom} NOT IN (${value
|
|
||||||
.map((val) => sqlstring.escape(String(val).trim()))
|
|
||||||
.join(', ')})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -649,15 +789,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
if (isWildcard) {
|
if (isWildcard) {
|
||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
`x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -668,14 +807,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -685,14 +824,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
if (isWildcard) {
|
if (isWildcard) {
|
||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -702,14 +841,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
if (isWildcard) {
|
if (isWildcard) {
|
||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -724,7 +863,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`,
|
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -752,14 +891,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -770,14 +909,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -788,14 +927,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -806,14 +945,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `arrayExists(x -> ${value
|
where[id] = `arrayExists(x -> ${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')}, ${whereFrom})`;
|
.join(' OR ')}, ${whereFrom})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -856,7 +995,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
break;
|
break;
|
||||||
@@ -865,7 +1004,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
break;
|
break;
|
||||||
@@ -874,7 +1013,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
break;
|
break;
|
||||||
@@ -883,7 +1022,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
break;
|
break;
|
||||||
@@ -892,7 +1031,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`,
|
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
break;
|
break;
|
||||||
@@ -902,7 +1041,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
} else {
|
} else {
|
||||||
@@ -917,7 +1056,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
} else {
|
} else {
|
||||||
@@ -932,13 +1071,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`,
|
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -949,13 +1088,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
|||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
} else {
|
} else {
|
||||||
where[id] = `(${value
|
where[id] = `(${value
|
||||||
.map(
|
.map(
|
||||||
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`,
|
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`
|
||||||
)
|
)
|
||||||
.join(' OR ')})`;
|
.join(' OR ')})`;
|
||||||
}
|
}
|
||||||
@@ -974,15 +1113,15 @@ export function getChartStartEndDate(
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
||||||
timezone: string,
|
timezone: string
|
||||||
) {
|
) {
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
return { startDate: startDate, endDate: endDate };
|
return { startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranges = getDatesFromRange(range, timezone);
|
const ranges = getDatesFromRange(range, timezone);
|
||||||
if (!startDate && endDate) {
|
if (!startDate && endDate) {
|
||||||
return { startDate: ranges.startDate, endDate: endDate };
|
return { startDate: ranges.startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
return ranges;
|
return ranges;
|
||||||
@@ -1002,8 +1141,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,8 +1157,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1035,8 +1174,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.endOf('day')
|
.endOf('day')
|
||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,8 +1192,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,8 +1210,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,8 +1228,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,8 +1245,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,8 +1263,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,8 +1280,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,8 +1291,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
|
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,8 +1309,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,7 +1322,7 @@ export function getChartPrevStartEndDate({
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
}) {
|
}) {
|
||||||
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
|
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
|
||||||
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'),
|
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss')
|
||||||
);
|
);
|
||||||
|
|
||||||
// this will make sure our start and end date's are correct
|
// this will make sure our start and end date's are correct
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ export class ConversionService {
|
|||||||
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
const breakdownExpressions = breakdowns.map(
|
const breakdownExpressions = breakdowns.map(
|
||||||
(b) => getSelectPropertyKey(b.name),
|
(b) => getSelectPropertyKey(b.name, projectId),
|
||||||
);
|
);
|
||||||
const breakdownSelects = breakdownExpressions.map(
|
const breakdownSelects = breakdownExpressions.map(
|
||||||
(expr, index) => `${expr} as b_${index}`,
|
(expr, index) => `${expr} as b_${index}`,
|
||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`);
|
||||||
|
|
||||||
// Check if any breakdown uses profile fields and build profile JOIN if needed
|
// Check if any breakdown or filter uses profile fields
|
||||||
const profileBreakdowns = breakdowns.filter((b) =>
|
const profileBreakdowns = breakdowns.filter((b) =>
|
||||||
b.name.startsWith('profile.'),
|
b.name.startsWith('profile.'),
|
||||||
);
|
);
|
||||||
@@ -71,6 +71,15 @@ export class ConversionService {
|
|||||||
|
|
||||||
const events = onlyReportEvents(series);
|
const events = onlyReportEvents(series);
|
||||||
|
|
||||||
|
// Check if any breakdown or filter uses group fields
|
||||||
|
const anyBreakdownOnGroup = breakdowns.some((b) =>
|
||||||
|
b.name.startsWith('group.'),
|
||||||
|
);
|
||||||
|
const anyFilterOnGroup = events.some((e) =>
|
||||||
|
e.filters?.some((f) => f.name.startsWith('group.')),
|
||||||
|
);
|
||||||
|
const needsGroupArrayJoin = anyBreakdownOnGroup || anyFilterOnGroup;
|
||||||
|
|
||||||
if (events.length !== 2) {
|
if (events.length !== 2) {
|
||||||
throw new Error('events must be an array of two events');
|
throw new Error('events must be an array of two events');
|
||||||
}
|
}
|
||||||
@@ -82,21 +91,25 @@ export class ConversionService {
|
|||||||
const eventA = events[0]!;
|
const eventA = events[0]!;
|
||||||
const eventB = events[1]!;
|
const eventB = events[1]!;
|
||||||
const whereA = Object.values(
|
const whereA = Object.values(
|
||||||
getEventFiltersWhereClause(eventA.filters),
|
getEventFiltersWhereClause(eventA.filters, projectId),
|
||||||
).join(' AND ');
|
).join(' AND ');
|
||||||
const whereB = Object.values(
|
const whereB = Object.values(
|
||||||
getEventFiltersWhereClause(eventB.filters),
|
getEventFiltersWhereClause(eventB.filters, projectId),
|
||||||
).join(' AND ');
|
).join(' AND ');
|
||||||
|
|
||||||
const funnelWindowSeconds = funnelWindow * 3600;
|
const funnelWindowSeconds = funnelWindow * 3600;
|
||||||
|
|
||||||
// Build funnel conditions
|
// Build funnel conditions
|
||||||
const conditionA = whereA
|
const conditionA = whereA
|
||||||
? `(name = '${eventA.name}' AND ${whereA})`
|
? `(events.name = '${eventA.name}' AND ${whereA})`
|
||||||
: `name = '${eventA.name}'`;
|
: `events.name = '${eventA.name}'`;
|
||||||
const conditionB = whereB
|
const conditionB = whereB
|
||||||
? `(name = '${eventB.name}' AND ${whereB})`
|
? `(events.name = '${eventB.name}' AND ${whereB})`
|
||||||
: `name = '${eventB.name}'`;
|
: `events.name = '${eventB.name}'`;
|
||||||
|
|
||||||
|
const groupJoin = needsGroupArrayJoin
|
||||||
|
? `ARRAY JOIN groups AS _group_id LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`
|
||||||
|
: '';
|
||||||
|
|
||||||
// Use windowFunnel approach - single scan, no JOIN
|
// Use windowFunnel approach - single scan, no JOIN
|
||||||
const query = clix(this.client, timezone)
|
const query = clix(this.client, timezone)
|
||||||
@@ -126,8 +139,9 @@ export class ConversionService {
|
|||||||
) as steps
|
) as steps
|
||||||
FROM ${TABLE_NAMES.events}
|
FROM ${TABLE_NAMES.events}
|
||||||
${profileJoin}
|
${profileJoin}
|
||||||
|
${groupJoin}
|
||||||
WHERE project_id = '${projectId}'
|
WHERE project_id = '${projectId}'
|
||||||
AND name IN ('${eventA.name}', '${eventB.name}')
|
AND events.name IN ('${eventA.name}', '${eventB.name}')
|
||||||
AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}')
|
AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}')
|
||||||
GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''})
|
GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''})
|
||||||
`),
|
`),
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export type IImportedEvent = Omit<
|
|||||||
properties: Record<string, unknown>;
|
properties: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IServicePage = {
|
export interface IServicePage {
|
||||||
path: string;
|
path: string;
|
||||||
count: number;
|
count: number;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
first_seen: string;
|
first_seen: string;
|
||||||
title: string;
|
title: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface IClickhouseBotEvent {
|
export interface IClickhouseBotEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -92,6 +92,7 @@ export interface IClickhouseEvent {
|
|||||||
sdk_name: string;
|
sdk_name: string;
|
||||||
sdk_version: string;
|
sdk_version: string;
|
||||||
revenue?: number;
|
revenue?: number;
|
||||||
|
groups: string[];
|
||||||
|
|
||||||
// They do not exist here. Just make ts happy for now
|
// They do not exist here. Just make ts happy for now
|
||||||
profile?: IServiceProfile;
|
profile?: IServiceProfile;
|
||||||
@@ -143,6 +144,7 @@ export function transformSessionToEvent(
|
|||||||
importedAt: undefined,
|
importedAt: undefined,
|
||||||
sdkName: undefined,
|
sdkName: undefined,
|
||||||
sdkVersion: undefined,
|
sdkVersion: undefined,
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +181,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
|||||||
sdkVersion: event.sdk_version,
|
sdkVersion: event.sdk_version,
|
||||||
profile: event.profile,
|
profile: event.profile,
|
||||||
revenue: event.revenue,
|
revenue: event.revenue,
|
||||||
|
groups: event.groups ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +230,7 @@ export interface IServiceEvent {
|
|||||||
sdkName: string | undefined;
|
sdkName: string | undefined;
|
||||||
sdkVersion: string | undefined;
|
sdkVersion: string | undefined;
|
||||||
revenue?: number;
|
revenue?: number;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectHelper<T> = {
|
type SelectHelper<T> = {
|
||||||
@@ -331,6 +335,7 @@ export async function getEvents(
|
|||||||
projectId,
|
projectId,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +391,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
sdk_name: payload.sdkName ?? '',
|
sdk_name: payload.sdkName ?? '',
|
||||||
sdk_version: payload.sdkVersion ?? '',
|
sdk_version: payload.sdkVersion ?? '',
|
||||||
revenue: payload.revenue,
|
revenue: payload.revenue,
|
||||||
|
groups: payload.groups ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
||||||
@@ -434,6 +440,7 @@ export interface GetEventListOptions {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
groupId?: string;
|
||||||
take: number;
|
take: number;
|
||||||
cursor?: number | Date;
|
cursor?: number | Date;
|
||||||
events?: string[] | null;
|
events?: string[] | null;
|
||||||
@@ -452,6 +459,7 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
projectId,
|
projectId,
|
||||||
profileId,
|
profileId,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
groupId,
|
||||||
events,
|
events,
|
||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -589,6 +597,10 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
sb.select.revenue = 'revenue';
|
sb.select.revenue = 'revenue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (select.groups) {
|
||||||
|
sb.select.groups = 'groups';
|
||||||
|
}
|
||||||
|
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
sb.where.deviceId = `(device_id IN (SELECT device_id as did FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND device_id != '' AND profile_id = ${sqlstring.escape(profileId)} group by did) OR profile_id = ${sqlstring.escape(profileId)})`;
|
sb.where.deviceId = `(device_id IN (SELECT device_id as did FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND device_id != '' AND profile_id = ${sqlstring.escape(profileId)} group by did) OR profile_id = ${sqlstring.escape(profileId)})`;
|
||||||
}
|
}
|
||||||
@@ -597,6 +609,10 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
sb.where.sessionId = `session_id = ${sqlstring.escape(sessionId)}`;
|
sb.where.sessionId = `session_id = ${sqlstring.escape(sessionId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
sb.where.groupId = `has(groups, ${sqlstring.escape(groupId)})`;
|
||||||
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
|
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
|
||||||
}
|
}
|
||||||
@@ -611,7 +627,7 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
if (filters) {
|
if (filters) {
|
||||||
sb.where = {
|
sb.where = {
|
||||||
...sb.where,
|
...sb.where,
|
||||||
...getEventFiltersWhereClause(filters),
|
...getEventFiltersWhereClause(filters, projectId),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Join profiles table if any filter uses profile fields
|
// Join profiles table if any filter uses profile fields
|
||||||
@@ -622,6 +638,13 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
if (profileFilters.length > 0) {
|
if (profileFilters.length > 0) {
|
||||||
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join groups table if any filter uses group fields
|
||||||
|
const groupFilters = filters.filter((f) => f.name.startsWith('group.'));
|
||||||
|
if (groupFilters.length > 0) {
|
||||||
|
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||||
|
sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.orderBy.created_at = 'created_at DESC, id ASC';
|
sb.orderBy.created_at = 'created_at DESC, id ASC';
|
||||||
@@ -653,6 +676,7 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
export async function getEventsCount({
|
export async function getEventsCount({
|
||||||
projectId,
|
projectId,
|
||||||
profileId,
|
profileId,
|
||||||
|
groupId,
|
||||||
events,
|
events,
|
||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -664,6 +688,10 @@ export async function getEventsCount({
|
|||||||
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
sb.where.groupId = `has(groups, ${sqlstring.escape(groupId)})`;
|
||||||
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
|
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
|
||||||
}
|
}
|
||||||
@@ -678,7 +706,7 @@ export async function getEventsCount({
|
|||||||
if (filters) {
|
if (filters) {
|
||||||
sb.where = {
|
sb.where = {
|
||||||
...sb.where,
|
...sb.where,
|
||||||
...getEventFiltersWhereClause(filters),
|
...getEventFiltersWhereClause(filters, projectId),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Join profiles table if any filter uses profile fields
|
// Join profiles table if any filter uses profile fields
|
||||||
@@ -689,6 +717,13 @@ export async function getEventsCount({
|
|||||||
if (profileFilters.length > 0) {
|
if (profileFilters.length > 0) {
|
||||||
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join groups table if any filter uses group fields
|
||||||
|
const groupFilters = filters.filter((f) => f.name.startsWith('group.'));
|
||||||
|
if (groupFilters.length > 0) {
|
||||||
|
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||||
|
sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await chQuery<{ count: number }>(
|
const res = await chQuery<{ count: number }>(
|
||||||
@@ -1052,8 +1087,19 @@ class EventService {
|
|||||||
}
|
}
|
||||||
if (filters) {
|
if (filters) {
|
||||||
q.rawWhere(
|
q.rawWhere(
|
||||||
Object.values(getEventFiltersWhereClause(filters)).join(' AND ')
|
Object.values(
|
||||||
|
getEventFiltersWhereClause(filters, projectId)
|
||||||
|
).join(' AND ')
|
||||||
);
|
);
|
||||||
|
const groupFilters = filters.filter((f) =>
|
||||||
|
f.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
if (groupFilters.length > 0) {
|
||||||
|
q.rawJoin('ARRAY JOIN groups AS _group_id');
|
||||||
|
q.rawJoin(
|
||||||
|
`LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
session: (q) => {
|
session: (q) => {
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export class FunnelService {
|
|||||||
return group === 'profile_id' ? 'profile_id' : 'session_id';
|
return group === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
}
|
}
|
||||||
|
|
||||||
getFunnelConditions(events: IChartEvent[] = []): string[] {
|
getFunnelConditions(events: IChartEvent[] = [], projectId?: string): string[] {
|
||||||
return events.map((event) => {
|
return events.map((event) => {
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
const { sb, getWhere } = createSqlBuilder();
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
sb.where = getEventFiltersWhereClause(event.filters, projectId);
|
||||||
sb.where.name = `name = ${sqlstring.escape(event.name)}`;
|
sb.where.name = `events.name = ${sqlstring.escape(event.name)}`;
|
||||||
return getWhere().replace('WHERE ', '');
|
return getWhere().replace('WHERE ', '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ export class FunnelService {
|
|||||||
additionalGroupBy?: string[];
|
additionalGroupBy?: string[];
|
||||||
group?: 'session_id' | 'profile_id';
|
group?: 'session_id' | 'profile_id';
|
||||||
}) {
|
}) {
|
||||||
const funnels = this.getFunnelConditions(eventSeries);
|
const funnels = this.getFunnelConditions(eventSeries, projectId);
|
||||||
const primaryKey = group === 'profile_id' ? 'profile_id' : 'session_id';
|
const primaryKey = group === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
|
|
||||||
return clix(this.client, timezone)
|
return clix(this.client, timezone)
|
||||||
@@ -90,7 +90,7 @@ export class FunnelService {
|
|||||||
clix.datetime(endDate, 'toDateTime'),
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
])
|
])
|
||||||
.where(
|
.where(
|
||||||
'name',
|
'events.name',
|
||||||
'IN',
|
'IN',
|
||||||
eventSeries.map((e) => e.name),
|
eventSeries.map((e) => e.name),
|
||||||
)
|
)
|
||||||
@@ -236,10 +236,18 @@ export class FunnelService {
|
|||||||
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
||||||
b.name.startsWith('profile.'),
|
b.name.startsWith('profile.'),
|
||||||
);
|
);
|
||||||
|
const anyFilterOnGroup = eventSeries.some((e) =>
|
||||||
|
e.filters?.some((f) => f.name.startsWith('group.')),
|
||||||
|
);
|
||||||
|
const anyBreakdownOnGroup = breakdowns.some((b) =>
|
||||||
|
b.name.startsWith('group.'),
|
||||||
|
);
|
||||||
|
const needsGroupArrayJoin =
|
||||||
|
anyFilterOnGroup || anyBreakdownOnGroup || funnelGroup === 'group';
|
||||||
|
|
||||||
// Create the funnel CTE (session-level)
|
// Create the funnel CTE (session-level)
|
||||||
const breakdownSelects = breakdowns.map(
|
const breakdownSelects = breakdowns.map(
|
||||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
(b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}`,
|
||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
|
|
||||||
@@ -277,8 +285,21 @@ export class FunnelService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsGroupArrayJoin) {
|
||||||
|
funnelCte.rawJoin('ARRAY JOIN groups AS _group_id');
|
||||||
|
funnelCte.rawJoin('LEFT ANY JOIN _g ON _g.id = _group_id');
|
||||||
|
}
|
||||||
|
|
||||||
// Base funnel query with CTEs
|
// Base funnel query with CTEs
|
||||||
const funnelQuery = clix(this.client, timezone);
|
const funnelQuery = clix(this.client, timezone);
|
||||||
|
|
||||||
|
if (needsGroupArrayJoin) {
|
||||||
|
funnelQuery.with(
|
||||||
|
'_g',
|
||||||
|
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
funnelQuery.with('session_funnel', funnelCte);
|
funnelQuery.with('session_funnel', funnelCte);
|
||||||
|
|
||||||
// windowFunnel is computed per the primary key (profile_id or session_id),
|
// windowFunnel is computed per the primary key (profile_id or session_id),
|
||||||
|
|||||||
363
packages/db/src/services/group.service.ts
Normal file
363
packages/db/src/services/group.service.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { toDots } from '@openpanel/common';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
|
import {
|
||||||
|
ch,
|
||||||
|
chQuery,
|
||||||
|
formatClickhouseDate,
|
||||||
|
TABLE_NAMES,
|
||||||
|
} from '../clickhouse/client';
|
||||||
|
import type { IServiceProfile } from './profile.service';
|
||||||
|
import { getProfiles } from './profile.service';
|
||||||
|
|
||||||
|
export type IServiceGroup = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IServiceUpsertGroup = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IClickhouseGroup = {
|
||||||
|
project_id: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformGroup(row: IClickhouseGroup): IServiceGroup {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
type: row.type,
|
||||||
|
name: row.name,
|
||||||
|
properties: row.properties,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(Number(row.version)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGroupToCh(
|
||||||
|
group: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, string>;
|
||||||
|
createdAt?: Date;
|
||||||
|
},
|
||||||
|
deleted = 0
|
||||||
|
) {
|
||||||
|
await ch.insert({
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
table: TABLE_NAMES.groups,
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
project_id: group.projectId,
|
||||||
|
id: group.id,
|
||||||
|
type: group.type,
|
||||||
|
name: group.name,
|
||||||
|
properties: group.properties,
|
||||||
|
created_at: formatClickhouseDate(group.createdAt ?? new Date()),
|
||||||
|
version: Date.now(),
|
||||||
|
deleted,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertGroup(input: IServiceUpsertGroup) {
|
||||||
|
const existing = await getGroupById(input.id, input.projectId);
|
||||||
|
await writeGroupToCh({
|
||||||
|
id: input.id,
|
||||||
|
projectId: input.projectId,
|
||||||
|
type: input.type,
|
||||||
|
name: input.name,
|
||||||
|
properties: toDots({
|
||||||
|
...(existing?.properties ?? {}),
|
||||||
|
...(input.properties ?? {}),
|
||||||
|
}),
|
||||||
|
createdAt: existing?.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupById(
|
||||||
|
id: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<IServiceGroup | null> {
|
||||||
|
const rows = await chQuery<IClickhouseGroup>(`
|
||||||
|
SELECT project_id, id, type, name, properties, created_at, version
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND id = ${sqlstring.escape(id)}
|
||||||
|
AND deleted = 0
|
||||||
|
`);
|
||||||
|
return rows[0] ? transformGroup(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupList({
|
||||||
|
projectId,
|
||||||
|
cursor,
|
||||||
|
take,
|
||||||
|
search,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
cursor?: number;
|
||||||
|
take: number;
|
||||||
|
search?: string;
|
||||||
|
type?: string;
|
||||||
|
}): Promise<IServiceGroup[]> {
|
||||||
|
const conditions = [
|
||||||
|
`project_id = ${sqlstring.escape(projectId)}`,
|
||||||
|
'deleted = 0',
|
||||||
|
...(type ? [`type = ${sqlstring.escape(type)}`] : []),
|
||||||
|
...(search
|
||||||
|
? [
|
||||||
|
`(name ILIKE ${sqlstring.escape(`%${search}%`)} OR id ILIKE ${sqlstring.escape(`%${search}%`)})`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = await chQuery<IClickhouseGroup>(`
|
||||||
|
SELECT project_id, id, type, name, properties, created_at, version
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${take}
|
||||||
|
OFFSET ${Math.max(0, (cursor ?? 0) * take)}
|
||||||
|
`);
|
||||||
|
return rows.map(transformGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupListCount({
|
||||||
|
projectId,
|
||||||
|
type,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
type?: string;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const conditions = [
|
||||||
|
`project_id = ${sqlstring.escape(projectId)}`,
|
||||||
|
'deleted = 0',
|
||||||
|
...(type ? [`type = ${sqlstring.escape(type)}`] : []),
|
||||||
|
...(search
|
||||||
|
? [
|
||||||
|
`(name ILIKE ${sqlstring.escape(`%${search}%`)} OR id ILIKE ${sqlstring.escape(`%${search}%`)})`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = await chQuery<{ count: number }>(`
|
||||||
|
SELECT count() as count
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
`);
|
||||||
|
return rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupTypes(projectId: string): Promise<string[]> {
|
||||||
|
const rows = await chQuery<{ type: string }>(`
|
||||||
|
SELECT DISTINCT type
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND deleted = 0
|
||||||
|
`);
|
||||||
|
return rows.map((r) => r.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(input: IServiceUpsertGroup) {
|
||||||
|
await upsertGroup(input);
|
||||||
|
return getGroupById(input.id, input.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroup(
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: { type?: string; name?: string; properties?: Record<string, unknown> }
|
||||||
|
) {
|
||||||
|
const existing = await getGroupById(id, projectId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Group ${id} not found`);
|
||||||
|
}
|
||||||
|
const mergedProperties = {
|
||||||
|
...(existing.properties ?? {}),
|
||||||
|
...(data.properties ?? {}),
|
||||||
|
};
|
||||||
|
const normalizedProperties = toDots(
|
||||||
|
mergedProperties as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const updated = {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
type: data.type ?? existing.type,
|
||||||
|
name: data.name ?? existing.name,
|
||||||
|
properties: normalizedProperties,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
};
|
||||||
|
await writeGroupToCh(updated);
|
||||||
|
return { ...existing, ...updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(id: string, projectId: string) {
|
||||||
|
const existing = await getGroupById(id, projectId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Group ${id} not found`);
|
||||||
|
}
|
||||||
|
await writeGroupToCh(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
type: existing.type,
|
||||||
|
name: existing.name,
|
||||||
|
properties: existing.properties as Record<string, string>,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupPropertyKeys(
|
||||||
|
projectId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await chQuery<{ key: string }>(`
|
||||||
|
SELECT DISTINCT arrayJoin(mapKeys(properties)) as key
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND deleted = 0
|
||||||
|
`);
|
||||||
|
return rows.map((r) => r.key).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IServiceGroupStats = {
|
||||||
|
groupId: string;
|
||||||
|
memberCount: number;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGroupStats(
|
||||||
|
projectId: string,
|
||||||
|
groupIds: string[]
|
||||||
|
): Promise<Map<string, IServiceGroupStats>> {
|
||||||
|
if (groupIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await chQuery<{
|
||||||
|
group_id: string;
|
||||||
|
member_count: number;
|
||||||
|
last_active_at: string;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
g AS group_id,
|
||||||
|
uniqExact(profile_id) AS member_count,
|
||||||
|
max(created_at) AS last_active_at
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
ARRAY JOIN groups AS g
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND g IN (${groupIds.map((id) => sqlstring.escape(id)).join(',')})
|
||||||
|
AND profile_id != device_id
|
||||||
|
GROUP BY g
|
||||||
|
`);
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.group_id,
|
||||||
|
{
|
||||||
|
groupId: r.group_id,
|
||||||
|
memberCount: r.member_count,
|
||||||
|
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupsByIds(
|
||||||
|
projectId: string,
|
||||||
|
ids: string[]
|
||||||
|
): Promise<IServiceGroup[]> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await chQuery<IClickhouseGroup>(`
|
||||||
|
SELECT project_id, id, type, name, properties, created_at, version
|
||||||
|
FROM ${TABLE_NAMES.groups} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND id IN (${ids.map((id) => sqlstring.escape(id)).join(',')})
|
||||||
|
AND deleted = 0
|
||||||
|
`);
|
||||||
|
return rows.map(transformGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupMemberProfiles({
|
||||||
|
projectId,
|
||||||
|
groupId,
|
||||||
|
cursor,
|
||||||
|
take,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
groupId: string;
|
||||||
|
cursor?: number;
|
||||||
|
take: number;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<{ data: IServiceProfile[]; count: number }> {
|
||||||
|
const offset = Math.max(0, (cursor ?? 0) * take);
|
||||||
|
const searchCondition = search?.trim()
|
||||||
|
? `AND (email ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR first_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR last_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// count() OVER () is evaluated after JOINs/WHERE but before LIMIT,
|
||||||
|
// so we get the total match count and the paginated IDs in one query.
|
||||||
|
const rows = await chQuery<{ profile_id: string; total_count: number }>(`
|
||||||
|
SELECT
|
||||||
|
gm.profile_id,
|
||||||
|
count() OVER () AS total_count
|
||||||
|
FROM (
|
||||||
|
SELECT profile_id, max(created_at) AS last_seen
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(groupId)})
|
||||||
|
AND profile_id != device_id
|
||||||
|
GROUP BY profile_id
|
||||||
|
) gm
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT id FROM ${TABLE_NAMES.profiles} FINAL
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
${searchCondition}
|
||||||
|
) p ON p.id = gm.profile_id
|
||||||
|
ORDER BY gm.last_seen DESC
|
||||||
|
LIMIT ${take}
|
||||||
|
OFFSET ${offset}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const count = rows[0]?.total_count ?? 0;
|
||||||
|
const profileIds = rows.map((r) => r.profile_id);
|
||||||
|
|
||||||
|
if (profileIds.length === 0) {
|
||||||
|
return { data: [], count };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await getProfiles(profileIds, projectId);
|
||||||
|
const byId = new Map(profiles.map((p) => [p.id, p]));
|
||||||
|
const data = profileIds
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter(Boolean) as IServiceProfile[];
|
||||||
|
return { data, count };
|
||||||
|
}
|
||||||
@@ -355,6 +355,7 @@ export async function createSessionsStartEndEvents(
|
|||||||
profile_id: session.profile_id,
|
profile_id: session.profile_id,
|
||||||
project_id: session.project_id,
|
project_id: session.project_id,
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
|
groups: [],
|
||||||
path: firstPath,
|
path: firstPath,
|
||||||
origin: firstOrigin,
|
origin: firstOrigin,
|
||||||
referrer: firstReferrer,
|
referrer: firstReferrer,
|
||||||
@@ -390,6 +391,7 @@ export async function createSessionsStartEndEvents(
|
|||||||
profile_id: session.profile_id,
|
profile_id: session.profile_id,
|
||||||
project_id: session.project_id,
|
project_id: session.project_id,
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
|
groups: [],
|
||||||
path: lastPath,
|
path: lastPath,
|
||||||
origin: lastOrigin,
|
origin: lastOrigin,
|
||||||
referrer: firstReferrer,
|
referrer: firstReferrer,
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ export async function getProfiles(ids: string[], projectId: string) {
|
|||||||
any(nullIf(avatar, '')) as avatar,
|
any(nullIf(avatar, '')) as avatar,
|
||||||
last_value(is_external) as is_external,
|
last_value(is_external) as is_external,
|
||||||
any(properties) as properties,
|
any(properties) as properties,
|
||||||
any(created_at) as created_at
|
any(created_at) as created_at,
|
||||||
|
any(groups) as groups
|
||||||
FROM ${TABLE_NAMES.profiles}
|
FROM ${TABLE_NAMES.profiles}
|
||||||
WHERE
|
WHERE
|
||||||
project_id = ${sqlstring.escape(projectId)} AND
|
project_id = ${sqlstring.escape(projectId)} AND
|
||||||
@@ -232,6 +233,7 @@ export interface IServiceProfile {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isExternal: boolean;
|
isExternal: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
groups: string[];
|
||||||
properties: Record<string, unknown> & {
|
properties: Record<string, unknown> & {
|
||||||
region?: string;
|
region?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
@@ -259,6 +261,7 @@ export interface IClickhouseProfile {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
is_external: boolean;
|
is_external: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IServiceUpsertProfile {
|
export interface IServiceUpsertProfile {
|
||||||
@@ -270,6 +273,7 @@ export interface IServiceUpsertProfile {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
isExternal: boolean;
|
isExternal: boolean;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformProfile({
|
export function transformProfile({
|
||||||
@@ -288,6 +292,7 @@ export function transformProfile({
|
|||||||
id: profile.id,
|
id: profile.id,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
avatar: profile.avatar,
|
avatar: profile.avatar,
|
||||||
|
groups: profile.groups ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +306,7 @@ export function upsertProfile(
|
|||||||
properties,
|
properties,
|
||||||
projectId,
|
projectId,
|
||||||
isExternal,
|
isExternal,
|
||||||
|
groups,
|
||||||
}: IServiceUpsertProfile,
|
}: IServiceUpsertProfile,
|
||||||
isFromEvent = false
|
isFromEvent = false
|
||||||
) {
|
) {
|
||||||
@@ -314,6 +320,7 @@ export function upsertProfile(
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
created_at: formatClickhouseDate(new Date()),
|
created_at: formatClickhouseDate(new Date()),
|
||||||
is_external: isExternal,
|
is_external: isExternal,
|
||||||
|
groups: groups ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return profileBuffer.add(profile, isFromEvent);
|
return profileBuffer.add(profile, isFromEvent);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface IClickhouseSession {
|
|||||||
version: number;
|
version: number;
|
||||||
// Dynamically added
|
// Dynamically added
|
||||||
has_replay?: boolean;
|
has_replay?: boolean;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IServiceSession {
|
export interface IServiceSession {
|
||||||
@@ -95,6 +96,7 @@ export interface IServiceSession {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
profile?: IServiceProfile;
|
profile?: IServiceProfile;
|
||||||
hasReplay?: boolean;
|
hasReplay?: boolean;
|
||||||
|
groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetSessionListOptions {
|
export interface GetSessionListOptions {
|
||||||
@@ -152,6 +154,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
revenue: session.revenue,
|
revenue: session.revenue,
|
||||||
profile: undefined,
|
profile: undefined,
|
||||||
hasReplay: session.has_replay,
|
hasReplay: session.has_replay,
|
||||||
|
groups: session.groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +247,7 @@ export async function getSessionList(options: GetSessionListOptions) {
|
|||||||
'screen_view_count',
|
'screen_view_count',
|
||||||
'event_count',
|
'event_count',
|
||||||
'revenue',
|
'revenue',
|
||||||
|
'groups',
|
||||||
];
|
];
|
||||||
|
|
||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
@@ -292,6 +296,7 @@ export async function getSessionList(options: GetSessionListOptions) {
|
|||||||
projectId,
|
projectId,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
|
groups: [],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ describe('mixpanel', () => {
|
|||||||
sdk_name: 'mixpanel (web)',
|
sdk_name: 'mixpanel (web)',
|
||||||
sdk_version: '1.0.0',
|
sdk_version: '1.0.0',
|
||||||
session_id: '',
|
session_id: '',
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
|
|||||||
properties,
|
properties,
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
is_external: true,
|
is_external: true,
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +537,7 @@ export class MixpanelProvider extends BaseImportProvider<MixpanelRawEvent> {
|
|||||||
? `${this.provider} (${props.mp_lib})`
|
? `${this.provider} (${props.mp_lib})`
|
||||||
: this.provider,
|
: this.provider,
|
||||||
sdk_version: this.version,
|
sdk_version: this.version,
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Remove this
|
// TODO: Remove this
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export class UmamiProvider extends BaseImportProvider<UmamiRawEvent> {
|
|||||||
imported_at: new Date().toISOString(),
|
imported_at: new Date().toISOString(),
|
||||||
sdk_name: this.provider,
|
sdk_name: this.provider,
|
||||||
sdk_version: this.version,
|
sdk_version: this.version,
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
|
|
||||||
const redactSensitiveInfo = winston.format((info) => {
|
const redactSensitiveInfo = winston.format((info) => {
|
||||||
const redactObject = (obj: any): any => {
|
const redactObject = (obj: any): any => {
|
||||||
if (!obj || typeof obj !== 'object') return obj;
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(obj).reduce((acc, key) => {
|
return Object.keys(obj).reduce((acc, key) => {
|
||||||
const lowerKey = key.toLowerCase();
|
const lowerKey = key.toLowerCase();
|
||||||
@@ -85,7 +87,7 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
}, {} as any);
|
}, {} as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
return Object.assign({}, info, redactObject(info));
|
return { ...info, ...redactObject(info) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const transports: winston.transport[] = [];
|
const transports: winston.transport[] = [];
|
||||||
@@ -96,12 +98,12 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
HyperDX.getWinstonTransport(logLevel, {
|
HyperDX.getWinstonTransport(logLevel, {
|
||||||
detectResources: true,
|
detectResources: true,
|
||||||
service,
|
service,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
format = winston.format.combine(
|
format = winston.format.combine(
|
||||||
errorFormatter(),
|
errorFormatter(),
|
||||||
redactSensitiveInfo(),
|
redactSensitiveInfo(),
|
||||||
winston.format.json(),
|
winston.format.json()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
transports.push(new winston.transports.Console());
|
transports.push(new winston.transports.Console());
|
||||||
@@ -116,7 +118,7 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
const metaStr =
|
const metaStr =
|
||||||
Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
|
Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
|
||||||
return `${level} ${message}${metaStr}`;
|
return `${level} ${message}${metaStr}`;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
format,
|
format,
|
||||||
transports,
|
transports,
|
||||||
silent,
|
silent,
|
||||||
levels: Object.assign({}, customLevels, winston.config.syslog.levels),
|
levels: { ...customLevels, ...winston.config.syslog.levels },
|
||||||
});
|
});
|
||||||
|
|
||||||
return logger;
|
return logger;
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ export type CronQueuePayloadGscSync = {
|
|||||||
type: 'gscSync';
|
type: 'gscSync';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
export type CronQueuePayloadFlushGroups = {
|
||||||
|
type: 'flushGroups';
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
export type CronQueuePayload =
|
export type CronQueuePayload =
|
||||||
| CronQueuePayloadSalt
|
| CronQueuePayloadSalt
|
||||||
| CronQueuePayloadFlushEvents
|
| CronQueuePayloadFlushEvents
|
||||||
@@ -141,6 +145,7 @@ export type CronQueuePayload =
|
|||||||
| CronQueuePayloadFlushProfiles
|
| CronQueuePayloadFlushProfiles
|
||||||
| CronQueuePayloadFlushProfileBackfill
|
| CronQueuePayloadFlushProfileBackfill
|
||||||
| CronQueuePayloadFlushReplay
|
| CronQueuePayloadFlushReplay
|
||||||
|
| CronQueuePayloadFlushGroups
|
||||||
| CronQueuePayloadPing
|
| CronQueuePayloadPing
|
||||||
| CronQueuePayloadProject
|
| CronQueuePayloadProject
|
||||||
| CronQueuePayloadInsightsDaily
|
| CronQueuePayloadInsightsDaily
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
IAliasPayload as AliasPayload,
|
IAliasPayload as AliasPayload,
|
||||||
|
IAssignGroupPayload as AssignGroupPayload,
|
||||||
IDecrementPayload as DecrementPayload,
|
IDecrementPayload as DecrementPayload,
|
||||||
|
IGroupPayload as GroupPayload,
|
||||||
IIdentifyPayload as IdentifyPayload,
|
IIdentifyPayload as IdentifyPayload,
|
||||||
IIncrementPayload as IncrementPayload,
|
IIncrementPayload as IncrementPayload,
|
||||||
ITrackHandlerPayload as TrackHandlerPayload,
|
ITrackHandlerPayload as TrackHandlerPayload,
|
||||||
@@ -12,7 +14,9 @@ import { Api } from './api';
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
AliasPayload,
|
AliasPayload,
|
||||||
|
AssignGroupPayload,
|
||||||
DecrementPayload,
|
DecrementPayload,
|
||||||
|
GroupPayload,
|
||||||
IdentifyPayload,
|
IdentifyPayload,
|
||||||
IncrementPayload,
|
IncrementPayload,
|
||||||
TrackHandlerPayload,
|
TrackHandlerPayload,
|
||||||
@@ -22,8 +26,11 @@ export type {
|
|||||||
export interface TrackProperties {
|
export interface TrackProperties {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpsertGroupPayload = GroupPayload;
|
||||||
|
|
||||||
export interface OpenPanelOptions {
|
export interface OpenPanelOptions {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
@@ -45,6 +52,7 @@ export class OpenPanel {
|
|||||||
api: Api;
|
api: Api;
|
||||||
options: OpenPanelOptions;
|
options: OpenPanelOptions;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
|
groups: string[] = [];
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
global?: Record<string, unknown>;
|
global?: Record<string, unknown>;
|
||||||
@@ -142,14 +150,19 @@ export class OpenPanel {
|
|||||||
|
|
||||||
track(name: string, properties?: TrackProperties) {
|
track(name: string, properties?: TrackProperties) {
|
||||||
this.log('track event', name, properties);
|
this.log('track event', name, properties);
|
||||||
|
const { groups: groupsOverride, profileId, ...rest } = properties ?? {};
|
||||||
|
const mergedGroups = [
|
||||||
|
...new Set([...this.groups, ...(groupsOverride ?? [])]),
|
||||||
|
];
|
||||||
return this.send({
|
return this.send({
|
||||||
type: 'track',
|
type: 'track',
|
||||||
payload: {
|
payload: {
|
||||||
name,
|
name,
|
||||||
profileId: properties?.profileId ?? this.profileId,
|
profileId: profileId ?? this.profileId,
|
||||||
|
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
|
||||||
properties: {
|
properties: {
|
||||||
...(this.global ?? {}),
|
...(this.global ?? {}),
|
||||||
...(properties ?? {}),
|
...rest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -176,6 +189,40 @@ export class OpenPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upsertGroup(payload: UpsertGroupPayload) {
|
||||||
|
this.log('upsert group', payload);
|
||||||
|
return this.send({
|
||||||
|
type: 'group',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroup(groupId: string) {
|
||||||
|
this.log('set group', groupId);
|
||||||
|
if (!this.groups.includes(groupId)) {
|
||||||
|
this.groups = [...this.groups, groupId];
|
||||||
|
}
|
||||||
|
return this.send({
|
||||||
|
type: 'assign_group',
|
||||||
|
payload: {
|
||||||
|
groupIds: [groupId],
|
||||||
|
profileId: this.profileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroups(groupIds: string[]) {
|
||||||
|
this.log('set groups', groupIds);
|
||||||
|
this.groups = [...new Set([...this.groups, ...groupIds])];
|
||||||
|
return this.send({
|
||||||
|
type: 'assign_group',
|
||||||
|
payload: {
|
||||||
|
groupIds,
|
||||||
|
profileId: this.profileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated This method is deprecated and will be removed in a future version.
|
* @deprecated This method is deprecated and will be removed in a future version.
|
||||||
*/
|
*/
|
||||||
@@ -227,10 +274,46 @@ export class OpenPanel {
|
|||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.profileId = undefined;
|
this.profileId = undefined;
|
||||||
|
this.groups = [];
|
||||||
this.deviceId = undefined;
|
this.deviceId = undefined;
|
||||||
this.sessionId = undefined;
|
this.sessionId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildFlushPayload(
|
||||||
|
item: TrackHandlerPayload
|
||||||
|
): TrackHandlerPayload['payload'] {
|
||||||
|
if (item.type === 'replay') {
|
||||||
|
return item.payload;
|
||||||
|
}
|
||||||
|
if (item.type === 'track') {
|
||||||
|
const queuedGroups =
|
||||||
|
'groups' in item.payload ? (item.payload.groups ?? []) : [];
|
||||||
|
const mergedGroups = [...new Set([...this.groups, ...queuedGroups])];
|
||||||
|
return {
|
||||||
|
...item.payload,
|
||||||
|
profileId: item.payload.profileId ?? this.profileId,
|
||||||
|
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item.type === 'identify' ||
|
||||||
|
item.type === 'increment' ||
|
||||||
|
item.type === 'decrement'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...item.payload,
|
||||||
|
profileId: item.payload.profileId ?? this.profileId,
|
||||||
|
} as TrackHandlerPayload['payload'];
|
||||||
|
}
|
||||||
|
if (item.type === 'assign_group') {
|
||||||
|
return {
|
||||||
|
...item.payload,
|
||||||
|
profileId: item.payload.profileId ?? this.profileId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item.payload;
|
||||||
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
const remaining: TrackHandlerPayload[] = [];
|
const remaining: TrackHandlerPayload[] = [];
|
||||||
for (const item of this.queue) {
|
for (const item of this.queue) {
|
||||||
@@ -238,16 +321,7 @@ export class OpenPanel {
|
|||||||
remaining.push(item);
|
remaining.push(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const payload =
|
const payload = this.buildFlushPayload(item);
|
||||||
item.type === 'replay'
|
|
||||||
? item.payload
|
|
||||||
: {
|
|
||||||
...item.payload,
|
|
||||||
profileId:
|
|
||||||
'profileId' in item.payload
|
|
||||||
? (item.payload.profileId ?? this.profileId)
|
|
||||||
: this.profileId,
|
|
||||||
};
|
|
||||||
this.send({ ...item, payload } as TrackHandlerPayload);
|
this.send({ ...item, payload } as TrackHandlerPayload);
|
||||||
}
|
}
|
||||||
this.queue = remaining;
|
this.queue = remaining;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { authRouter } from './routers/auth';
|
import { authRouter } from './routers/auth';
|
||||||
import { gscRouter } from './routers/gsc';
|
|
||||||
import { chartRouter } from './routers/chart';
|
import { chartRouter } from './routers/chart';
|
||||||
import { chatRouter } from './routers/chat';
|
import { chatRouter } from './routers/chat';
|
||||||
import { clientRouter } from './routers/client';
|
import { clientRouter } from './routers/client';
|
||||||
import { dashboardRouter } from './routers/dashboard';
|
import { dashboardRouter } from './routers/dashboard';
|
||||||
import { emailRouter } from './routers/email';
|
import { emailRouter } from './routers/email';
|
||||||
import { eventRouter } from './routers/event';
|
import { eventRouter } from './routers/event';
|
||||||
|
import { groupRouter } from './routers/group';
|
||||||
|
import { gscRouter } from './routers/gsc';
|
||||||
import { importRouter } from './routers/import';
|
import { importRouter } from './routers/import';
|
||||||
import { insightRouter } from './routers/insight';
|
import { insightRouter } from './routers/insight';
|
||||||
import { integrationRouter } from './routers/integration';
|
import { integrationRouter } from './routers/integration';
|
||||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
gsc: gscRouter,
|
gsc: gscRouter,
|
||||||
|
group: groupRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import {
|
|||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
getEventFiltersWhereClause,
|
getEventFiltersWhereClause,
|
||||||
getEventMetasCached,
|
getEventMetasCached,
|
||||||
|
getGroupPropertySelect,
|
||||||
|
getProfilePropertySelect,
|
||||||
getProfilesCached,
|
getProfilesCached,
|
||||||
getReportById,
|
getReportById,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
type IClickhouseProfile,
|
|
||||||
type IServiceProfile,
|
type IServiceProfile,
|
||||||
onlyReportEvents,
|
onlyReportEvents,
|
||||||
sankeyService,
|
sankeyService,
|
||||||
@@ -354,6 +355,33 @@ export const chartRouter = createTRPCRouter({
|
|||||||
const res = await query.execute();
|
const res = await query.execute();
|
||||||
|
|
||||||
values.push(...res.map((e) => e.property_value));
|
values.push(...res.map((e) => e.property_value));
|
||||||
|
} else if (property.startsWith('profile.')) {
|
||||||
|
const selectExpr = getProfilePropertySelect(property);
|
||||||
|
const query = clix(ch)
|
||||||
|
.select<{ values: string }>([`distinct ${selectExpr} as values`])
|
||||||
|
.from(TABLE_NAMES.profiles, true)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where(selectExpr, '!=', '')
|
||||||
|
.where(selectExpr, 'IS NOT NULL', null)
|
||||||
|
.orderBy('created_at', 'DESC')
|
||||||
|
.limit(100_000);
|
||||||
|
|
||||||
|
const res = await query.execute();
|
||||||
|
values.push(...res.map((r) => String(r.values)).filter(Boolean));
|
||||||
|
} else if (property.startsWith('group.')) {
|
||||||
|
const selectExpr = getGroupPropertySelect(property);
|
||||||
|
const query = clix(ch)
|
||||||
|
.select<{ values: string }>([`distinct ${selectExpr} as values`])
|
||||||
|
.from(TABLE_NAMES.groups, true)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('deleted', '=', 0)
|
||||||
|
.where(selectExpr, '!=', '')
|
||||||
|
.where(selectExpr, 'IS NOT NULL', null)
|
||||||
|
.orderBy('created_at', 'DESC')
|
||||||
|
.limit(100_000);
|
||||||
|
|
||||||
|
const res = await query.execute();
|
||||||
|
values.push(...res.map((r) => String(r.values)).filter(Boolean));
|
||||||
} else {
|
} else {
|
||||||
const query = clix(ch)
|
const query = clix(ch)
|
||||||
.select<{ values: string[] }>([
|
.select<{ values: string[] }>([
|
||||||
@@ -369,17 +397,6 @@ export const chartRouter = createTRPCRouter({
|
|||||||
query.where('name', '=', event);
|
query.where('name', '=', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (property.startsWith('profile.')) {
|
|
||||||
query.leftAnyJoin(
|
|
||||||
clix(ch)
|
|
||||||
.select<IClickhouseProfile>([])
|
|
||||||
.from(TABLE_NAMES.profiles)
|
|
||||||
.where('project_id', '=', projectId),
|
|
||||||
'profile.id = profile_id',
|
|
||||||
'profile'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await query.execute();
|
const events = await query.execute();
|
||||||
|
|
||||||
values.push(
|
values.push(
|
||||||
@@ -785,7 +802,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
const { sb, getSql } = createSqlBuilder();
|
const { sb, getSql } = createSqlBuilder();
|
||||||
|
|
||||||
sb.select.profile_id = 'DISTINCT profile_id';
|
sb.select.profile_id = 'DISTINCT profile_id';
|
||||||
sb.where = getEventFiltersWhereClause(serie.filters);
|
sb.where = getEventFiltersWhereClause(serie.filters, projectId);
|
||||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||||
sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`;
|
sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`;
|
||||||
if (serie.name !== '*') {
|
if (serie.name !== '*') {
|
||||||
@@ -812,10 +829,22 @@ export const chartRouter = createTRPCRouter({
|
|||||||
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${fieldsToSelect} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${fieldsToSelect} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for group filters/breakdowns and add ARRAY JOIN if needed
|
||||||
|
const anyFilterOnGroup = serie.filters.some((f) =>
|
||||||
|
f.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const anyBreakdownOnGroup = input.breakdowns
|
||||||
|
? Object.keys(input.breakdowns).some((key) => key.startsWith('group.'))
|
||||||
|
: false;
|
||||||
|
if (anyFilterOnGroup || anyBreakdownOnGroup) {
|
||||||
|
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||||
|
sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.breakdowns) {
|
if (input.breakdowns) {
|
||||||
Object.entries(input.breakdowns).forEach(([key, value]) => {
|
Object.entries(input.breakdowns).forEach(([key, value]) => {
|
||||||
// Transform property keys (e.g., properties.method -> properties['method'])
|
// Transform property keys (e.g., properties.method -> properties['method'])
|
||||||
const propertyKey = getSelectPropertyKey(key);
|
const propertyKey = getSelectPropertyKey(key, projectId);
|
||||||
sb.where[`breakdown_${key}`] =
|
sb.where[`breakdown_${key}`] =
|
||||||
`${propertyKey} = ${sqlstring.escape(value)}`;
|
`${propertyKey} = ${sqlstring.escape(value)}`;
|
||||||
});
|
});
|
||||||
@@ -858,6 +887,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
funnelWindow: z.number().optional(),
|
funnelWindow: z.number().optional(),
|
||||||
funnelGroup: z.string().optional(),
|
funnelGroup: z.string().optional(),
|
||||||
breakdowns: z.array(z.object({ name: z.string() })).optional(),
|
breakdowns: z.array(z.object({ name: z.string() })).optional(),
|
||||||
|
breakdownValues: z.array(z.string()).optional(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -870,6 +900,8 @@ export const chartRouter = createTRPCRouter({
|
|||||||
showDropoffs = false,
|
showDropoffs = false,
|
||||||
funnelWindow,
|
funnelWindow,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
|
breakdowns = [],
|
||||||
|
breakdownValues = [],
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
@@ -889,9 +921,21 @@ export const chartRouter = createTRPCRouter({
|
|||||||
// Get the grouping strategy (profile_id or session_id)
|
// Get the grouping strategy (profile_id or session_id)
|
||||||
const group = funnelService.getFunnelGroup(funnelGroup);
|
const group = funnelService.getFunnelGroup(funnelGroup);
|
||||||
|
|
||||||
|
const anyFilterOnGroup = (eventSeries as IChartEvent[]).some((e) =>
|
||||||
|
e.filters?.some((f) => f.name.startsWith('group.'))
|
||||||
|
);
|
||||||
|
const anyBreakdownOnGroup = breakdowns.some((b) =>
|
||||||
|
b.name.startsWith('group.')
|
||||||
|
);
|
||||||
|
const needsGroupArrayJoin = anyFilterOnGroup || anyBreakdownOnGroup;
|
||||||
|
|
||||||
|
// Breakdown selects/groupBy so we can filter by specific breakdown values
|
||||||
|
const breakdownSelects = breakdowns.map(
|
||||||
|
(b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}`
|
||||||
|
);
|
||||||
|
const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`);
|
||||||
|
|
||||||
// Create funnel CTE using funnel service
|
// Create funnel CTE using funnel service
|
||||||
// Note: buildFunnelCte always computes windowFunnel per session_id and extracts
|
|
||||||
// profile_id via argMax to handle identity changes mid-session correctly.
|
|
||||||
const funnelCte = funnelService.buildFunnelCte({
|
const funnelCte = funnelService.buildFunnelCte({
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -899,8 +943,8 @@ export const chartRouter = createTRPCRouter({
|
|||||||
eventSeries: eventSeries as IChartEvent[],
|
eventSeries: eventSeries as IChartEvent[],
|
||||||
funnelWindowMilliseconds,
|
funnelWindowMilliseconds,
|
||||||
timezone,
|
timezone,
|
||||||
// No need to add profile_id to additionalSelects/additionalGroupBy
|
additionalSelects: breakdownSelects,
|
||||||
// since buildFunnelCte already extracts it via argMax(profile_id, created_at)
|
additionalGroupBy: breakdownGroupBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for profile filters and add profile join if needed
|
// Check for profile filters and add profile join if needed
|
||||||
@@ -917,36 +961,50 @@ export const chartRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsGroupArrayJoin) {
|
||||||
|
funnelCte.rawJoin('ARRAY JOIN groups AS _group_id');
|
||||||
|
funnelCte.rawJoin('LEFT ANY JOIN _g ON _g.id = _group_id');
|
||||||
|
}
|
||||||
|
|
||||||
// Build main query
|
// Build main query
|
||||||
const query = clix(ch, timezone);
|
const query = clix(ch, timezone);
|
||||||
|
if (needsGroupArrayJoin) {
|
||||||
|
query.with(
|
||||||
|
'_g',
|
||||||
|
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
query.with('session_funnel', funnelCte);
|
query.with('session_funnel', funnelCte);
|
||||||
|
|
||||||
if (group === 'profile_id') {
|
if (group === 'profile_id') {
|
||||||
// For profile grouping: re-aggregate by profile_id, taking MAX level per profile.
|
const breakdownAggregates =
|
||||||
// This ensures a user who completed the funnel with identity change is counted correctly.
|
breakdowns.length > 0
|
||||||
// NOTE: Wrap in subquery to avoid ClickHouse resolving `level` in WHERE to the
|
? `, ${breakdowns.map((_, index) => `any(b_${index}) AS b_${index}`).join(', ')}`
|
||||||
// `max(level) AS level` alias (ILLEGAL_AGGREGATION error).
|
: '';
|
||||||
query.with(
|
query.with(
|
||||||
'funnel',
|
'funnel',
|
||||||
'SELECT profile_id, max(level) AS level FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id'
|
`SELECT profile_id, max(level) AS level${breakdownAggregates} FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// For session grouping: filter out level = 0 inside the CTE
|
|
||||||
query.with('funnel', 'SELECT * FROM session_funnel WHERE level != 0');
|
query.with('funnel', 'SELECT * FROM session_funnel WHERE level != 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get distinct profile IDs
|
|
||||||
// NOTE: level != 0 is already filtered inside the funnel CTE above
|
|
||||||
query.select(['DISTINCT profile_id']).from('funnel');
|
query.select(['DISTINCT profile_id']).from('funnel');
|
||||||
|
|
||||||
if (showDropoffs) {
|
if (showDropoffs) {
|
||||||
// Show users who dropped off at this step (completed this step but not the next)
|
|
||||||
query.where('level', '=', targetLevel);
|
query.where('level', '=', targetLevel);
|
||||||
} else {
|
} else {
|
||||||
// Show users who completed at least this step
|
|
||||||
query.where('level', '>=', targetLevel);
|
query.where('level', '>=', targetLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by specific breakdown values when a breakdown row was clicked
|
||||||
|
breakdowns.forEach((_, index) => {
|
||||||
|
const value = breakdownValues[index];
|
||||||
|
if (value !== undefined) {
|
||||||
|
query.where(`b_${index}`, '=', value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cap the number of profiles to avoid exceeding ClickHouse max_query_size
|
// Cap the number of profiles to avoid exceeding ClickHouse max_query_size
|
||||||
// when passing IDs to the next query
|
// when passing IDs to the next query
|
||||||
query.limit(1000);
|
query.limit(1000);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export const eventRouter = createTRPCRouter({
|
|||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
profileId: z.string().optional(),
|
profileId: z.string().optional(),
|
||||||
sessionId: z.string().optional(),
|
sessionId: z.string().optional(),
|
||||||
|
groupId: z.string().optional(),
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
filters: z.array(zChartEventFilter).default([]),
|
filters: z.array(zChartEventFilter).default([]),
|
||||||
startDate: z.date().optional(),
|
startDate: z.date().optional(),
|
||||||
|
|||||||
233
packages/trpc/src/routers/group.ts
Normal file
233
packages/trpc/src/routers/group.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
chQuery,
|
||||||
|
createGroup,
|
||||||
|
deleteGroup,
|
||||||
|
getGroupById,
|
||||||
|
getGroupList,
|
||||||
|
getGroupListCount,
|
||||||
|
getGroupMemberProfiles,
|
||||||
|
getGroupPropertyKeys,
|
||||||
|
getGroupStats,
|
||||||
|
getGroupsByIds,
|
||||||
|
getGroupTypes,
|
||||||
|
TABLE_NAMES,
|
||||||
|
toNullIfDefaultMinDate,
|
||||||
|
updateGroup,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
import { zCreateGroup, zUpdateGroup } from '@openpanel/validation';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
|
export const groupRouter = createTRPCRouter({
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
cursor: z.number().optional(),
|
||||||
|
take: z.number().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
getGroupList(input),
|
||||||
|
getGroupListCount(input),
|
||||||
|
]);
|
||||||
|
const stats = await getGroupStats(
|
||||||
|
input.projectId,
|
||||||
|
data.map((g) => g.id)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: data.map((g) => ({
|
||||||
|
...g,
|
||||||
|
memberCount: stats.get(g.id)?.memberCount ?? 0,
|
||||||
|
lastActiveAt: stats.get(g.id)?.lastActiveAt ?? null,
|
||||||
|
})),
|
||||||
|
meta: { count, take: input.take },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
byId: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return getGroupById(id, projectId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(zCreateGroup)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
return createGroup(input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(zUpdateGroup)
|
||||||
|
.mutation(({ input: { id, projectId, ...data } }) => {
|
||||||
|
return updateGroup(id, projectId, data);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.mutation(({ input: { id, projectId } }) => {
|
||||||
|
return deleteGroup(id, projectId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
types: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(({ input: { projectId } }) => {
|
||||||
|
return getGroupTypes(projectId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
metrics: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(async ({ input: { id, projectId } }) => {
|
||||||
|
const data = await chQuery<{
|
||||||
|
totalEvents: number;
|
||||||
|
uniqueProfiles: number;
|
||||||
|
firstSeen: string;
|
||||||
|
lastSeen: string;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
count() AS totalEvents,
|
||||||
|
uniqExact(profile_id) AS uniqueProfiles,
|
||||||
|
min(created_at) AS firstSeen,
|
||||||
|
max(created_at) AS lastSeen
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEvents: data[0]?.totalEvents ?? 0,
|
||||||
|
uniqueProfiles: data[0]?.uniqueProfiles ?? 0,
|
||||||
|
firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen),
|
||||||
|
lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
activity: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return chQuery<{ count: number; date: string }>(`
|
||||||
|
SELECT count() AS count, toStartOfDay(created_at) AS date
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date DESC
|
||||||
|
`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
members: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return chQuery<{
|
||||||
|
profileId: string;
|
||||||
|
lastSeen: string;
|
||||||
|
eventCount: number;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
profile_id AS profileId,
|
||||||
|
max(created_at) AS lastSeen,
|
||||||
|
count() AS eventCount
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
AND profile_id != device_id
|
||||||
|
GROUP BY profile_id
|
||||||
|
ORDER BY lastSeen DESC, eventCount DESC
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
listProfiles: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
groupId: z.string(),
|
||||||
|
cursor: z.number().optional(),
|
||||||
|
take: z.number().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { data, count } = await getGroupMemberProfiles({
|
||||||
|
projectId: input.projectId,
|
||||||
|
groupId: input.groupId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
take: input.take,
|
||||||
|
search: input.search,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: { count, pageCount: input.take },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
mostEvents: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return chQuery<{ count: number; name: string }>(`
|
||||||
|
SELECT count() as count, name
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
AND name NOT IN ('screen_view', 'session_start', 'session_end')
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
popularRoutes: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return chQuery<{ count: number; path: string }>(`
|
||||||
|
SELECT count() as count, path
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
AND name = 'screen_view'
|
||||||
|
GROUP BY path
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
memberGrowth: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||||
|
.query(({ input: { id, projectId } }) => {
|
||||||
|
return chQuery<{ date: string; count: number }>(`
|
||||||
|
SELECT
|
||||||
|
toDate(toStartOfDay(min_date)) AS date,
|
||||||
|
count() AS count
|
||||||
|
FROM (
|
||||||
|
SELECT profile_id, min(created_at) AS min_date
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||||
|
AND has(groups, ${sqlstring.escape(id)})
|
||||||
|
AND profile_id != device_id
|
||||||
|
AND created_at >= now() - INTERVAL 30 DAY
|
||||||
|
GROUP BY profile_id
|
||||||
|
)
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC WITH FILL
|
||||||
|
FROM toDate(now() - INTERVAL 29 DAY)
|
||||||
|
TO toDate(now() + INTERVAL 1 DAY)
|
||||||
|
STEP 1
|
||||||
|
`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
properties: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(({ input: { projectId } }) => {
|
||||||
|
return getGroupPropertyKeys(projectId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
listByIds: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string(), ids: z.array(z.string()) }))
|
||||||
|
.query(({ input: { projectId, ids } }) => {
|
||||||
|
return getGroupsByIds(projectId, ids);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -540,6 +540,32 @@ export const zCheckout = z.object({
|
|||||||
});
|
});
|
||||||
export type ICheckout = z.infer<typeof zCheckout>;
|
export type ICheckout = z.infer<typeof zCheckout>;
|
||||||
|
|
||||||
|
export const zGroupId = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9_-]+$/,
|
||||||
|
'ID must only contain lowercase letters, digits, hyphens, or underscores',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const zCreateGroup = z.object({
|
||||||
|
id: zGroupId,
|
||||||
|
projectId: z.string(),
|
||||||
|
type: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
properties: z.record(z.string()).default({}),
|
||||||
|
});
|
||||||
|
export type ICreateGroup = z.infer<typeof zCreateGroup>;
|
||||||
|
|
||||||
|
export const zUpdateGroup = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
projectId: z.string(),
|
||||||
|
type: z.string().min(1).optional(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
});
|
||||||
|
export type IUpdateGroup = z.infer<typeof zUpdateGroup>;
|
||||||
|
|
||||||
export const zEditOrganization = z.object({
|
export const zEditOrganization = z.object({
|
||||||
id: z.string().min(2),
|
id: z.string().min(2),
|
||||||
name: z.string().min(2),
|
name: z.string().min(2),
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isBlockedEventName } from './event-blocklist';
|
import { isBlockedEventName } from './event-blocklist';
|
||||||
|
|
||||||
|
export const zGroupPayload = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
properties: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zAssignGroupPayload = z.object({
|
||||||
|
groupIds: z.array(z.string().min(1)),
|
||||||
|
profileId: z.union([z.string().min(1), z.number()]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const zTrackPayload = z
|
export const zTrackPayload = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
properties: z.record(z.string(), z.unknown()).optional(),
|
properties: z.record(z.string(), z.unknown()).optional(),
|
||||||
profileId: z.string().or(z.number()).optional(),
|
profileId: z.union([z.string().min(1), z.number()]).optional(),
|
||||||
|
groups: z.array(z.string().min(1)).optional(),
|
||||||
})
|
})
|
||||||
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
|
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
|
||||||
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
|
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
|
||||||
@@ -97,6 +110,14 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('replay'),
|
type: z.literal('replay'),
|
||||||
payload: zReplayPayload,
|
payload: zReplayPayload,
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('group'),
|
||||||
|
payload: zGroupPayload,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('assign_group'),
|
||||||
|
payload: zAssignGroupPayload,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
||||||
@@ -105,6 +126,8 @@ export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
|
|||||||
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
||||||
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
||||||
export type IReplayPayload = z.infer<typeof zReplayPayload>;
|
export type IReplayPayload = z.infer<typeof zReplayPayload>;
|
||||||
|
export type IGroupPayload = z.infer<typeof zGroupPayload>;
|
||||||
|
export type IAssignGroupPayload = z.infer<typeof zAssignGroupPayload>;
|
||||||
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
||||||
|
|
||||||
// Deprecated types for beta version of the SDKs
|
// Deprecated types for beta version of the SDKs
|
||||||
|
|||||||
974
pnpm-lock.yaml
generated
974
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
599
scripts/seed-events.mjs
Normal file
599
scripts/seed-events.mjs
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Seed script for generating realistic analytics events.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/seed-events.mjs [--timeline=30] [--sessions=500] [--url=http://localhost:3333]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --timeline=N Duration in minutes to spread events over (default: 30)
|
||||||
|
* --sessions=N Number of sessions to generate (default: 500)
|
||||||
|
* --url=URL API base URL (default: http://localhost:3333)
|
||||||
|
* --clientId=ID Client ID to use (required or set CLIENT_ID env var)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const args = Object.fromEntries(
|
||||||
|
process.argv.slice(2).map((a) => {
|
||||||
|
const [k, v] = a.replace(/^--/, '').split('=');
|
||||||
|
return [k, v ?? true];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const TIMELINE_MINUTES = Number(args.timeline ?? 30);
|
||||||
|
const SESSION_COUNT = Number(args.sessions ?? 500);
|
||||||
|
const BASE_URL = args.url ?? 'http://localhost:3333';
|
||||||
|
const CLIENT_ID = args.clientId ?? process.env.CLIENT_ID ?? '';
|
||||||
|
const ORIGIN = args.origin ?? process.env.ORIGIN ?? 'https://shop.example.com';
|
||||||
|
const CONCURRENCY = 20; // max parallel requests
|
||||||
|
|
||||||
|
if (!CLIENT_ID) {
|
||||||
|
console.error('ERROR: provide --clientId=<id> or set CLIENT_ID env var');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_URL = `${BASE_URL}/track`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic seeded random (mulberry32) — keeps identities stable across runs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mulberry32(seed) {
|
||||||
|
return function () {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-deterministic random for events (differs on each run)
|
||||||
|
const eventRng = Math.random.bind(Math);
|
||||||
|
|
||||||
|
function pick(arr, rng = eventRng) {
|
||||||
|
return arr[Math.floor(rng() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min, max, rng = eventRng) {
|
||||||
|
return Math.floor(rng() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randFloat(min, max, rng = eventRng) {
|
||||||
|
return rng() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake data pools
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Hank',
|
||||||
|
'Iris', 'Jack', 'Karen', 'Leo', 'Mia', 'Noah', 'Olivia', 'Pete',
|
||||||
|
'Quinn', 'Rachel', 'Sam', 'Tina', 'Uma', 'Victor', 'Wendy', 'Xavier',
|
||||||
|
'Yara', 'Zoe', 'Aaron', 'Bella', 'Carlos', 'Dani', 'Ethan', 'Fiona',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
|
||||||
|
'Davis', 'Wilson', 'Taylor', 'Anderson', 'Thomas', 'Jackson', 'White',
|
||||||
|
'Harris', 'Martin', 'Thompson', 'Moore', 'Young', 'Allen',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMAIL_DOMAINS = ['gmail.com', 'yahoo.com', 'outlook.com', 'icloud.com', 'proton.me'];
|
||||||
|
|
||||||
|
const USER_AGENTS = [
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
|
||||||
|
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:122.0) Gecko/20100101 Firefox/122.0',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Linux; Android 13; Samsung Galaxy S23) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 OPR/107.0.0.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ensure each session has a unique UA by appending a suffix
|
||||||
|
function makeUniqueUA(base, index) {
|
||||||
|
return `${base} Session/${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a plausible IP address (avoiding private ranges)
|
||||||
|
function makeIP(index) {
|
||||||
|
// Use a spread across several /8 public ranges
|
||||||
|
const ranges = [
|
||||||
|
[34, 0, 0, 0],
|
||||||
|
[52, 0, 0, 0],
|
||||||
|
[104, 0, 0, 0],
|
||||||
|
[185, 0, 0, 0],
|
||||||
|
[213, 0, 0, 0],
|
||||||
|
];
|
||||||
|
const base = ranges[index % ranges.length];
|
||||||
|
const a = base[0];
|
||||||
|
const b = Math.floor(index / 65025) % 256;
|
||||||
|
const c = Math.floor(index / 255) % 256;
|
||||||
|
const d = index % 255 + 1;
|
||||||
|
return `${a}.${b}.${c}.${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Products & categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{ id: 'prod_001', name: 'Wireless Headphones', category: 'Electronics', price: 8999 },
|
||||||
|
{ id: 'prod_002', name: 'Running Shoes', category: 'Sports', price: 12999 },
|
||||||
|
{ id: 'prod_003', name: 'Coffee Maker', category: 'Kitchen', price: 5499 },
|
||||||
|
{ id: 'prod_004', name: 'Yoga Mat', category: 'Sports', price: 2999 },
|
||||||
|
{ id: 'prod_005', name: 'Smart Watch', category: 'Electronics', price: 29999 },
|
||||||
|
{ id: 'prod_006', name: 'Blender', category: 'Kitchen', price: 7999 },
|
||||||
|
{ id: 'prod_007', name: 'Backpack', category: 'Travel', price: 4999 },
|
||||||
|
{ id: 'prod_008', name: 'Sunglasses', category: 'Accessories', price: 3499 },
|
||||||
|
{ id: 'prod_009', name: 'Novel: The Last Algorithm', category: 'Books', price: 1499 },
|
||||||
|
{ id: 'prod_010', name: 'Standing Desk', category: 'Furniture', price: 45999 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORIES = ['Electronics', 'Sports', 'Kitchen', 'Travel', 'Accessories', 'Books', 'Furniture'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Groups (3 pre-defined companies)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: 'org_acme', type: 'company', name: 'Acme Corp', properties: { plan: 'enterprise', industry: 'Technology', employees: 500 } },
|
||||||
|
{ id: 'org_globex', type: 'company', name: 'Globex Inc', properties: { plan: 'pro', industry: 'Finance', employees: 120 } },
|
||||||
|
{ id: 'org_initech', type: 'company', name: 'Initech LLC', properties: { plan: 'starter', industry: 'Consulting', employees: 45 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenarios — 20 distinct user journeys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each scenario returns a list of event descriptors.
|
||||||
|
* screen_view events use a `path` property (origin + pathname).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCENARIOS = [
|
||||||
|
// 1. Full e-commerce checkout success
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://google.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/cart`, title: 'Cart' } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/review`, title: 'Order Review' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id, product_name: product.name, quantity: 1 }, revenue: true },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/success`, title: 'Order Confirmed' } },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 2. Checkout failed (payment declined)
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'express', estimated_days: 2 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'checkout_failed', props: { reason: 'payment_declined', error_code: 'insufficient_funds' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 3. Browse only — no purchase
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://facebook.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/categories/${product.category.toLowerCase()}`, title: product.category } },
|
||||||
|
{ name: 'category_viewed', props: { category: product.category } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${PRODUCTS[1].id}`, title: PRODUCTS[1].name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: PRODUCTS[1].id, product_name: PRODUCTS[1].name, price: PRODUCTS[1].price, category: PRODUCTS[1].category } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 4. Add to cart then abandon
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/cart`, title: 'Cart' } },
|
||||||
|
{ name: 'cart_abandoned', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 5. Search → product → purchase
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'search', props: { query: product.name.split(' ')[0], result_count: randInt(3, 20) } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/search?q=${encodeURIComponent(product.name)}`, title: 'Search Results' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'paypal' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id, product_name: product.name, quantity: 1 }, revenue: true },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 6. Sign up flow
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://twitter.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/signup`, title: 'Sign Up' } },
|
||||||
|
{ name: 'signup_started', props: {} },
|
||||||
|
{ name: 'signup_step_completed', props: { step: 'email', step_number: 1 } },
|
||||||
|
{ name: 'signup_step_completed', props: { step: 'password', step_number: 2 } },
|
||||||
|
{ name: 'signup_step_completed', props: { step: 'profile', step_number: 3 } },
|
||||||
|
{ name: 'signup_completed', props: { method: 'email' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/dashboard`, title: 'Dashboard' } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 7. Login → browse → wishlist
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/login`, title: 'Login' } },
|
||||||
|
{ name: 'login', props: { method: 'email' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_wishlist', props: { product_id: product.id, product_name: product.name, price: product.price } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/wishlist`, title: 'Wishlist' } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 8. Promo code → purchase
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://newsletter.example.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'promo_code_applied', props: { code: 'SAVE20', discount_percent: 20, discount_amount: Math.round(product.price * 0.2) } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: Math.round(product.price * 0.8), product_id: product.id, product_name: product.name, quantity: 1, promo_code: 'SAVE20' }, revenue: true },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: Math.round(product.price * 0.8) } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 9. Multi-item purchase
|
||||||
|
(_product) => {
|
||||||
|
const p1 = PRODUCTS[0];
|
||||||
|
const p2 = PRODUCTS[3];
|
||||||
|
const total = p1.price + p2.price;
|
||||||
|
return [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${p1.id}`, title: p1.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: p1.id, product_name: p1.name, price: p1.price, category: p1.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: p1.id, product_name: p1.name, price: p1.price, quantity: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${p2.id}`, title: p2.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: p2.id, product_name: p2.name, price: p2.price, category: p2.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: p2.id, product_name: p2.name, price: p2.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: total, item_count: 2 } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'express', estimated_days: 2 } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: total, item_count: 2 }, revenue: true },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: total } },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 10. Help center visit
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/help`, title: 'Help Center' } },
|
||||||
|
{ name: 'help_search', props: { query: 'return policy', result_count: 4 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/help/returns`, title: 'Return Policy' } },
|
||||||
|
{ name: 'help_article_read', props: { article: 'return_policy', time_on_page: randInt(60, 180) } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/help/shipping`, title: 'Shipping Info' } },
|
||||||
|
{ name: 'help_article_read', props: { article: 'shipping_times', time_on_page: randInt(30, 120) } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 11. Product review submitted
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'review_started', props: { product_id: product.id } },
|
||||||
|
{ name: 'review_submitted', props: { product_id: product.id, rating: randInt(3, 5), has_text: true } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 12. Newsletter signup only
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://instagram.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/blog`, title: 'Blog' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/blog/top-10-gadgets-2024`, title: 'Top 10 Gadgets 2024' } },
|
||||||
|
{ name: 'newsletter_signup', props: { source: 'blog_article', campaign: 'gadgets_2024' } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 13. Account settings update
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/login`, title: 'Login' } },
|
||||||
|
{ name: 'login', props: { method: 'google' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/account`, title: 'Account' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/account/settings`, title: 'Settings' } },
|
||||||
|
{ name: 'settings_updated', props: { field: 'notification_preferences', value: 'email_only' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/account/address`, title: 'Addresses' } },
|
||||||
|
{ name: 'address_added', props: { is_default: true } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 14. Referral program engagement
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://referral.example.com/?ref=abc123' } },
|
||||||
|
{ name: 'referral_link_clicked', props: { referrer_id: 'usr_ref123', campaign: 'summer_referral' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, referral_code: 'abc123' }, revenue: true },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 15. Mobile quick browse — short session
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/categories/${product.category.toLowerCase()}`, title: product.category } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 16. Compare products
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'compare_added', props: { product_id: product.id } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${PRODUCTS[4].id}`, title: PRODUCTS[4].name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: PRODUCTS[4].id, product_name: PRODUCTS[4].name, price: PRODUCTS[4].price, category: PRODUCTS[4].category } },
|
||||||
|
{ name: 'compare_added', props: { product_id: PRODUCTS[4].id } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/compare?ids=${product.id},${PRODUCTS[4].id}`, title: 'Compare Products' } },
|
||||||
|
{ name: 'compare_viewed', props: { product_ids: [product.id, PRODUCTS[4].id] } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 17. Shipping failure retry → success
|
||||||
|
(product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } },
|
||||||
|
{ name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } },
|
||||||
|
{ name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } },
|
||||||
|
{ name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } },
|
||||||
|
{ name: 'shipping_info_error', props: { error: 'invalid_address', attempt: 1 } },
|
||||||
|
{ name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5, attempt: 2 } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id }, revenue: true },
|
||||||
|
{ name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 18. Subscription / SaaS upgrade
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/pricing`, title: 'Pricing' } },
|
||||||
|
{ name: 'pricing_viewed', props: {} },
|
||||||
|
{ name: 'plan_selected', props: { plan: 'pro', billing: 'annual', price: 9900 } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/checkout/subscription`, title: 'Subscribe' } },
|
||||||
|
{ name: 'payment_info_submitted', props: { payment_method: 'credit_card' } },
|
||||||
|
{ name: 'subscription_started', props: { plan: 'pro', billing: 'annual', revenue: 9900 }, revenue: true },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/dashboard`, title: 'Dashboard' } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 19. Deep content engagement (blog)
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/blog`, title: 'Blog', referrer: 'https://google.com' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/blog/buying-guide-headphones`, title: 'Headphones Buying Guide' } },
|
||||||
|
{ name: 'content_read', props: { article: 'headphones_buying_guide', reading_time: randInt(120, 480), scroll_depth: randFloat(0.6, 1.0) } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/blog/best-running-shoes-2024`, title: 'Best Running Shoes 2024' } },
|
||||||
|
{ name: 'content_read', props: { article: 'best_running_shoes_2024', reading_time: randInt(90, 300), scroll_depth: randFloat(0.5, 1.0) } },
|
||||||
|
],
|
||||||
|
|
||||||
|
// 20. Error / 404 bounce
|
||||||
|
(_product) => [
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/products/old-discontinued-product`, title: 'Product Not Found' } },
|
||||||
|
{ name: 'page_error', props: { error_code: 404, path: '/products/old-discontinued-product' } },
|
||||||
|
{ name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Identity generation (deterministic by session index)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function generateIdentity(sessionIndex, sessionRng) {
|
||||||
|
const firstName = pick(FIRST_NAMES, sessionRng);
|
||||||
|
const lastName = pick(LAST_NAMES, sessionRng);
|
||||||
|
const emailDomain = pick(EMAIL_DOMAINS, sessionRng);
|
||||||
|
const profileId = `user_${String(sessionIndex + 1).padStart(4, '0')}`;
|
||||||
|
return {
|
||||||
|
profileId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${sessionIndex}@${emailDomain}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which sessions belong to which group (roughly 1/6 each)
|
||||||
|
function getGroupForSession(sessionIndex) {
|
||||||
|
if (sessionIndex % 6 === 0) return GROUPS[0];
|
||||||
|
if (sessionIndex % 6 === 1) return GROUPS[1];
|
||||||
|
if (sessionIndex % 6 === 2) return GROUPS[2];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function sendEvent(payload, ua, ip) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'user-agent': ua,
|
||||||
|
'openpanel-client-id': CLIENT_ID,
|
||||||
|
'x-forwarded-for': ip,
|
||||||
|
origin: ORIGIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(TRACK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.warn(` [WARN] ${res.status} ${payload.type}/${payload.payload?.name ?? ''}: ${text.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build session event list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSession(sessionIndex) {
|
||||||
|
const sessionRng = mulberry32(sessionIndex * 9973 + 1337); // deterministic per session
|
||||||
|
|
||||||
|
const identity = generateIdentity(sessionIndex, sessionRng);
|
||||||
|
const group = getGroupForSession(sessionIndex);
|
||||||
|
const ua = makeUniqueUA(pick(USER_AGENTS, sessionRng), sessionIndex);
|
||||||
|
const ip = makeIP(sessionIndex);
|
||||||
|
const product = pick(PRODUCTS, sessionRng);
|
||||||
|
const scenarioFn = SCENARIOS[sessionIndex % SCENARIOS.length];
|
||||||
|
const events = scenarioFn(product);
|
||||||
|
|
||||||
|
return { identity, group, ua, ip, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule events across timeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function scheduleSession(session, sessionIndex, totalSessions) {
|
||||||
|
const timelineMs = TIMELINE_MINUTES * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Sessions are spread across the timeline
|
||||||
|
const sessionStartOffset = (sessionIndex / totalSessions) * timelineMs;
|
||||||
|
const sessionStart = now - timelineMs + sessionStartOffset;
|
||||||
|
|
||||||
|
// Events within session: spread over 2-10 minutes
|
||||||
|
const sessionDurationMs = randInt(2, 10) * 60 * 1000;
|
||||||
|
const eventCount = session.events.length;
|
||||||
|
|
||||||
|
return session.events.map((event, i) => {
|
||||||
|
const eventOffset = eventCount > 1 ? (i / (eventCount - 1)) * sessionDurationMs : 0;
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
timestamp: Math.round(sessionStart + eventOffset),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Concurrency limiter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function withConcurrency(tasks, limit) {
|
||||||
|
const results = [];
|
||||||
|
const executing = [];
|
||||||
|
for (const task of tasks) {
|
||||||
|
const p = Promise.resolve().then(task);
|
||||||
|
results.push(p);
|
||||||
|
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
|
||||||
|
executing.push(e);
|
||||||
|
if (executing.length >= limit) {
|
||||||
|
await Promise.race(executing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\nSeeding ${SESSION_COUNT} sessions over ${TIMELINE_MINUTES} minutes`);
|
||||||
|
console.log(`API: ${TRACK_URL}`);
|
||||||
|
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||||
|
|
||||||
|
let totalEvents = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const sessionTasks = Array.from({ length: SESSION_COUNT }, (_, i) => async () => {
|
||||||
|
const session = buildSession(i);
|
||||||
|
const scheduledEvents = scheduleSession(session, i, SESSION_COUNT);
|
||||||
|
const { identity, group, ua, ip } = session;
|
||||||
|
|
||||||
|
// 1. Identify
|
||||||
|
try {
|
||||||
|
await sendEvent({ type: 'identify', payload: identity }, ua, ip);
|
||||||
|
} catch (e) {
|
||||||
|
errors++;
|
||||||
|
console.error(` [ERROR] identify session ${i}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Group (if applicable)
|
||||||
|
if (group) {
|
||||||
|
try {
|
||||||
|
await sendEvent({ type: 'group', payload: { ...group, profileId: identity.profileId } }, ua, ip);
|
||||||
|
} catch (e) {
|
||||||
|
errors++;
|
||||||
|
console.error(` [ERROR] group session ${i}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Track events in order
|
||||||
|
for (const ev of scheduledEvents) {
|
||||||
|
const trackPayload = {
|
||||||
|
name: ev.name,
|
||||||
|
profileId: identity.profileId,
|
||||||
|
properties: {
|
||||||
|
...ev.props,
|
||||||
|
__timestamp: new Date(ev.timestamp).toISOString(),
|
||||||
|
...(group ? { __group: group.id } : {}),
|
||||||
|
},
|
||||||
|
groups: group ? [group.id] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ev.revenue) {
|
||||||
|
trackPayload.properties.__revenue = ev.props.revenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEvent({ type: 'track', payload: trackPayload }, ua, ip);
|
||||||
|
totalEvents++;
|
||||||
|
} catch (e) {
|
||||||
|
errors++;
|
||||||
|
console.error(` [ERROR] track ${ev.name} session ${i}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i + 1) % 50 === 0 || i + 1 === SESSION_COUNT) {
|
||||||
|
console.log(` Progress: ${i + 1}/${SESSION_COUNT} sessions`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await withConcurrency(sessionTasks, CONCURRENCY);
|
||||||
|
|
||||||
|
console.log(`\nDone!`);
|
||||||
|
console.log(` Sessions: ${SESSION_COUNT}`);
|
||||||
|
console.log(` Events sent: ${totalEvents}`);
|
||||||
|
console.log(` Errors: ${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user