diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list.tsx index e39fd3db..8c86e380 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list.tsx @@ -30,7 +30,7 @@ interface EventListProps { count: number; } export function EventList({ data, count }: EventListProps) { - const { cursor, setCursor } = useCursor(); + const { cursor, setCursor, loading } = useCursor(); const [filters] = useEventQueryFilters(); return ( <> @@ -77,6 +77,7 @@ export function EventList({ data, count }: EventListProps) { setCursor={setCursor} count={count} take={50} + loading={loading} /> )} @@ -92,6 +93,7 @@ export function EventList({ data, count }: EventListProps) { setCursor={setCursor} count={count} take={50} + loading={loading} /> )} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx deleted file mode 100644 index 9cf2cf50..00000000 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import FullPageLoadingState from '@/components/full-page-loading-state'; - -export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx index 8abcd29c..8821e926 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { Suspense, useMemo } from 'react'; import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout'; import { ListPropertiesIcon } from '@/components/events/list-properties-icon'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; @@ -64,12 +64,7 @@ export default async function Page({ }; const startDate = parseAsString.parseServerSide(searchParams.startDate); const endDate = parseAsString.parseServerSide(searchParams.endDate); - const [profile, events, count, metrics] = await Promise.all([ - getProfileById(profileId, projectId), - getEventList(eventListOptions), - getEventsCount(eventListOptions), - getProfileMetrics(profileId, projectId), - ]); + const profile = await getProfileById(profileId, projectId); const pageViewsChart: IChartInput = { projectId, @@ -148,11 +143,11 @@ export default async function Page({ return ( <> } /> - +
-
-

+
+

{getProfileName(profile)}

@@ -194,31 +189,19 @@ export default async function Page({
- + }> + +
); } -function ValueRow({ name, value }: { name: string; value?: unknown }) { - if (!value) { - return null; - } - return ( -
-
- {name.replace('_', ' ')} -
-
- {typeof value === 'string' ? ( - <> - {value} - - ) : ( - <>{value} - )} -
-
- ); +async function EventListServer(props: GetEventListOptions) { + const [events, count] = await Promise.all([ + getEventList(props), + getEventsCount(props), + ]); + return ; } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx index b53a92f6..86beb12a 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx @@ -1,4 +1,4 @@ -import withLoadingWidget from '@/hocs/with-loading-widget'; +import withSuspense from '@/hocs/with-suspense'; import { getProfileMetrics } from '@openpanel/db'; @@ -14,4 +14,4 @@ const ProfileMetricsServer = async ({ projectId, profileId }: Props) => { return ; }; -export default withLoadingWidget(ProfileMetricsServer); +export default withSuspense(ProfileMetricsServer, () => null); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx index 2b314f75..0ec5a4d2 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx @@ -12,52 +12,52 @@ type Props = { const ProfileMetrics = ({ data }: Props) => { const number = useNumber(); return ( -
-
+
+
First seen
-
+
{formatDistanceToNow(data.firstSeen)}
-
+
Last seen
-
+
{formatDistanceToNow(data.lastSeen)}
-
+
Sessions
-
+
{number.format(data.sessions)}
-
+
Avg. Session
-
+
{number.formatWithUnit(data.durationAvg / 1000, 'min')}
-
+
P90. Session
-
+
{number.formatWithUnit(data.durationP90 / 1000, 'min')}
-
+
Page views
-
+
{number.format(data.screenViews)}
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx deleted file mode 100644 index 9cf2cf50..00000000 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import FullPageLoadingState from '@/components/full-page-loading-state'; - -export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/index.tsx index 3753d613..b3548c1c 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/index.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/index.tsx @@ -11,7 +11,7 @@ interface Props { filters?: IChartEventFilter[]; } -const limit = 50; +const limit = 40; async function ProfileListServer({ projectId, cursor, filters }: Props) { const [profiles, count] = await Promise.all([ diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx index 1f5ba303..f79ccf51 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx @@ -23,7 +23,7 @@ interface ProfileListProps { } export function ProfileList({ data, count, limit = 50 }: ProfileListProps) { const { organizationSlug, projectId } = useAppParams(); - const { cursor, setCursor } = useCursor(); + const { cursor, setCursor, loading } = useCursor(); return ( @@ -32,6 +32,7 @@ export function ProfileList({ data, count, limit = 50 }: ProfileListProps) { size="sm" cursor={cursor} setCursor={setCursor} + loading={loading} count={count} take={limit} /> diff --git a/apps/dashboard/src/components/pagination.tsx b/apps/dashboard/src/components/pagination.tsx index 8a46e361..4c8cbe87 100644 --- a/apps/dashboard/src/components/pagination.tsx +++ b/apps/dashboard/src/components/pagination.tsx @@ -29,6 +29,7 @@ export function Pagination({ setCursor, className, size = 'base', + loading, }: { take: number; count: number; @@ -36,6 +37,7 @@ export function Pagination({ setCursor: Dispatch>; className?: string; size?: 'sm' | 'base'; + loading?: boolean; }) { const lastCursor = Math.floor(count / take) - 1; const isNextDisabled = count === 0 || lastCursor === cursor; @@ -56,42 +58,43 @@ export function Pagination({ )} {size === 'base' && ( + icon={ChevronsLeftIcon} + /> )} + icon={ChevronLeftIcon} + /> + icon={ChevronRightIcon} + /> + {size === 'base' && ( + icon={ChevronsRightIcon} + /> )}
); diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index 9c54cb6f..ba7a0525 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -76,9 +76,10 @@ const Button = React.forwardRef( {Icon && ( )} diff --git a/apps/dashboard/src/hooks/useCursor.ts b/apps/dashboard/src/hooks/useCursor.ts index 458c4710..77eed95d 100644 --- a/apps/dashboard/src/hooks/useCursor.ts +++ b/apps/dashboard/src/hooks/useCursor.ts @@ -1,14 +1,17 @@ +import { useTransition } from 'react'; import { parseAsInteger, useQueryState } from 'nuqs'; export function useCursor() { + const [loading, startTransition] = useTransition(); const [cursor, setCursor] = useQueryState( 'cursor', parseAsInteger - .withOptions({ shallow: false, history: 'push' }) + .withOptions({ shallow: false, history: 'push', startTransition }) .withDefault(0) ); return { cursor, setCursor, + loading, }; } diff --git a/packages/db/src/clickhouse-client.ts b/packages/db/src/clickhouse-client.ts index 001beee3..02c1ecba 100644 --- a/packages/db/src/clickhouse-client.ts +++ b/packages/db/src/clickhouse-client.ts @@ -15,16 +15,15 @@ export const ch = createClient({ export async function chQueryWithMeta>( query: string ): Promise> { - console.log('Query:', query); const start = Date.now(); const res = await ch.query({ query, }); const json = await res.json(); + const keys = Object.keys(json.data[0] || {}); const response = { ...json, data: json.data.map((item) => { - const keys = Object.keys(item); return keys.reduce((acc, key) => { const meta = json.meta?.find((m) => m.name === key); return { @@ -38,8 +37,10 @@ export async function chQueryWithMeta>( }), }; - console.log(`Clickhouse query took ${response.statistics?.elapsed}ms`); - console.log(`chQuery took ${Date.now() - start}ms`); + console.log( + `Query: (${Date.now() - start}ms, ${response.statistics?.elapsed}ms)`, + query + ); return response; } diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index 26b27da3..1c4c0a11 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -42,7 +42,7 @@ export async function getProfileById(id: string, projectId: string) { } const [profile] = await chQuery( - `SELECT *, created_at as max_created_at FROM profiles WHERE id = ${escape(id)} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1` + `SELECT * FROM profiles WHERE id = ${escape(id)} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1` ); if (!profile) { @@ -59,20 +59,6 @@ interface GetProfileListOptions { filters?: IChartEventFilter[]; } -function getProfileSelectFields() { - return [ - 'id', - 'argMax(first_name, created_at) as first_name', - 'argMax(last_name, created_at) as last_name', - 'argMax(email, created_at) as email', - 'argMax(avatar, created_at) as avatar', - 'argMax(properties, created_at) as properties', - 'argMax(project_id, created_at) as project_id', - 'argMax(is_external, created_at) as is_external', - 'max(created_at) as max_created_at', - ].join(', '); -} - interface GetProfilesOptions { ids: string[]; } @@ -82,25 +68,15 @@ export async function getProfiles({ ids }: GetProfilesOptions) { } const data = await chQuery( - `SELECT - ${getProfileSelectFields()} - FROM profiles + `SELECT * + FROM profiles FINAL WHERE id IN (${ids.map((id) => escape(id)).join(',')}) - GROUP BY id ` ); return data.map(transformProfile); } -function getProfileInnerSelect(projectId: string) { - return `(SELECT - ${getProfileSelectFields()} - FROM profiles - GROUP BY id - HAVING project_id = ${escape(projectId)})`; -} - export async function getProfileList({ take, cursor, @@ -108,16 +84,12 @@ export async function getProfileList({ filters, }: GetProfileListOptions) { const { sb, getSql } = createSqlBuilder(); - sb.from = getProfileInnerSelect(projectId); - if (filters) { - sb.where = { - ...sb.where, - ...getEventFiltersWhereClause(filters), - }; - } + sb.from = 'profiles FINAL'; + sb.select.all = '*'; + sb.where.project_id = `project_id = ${escape(projectId)}`; sb.limit = take; sb.offset = Math.max(0, (cursor ?? 0) * take); - sb.orderBy.created_at = 'max_created_at DESC'; + sb.orderBy.created_at = 'created_at DESC'; const data = await chQuery(getSql()); return data.map(transformProfile); } @@ -127,21 +99,17 @@ export async function getProfileListCount({ filters, }: Omit) { const { sb, getSql } = createSqlBuilder(); + sb.from = 'profiles FINAL'; sb.select.count = 'count(id) as count'; - sb.from = getProfileInnerSelect(projectId); - if (filters) { - sb.where = { - ...sb.where, - ...getEventFiltersWhereClause(filters), - }; - } - const [data] = await chQuery<{ count: number }>(getSql()); - return data?.count ?? 0; + sb.where.project_id = `project_id = ${escape(projectId)}`; + sb.groupBy.project_id = 'project_id'; + const data = await chQuery<{ count: number }>(getSql()); + return data[0]?.count ?? 0; } export type IServiceProfile = Omit< IClickhouseProfile, - 'max_created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external' + 'created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external' > & { firstName: string; lastName: string; @@ -168,7 +136,7 @@ export interface IClickhouseProfile { properties: Record; project_id: string; is_external: boolean; - max_created_at: string; + created_at: string; } export interface IServiceUpsertProfile { @@ -183,7 +151,7 @@ export interface IServiceUpsertProfile { } export function transformProfile({ - max_created_at, + created_at, first_name, last_name, ...profile @@ -194,7 +162,7 @@ export function transformProfile({ lastName: last_name, isExternal: profile.is_external, properties: toObject(profile.properties), - createdAt: new Date(max_created_at), + createdAt: new Date(created_at), }; } diff --git a/packages/db/src/sql-builder.ts b/packages/db/src/sql-builder.ts index d17352b2..7bf0019e 100644 --- a/packages/db/src/sql-builder.ts +++ b/packages/db/src/sql-builder.ts @@ -1,5 +1,6 @@ export interface SqlBuilderObject { where: Record; + having: Record; select: Record; groupBy: Record; orderBy: Record; @@ -18,12 +19,15 @@ export function createSqlBuilder() { select: {}, groupBy: {}, orderBy: {}, + having: {}, limit: undefined, offset: undefined, }; const getWhere = () => Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : ''; + const getHaving = () => + Object.keys(sb.having).length ? 'HAVING ' + join(sb.having, ' AND ') : ''; const getFrom = () => `FROM ${sb.from}`; const getSelect = () => 'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*'); @@ -42,22 +46,20 @@ export function createSqlBuilder() { getSelect, getGroupBy, getOrderBy, + getHaving, getSql: () => { const sql = [ getSelect(), getFrom(), getWhere(), getGroupBy(), + getHaving(), getOrderBy(), getLimit(), getOffset(), ] .filter(Boolean) .join(' '); - console.log('---'); - console.log(sql); - console.log('---'); - return sql; }, };