mirror of
https://github.com/umami-software/umami.git
synced 2026-02-06 21:57:16 +01:00
Merge branch 'umami-software:master' into master
This commit is contained in:
commit
cf7d6f4dc3
59 changed files with 848 additions and 533 deletions
|
|
@ -5,22 +5,29 @@ import { UpdateNotice } from './UpdateNotice';
|
|||
import { SideNav } from '@/app/(main)/SideNav';
|
||||
import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
|
||||
import { MobileNav } from '@/app/(main)/MobileNav';
|
||||
import { useEffect } from 'react';
|
||||
import { removeItem, setItem } from '@/lib/storage';
|
||||
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
||||
|
||||
export function App({ children }) {
|
||||
const { user, isLoading, error } = useLoginQuery();
|
||||
const config = useConfig();
|
||||
const { pathname, router } = useNavigation();
|
||||
const { pathname, teamId } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (teamId) {
|
||||
setItem(LAST_TEAM_CONFIG, teamId);
|
||||
} else {
|
||||
removeItem(LAST_TEAM_CONFIG);
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
if (isLoading || !config) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (process.env.cloudMode) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
window.location.href = `${process.env.basePath || ''}/login`;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function Attribution({
|
|||
})}
|
||||
</MetricsBar>
|
||||
<SectionHeader title={formatMessage(labels.sources)} />
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
|
||||
<Panel>
|
||||
<AttributionTable data={data?.['referrer']} title={formatMessage(labels.referrer)} />
|
||||
</Panel>
|
||||
|
|
@ -104,7 +104,7 @@ export function Attribution({
|
|||
</Panel>
|
||||
</Grid>
|
||||
<SectionHeader title="UTM" />
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
|
||||
<Panel>
|
||||
<AttributionTable data={data?.['utm_source']} title={formatMessage(labels.sources)} />
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
|
|||
return (
|
||||
<Column gap="6">
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Grid columns="1fr 1fr 1fr" gap>
|
||||
<Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap>
|
||||
<Column>
|
||||
<Select
|
||||
label={formatMessage(labels.model)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Text, DataTable, DataColumn } from '@umami/react-zen';
|
||||
import { Text, DataTable, DataColumn, Column } from '@umami/react-zen';
|
||||
import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { formatShortTime } from '@/lib/format';
|
||||
|
|
@ -27,43 +27,65 @@ export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }
|
|||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<DataTable data={data}>
|
||||
{selectedFields.map(field => {
|
||||
return (
|
||||
<DataColumn key={field} id={field} label={fields.find(f => f.name === field)?.label}>
|
||||
{row => {
|
||||
const value = formatValue(row[field], field);
|
||||
return (
|
||||
<Text truncate title={value}>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
);
|
||||
})}
|
||||
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||
{row => row?.['visitors']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||
{row => row?.['visits']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
|
||||
{row => row?.['views']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
|
||||
{row => {
|
||||
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||
return Math.round(+n) + '%';
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
|
||||
{row => {
|
||||
const n = row?.['totaltime'] / row?.['visits'];
|
||||
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
<Column overflow="auto" minHeight="0" height="100%">
|
||||
<DataTable data={data} style={{ tableLayout: 'fixed' }}>
|
||||
{selectedFields.map(field => {
|
||||
return (
|
||||
<DataColumn
|
||||
key={field}
|
||||
id={field}
|
||||
label={fields.find(f => f.name === field)?.label}
|
||||
width="minmax(120px, 1fr)"
|
||||
>
|
||||
{row => {
|
||||
const value = formatValue(row[field], field);
|
||||
return (
|
||||
<Text truncate title={value}>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
);
|
||||
})}
|
||||
<DataColumn
|
||||
id="visitors"
|
||||
label={formatMessage(labels.visitors)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => row?.['visitors']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px">
|
||||
{row => row?.['visits']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px">
|
||||
{row => row?.['views']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn
|
||||
id="bounceRate"
|
||||
label={formatMessage(labels.bounceRate)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => {
|
||||
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||
return Math.round(+n) + '%';
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn
|
||||
id="visitDuration"
|
||||
label={formatMessage(labels.visitDuration)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => {
|
||||
const n = row?.['totaltime'] / row?.['visits'];
|
||||
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useDateRange, useMessages, useMobile } from '@/components/hooks';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { ListCheck } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { Column, Row } from '@umami/react-zen';
|
||||
|
|
@ -14,12 +14,10 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
|
|||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const [fields, setFields] = useState(['path']);
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Row alignItems="center" justifyContent={isMobile ? 'flex-end' : 'flex-start'}>
|
||||
<Row alignItems="center" justifyContent="flex-start">
|
||||
<FieldsButton value={fields} onChange={setFields} />
|
||||
</Row>
|
||||
<Panel height="900px" overflow="auto" allowFullscreen>
|
||||
|
|
@ -41,8 +39,9 @@ const FieldsButton = ({ value, onChange }) => {
|
|||
<DialogButton
|
||||
icon={<ListCheck />}
|
||||
label={formatMessage(labels.fields)}
|
||||
width="800px"
|
||||
width="400px"
|
||||
minHeight="300px"
|
||||
variant="outline"
|
||||
>
|
||||
{({ close }) => {
|
||||
return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function FunnelEditForm({
|
|||
const defaultValues = {
|
||||
name: data?.name || '',
|
||||
window: data?.parameters?.window || 60,
|
||||
steps: data?.parameters?.steps || [{ type: 'path', value: '/' }],
|
||||
steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -82,12 +82,10 @@ export function FunnelEditForm({
|
|||
validate: value => value.length > 1 || 'At least two steps are required',
|
||||
}}
|
||||
>
|
||||
{({ fields, append, remove, getValues }) => {
|
||||
{({ fields, append, remove }) => {
|
||||
return (
|
||||
<Grid gap>
|
||||
{fields.map(({ id }: { id: string }, index: number) => {
|
||||
const type = getValues(`steps.${index}.type`);
|
||||
|
||||
return (
|
||||
<Grid key={id} columns="260px 1fr auto" gap>
|
||||
<Column>
|
||||
|
|
@ -103,7 +101,8 @@ export function FunnelEditForm({
|
|||
name={`steps.${index}.value`}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
{({ field }) => {
|
||||
{({ field, context }) => {
|
||||
const type = context.watch(`steps.${index}.type`);
|
||||
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||
}}
|
||||
</FormField>
|
||||
|
|
@ -118,7 +117,7 @@ export function FunnelEditForm({
|
|||
})}
|
||||
<Row>
|
||||
<Button
|
||||
onPress={() => append({ type: 'path', value: '/' })}
|
||||
onPress={() => append({ type: 'path', value: '' })}
|
||||
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
|
||||
>
|
||||
<Icon>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
|||
<Modal>
|
||||
<Dialog
|
||||
aria-label="add goal"
|
||||
variant="modal"
|
||||
title={formatMessage(labels.goal)}
|
||||
style={{ minWidth: 800, minHeight: 300 }}
|
||||
style={{ minWidth: 400, minHeight: 300 }}
|
||||
>
|
||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -51,51 +51,72 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent
|
|||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
{data && (
|
||||
<Panel allowFullscreen height="900px">
|
||||
<Column gap="1" width="100%" overflow="auto">
|
||||
<Grid
|
||||
columns="120px repeat(10, 100px)"
|
||||
alignItems="center"
|
||||
gap="1"
|
||||
height="50px"
|
||||
autoFlow="column"
|
||||
>
|
||||
<Column>
|
||||
<Text weight="bold" align="center">
|
||||
{formatMessage(labels.cohort)}
|
||||
</Text>
|
||||
</Column>
|
||||
{days.map(n => (
|
||||
<Column key={n}>
|
||||
<Text weight="bold" align="center" wrap="nowrap">
|
||||
{formatMessage(labels.day)} {n}
|
||||
<Column
|
||||
paddingY="6"
|
||||
paddingX={{ xs: '3', md: '6' }}
|
||||
position="absolute"
|
||||
top="40px"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
>
|
||||
<Column gap="1" overflow="auto">
|
||||
<Grid
|
||||
columns="120px repeat(10, 100px)"
|
||||
alignItems="center"
|
||||
gap="1"
|
||||
height="50px"
|
||||
width="max-content"
|
||||
minWidth="100%"
|
||||
autoFlow="column"
|
||||
>
|
||||
<Column>
|
||||
<Text weight="bold" align="center">
|
||||
{formatMessage(labels.cohort)}
|
||||
</Text>
|
||||
</Column>
|
||||
))}
|
||||
</Grid>
|
||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||
return (
|
||||
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
||||
<Column justifyContent="center" gap="1">
|
||||
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Users />
|
||||
</Icon>
|
||||
<Text>{formatLongNumber(visitors)}</Text>
|
||||
</Row>
|
||||
{days.map(n => (
|
||||
<Column key={n}>
|
||||
<Text weight="bold" align="center" wrap="nowrap">
|
||||
{formatMessage(labels.day)} {n}
|
||||
</Text>
|
||||
</Column>
|
||||
{days.map(day => {
|
||||
if (totalDays - rowIndex < day) {
|
||||
return null;
|
||||
}
|
||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
||||
return (
|
||||
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</Grid>
|
||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||
return (
|
||||
<Grid
|
||||
key={rowIndex}
|
||||
columns="120px repeat(10, 100px)"
|
||||
gap="1"
|
||||
autoFlow="column"
|
||||
width="max-content"
|
||||
minWidth="100%"
|
||||
>
|
||||
<Column justifyContent="center" gap="1">
|
||||
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Users />
|
||||
</Icon>
|
||||
<Text>{formatLongNumber(visitors)}</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
{days.map(day => {
|
||||
if (totalDays - rowIndex < day) {
|
||||
return null;
|
||||
}
|
||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
||||
return (
|
||||
<Cell key={day}>
|
||||
{percentage ? `${Number(percentage).toFixed(2)}%` : ''}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Column>
|
||||
</Column>
|
||||
</Panel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function UTM({ websiteId, startDate, endDate }: UTMProps) {
|
|||
|
||||
return (
|
||||
<Panel key={param}>
|
||||
<Grid columns="1fr 1fr">
|
||||
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6">
|
||||
<Column>
|
||||
<Heading>
|
||||
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export function ExpandedViewModal({
|
|||
maxWidth: 1320,
|
||||
width: '100vw',
|
||||
height: isMobile ? '100dvh' : 'calc(100dvh - 40px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{({ close }) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange } from '@/components/hooks';
|
||||
import { useDateRange, useTimezone } from '@/components/hooks';
|
||||
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
|
||||
import { PageviewsChart } from '@/components/metrics/PageviewsChart';
|
||||
import { useMemo } from 'react';
|
||||
|
|
@ -11,7 +11,8 @@ export function WebsiteChart({
|
|||
websiteId: string;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange, dateCompare } = useDateRange();
|
||||
const { timezone } = useTimezone();
|
||||
const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
|
||||
const { startDate, endDate, unit, value } = dateRange;
|
||||
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
|
||||
websiteId,
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ export function WebsiteControls({
|
|||
return (
|
||||
<Column gap>
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
|
||||
<Row alignItems="center" justifyContent="flex-end">
|
||||
<Row alignItems="center" justifyContent="flex-start">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="flex-end">
|
||||
<Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
|
||||
{allowDateFilter && (
|
||||
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,17 +19,23 @@ export function WebsiteExpandedView({
|
|||
} = useNavigation();
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row display={{ xs: 'flex', md: 'none' }}>
|
||||
<Column height="100%" overflow="hidden" gap>
|
||||
<Row id="expanded-mobile-menu-button" display={{ xs: 'flex', md: 'none' }}>
|
||||
<MobileMenuButton>
|
||||
{({ close }) => {
|
||||
return <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />;
|
||||
return (
|
||||
<Column padding="3">
|
||||
<WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />
|
||||
</Column>
|
||||
);
|
||||
}}
|
||||
</MobileMenuButton>
|
||||
</Row>
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden">
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" overflow="hidden">
|
||||
<Column
|
||||
id="metrics-expanded-menu"
|
||||
display={{ xs: 'none', md: 'flex' }}
|
||||
width="240px"
|
||||
gap="6"
|
||||
border="right"
|
||||
paddingRight="3"
|
||||
|
|
@ -37,7 +43,7 @@ export function WebsiteExpandedView({
|
|||
>
|
||||
<WebsiteExpandedMenu excludedIds={excludedIds} />
|
||||
</Column>
|
||||
<Column overflow="hidden">
|
||||
<Column id="metrics-expanded-table" overflow="hidden">
|
||||
<MetricsExpandedTable
|
||||
title={formatMessage(labels[view])}
|
||||
type={view}
|
||||
|
|
|
|||
|
|
@ -19,15 +19,11 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
|||
|
||||
return (
|
||||
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
|
||||
<Row alignItems="center" gap="6">
|
||||
<Row alignItems="center" gap="6" wrap="wrap">
|
||||
<ActiveUsers websiteId={website.id} />
|
||||
|
||||
{showActions && (
|
||||
<Row
|
||||
display={{ xs: 'none', sm: 'none', md: 'none', lg: 'flex', xl: 'flex' }}
|
||||
alignItems="center"
|
||||
gap
|
||||
>
|
||||
<Row alignItems="center" gap>
|
||||
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||
<Icon>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import { DataTable, DataColumn, Row, Text, DataTableProps, IconLabel } from '@umami/react-zen';
|
||||
import {
|
||||
DataTable,
|
||||
DataColumn,
|
||||
Row,
|
||||
Text,
|
||||
DataTableProps,
|
||||
IconLabel,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Icon,
|
||||
Popover,
|
||||
} from '@umami/react-zen';
|
||||
import { useFormat, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import Link from 'next/link';
|
||||
import { Eye } from '@/components/icons';
|
||||
import { Eye, FileText } from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import { EventData } from '@/components/metrics/EventData';
|
||||
|
||||
export function EventsTable(props: DataTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -32,6 +45,7 @@ export function EventsTable(props: DataTableProps) {
|
|||
>
|
||||
{row.eventName || row.urlPath}
|
||||
</Text>
|
||||
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
|
||||
</Row>
|
||||
);
|
||||
}}
|
||||
|
|
@ -72,3 +86,22 @@ export function EventsTable(props: DataTableProps) {
|
|||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
const PropertiesButton = props => {
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button variant="quiet">
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<FileText />
|
||||
</Icon>
|
||||
</Row>
|
||||
</Button>
|
||||
<Popover placement="right">
|
||||
<Dialog>
|
||||
<EventData {...props} />
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
useCountryNames,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useMobile,
|
||||
useNavigation,
|
||||
useTimezone,
|
||||
useWebsite,
|
||||
|
|
@ -40,6 +41,7 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
const { countryNames } = useCountryNames(locale);
|
||||
const [filter, setFilter] = useState(TYPE_ALL);
|
||||
const { updateParams } = useNavigation();
|
||||
const { isPhone } = useMobile();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
|
|
@ -123,12 +125,18 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
const row = logs[index];
|
||||
return (
|
||||
<Row alignItems="center" style={style} gap>
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} size={32} />
|
||||
</Link>
|
||||
<Row width="100px">{getTime(row)}</Row>
|
||||
<Row minWidth="30px">
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} size={32} />
|
||||
</Link>
|
||||
</Row>
|
||||
<Row minWidth="100px">
|
||||
<Text wrap="nowrap">{getTime(row)}</Text>
|
||||
</Row>
|
||||
<IconLabel icon={getIcon(row)}>
|
||||
<Text>{getDetail(row)}</Text>
|
||||
<Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
|
||||
{getDetail(row)}
|
||||
</Text>
|
||||
</IconLabel>
|
||||
</Row>
|
||||
);
|
||||
|
|
@ -168,10 +176,22 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
return (
|
||||
<Column gap>
|
||||
<Heading size="2">{formatMessage(labels.activity)}</Heading>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<SearchField value={search} onSearch={setSearch} />
|
||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||
</Row>
|
||||
{isPhone ? (
|
||||
<>
|
||||
<Row>
|
||||
<SearchField value={search} onSearch={setSearch} />
|
||||
</Row>
|
||||
<Row>
|
||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<SearchField value={search} onSearch={setSearch} />
|
||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Column>
|
||||
{logs?.length === 0 && <Empty />}
|
||||
{logs?.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { PageBody } from '@/components/common/PageBody';
|
|||
import { Panel } from '@/components/common/Panel';
|
||||
import { RealtimeChart } from '@/components/metrics/RealtimeChart';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { useRealtimeQuery } from '@/components/hooks';
|
||||
import { useMobile, useRealtimeQuery } from '@/components/hooks';
|
||||
import { RealtimeLog } from './RealtimeLog';
|
||||
import { RealtimeHeader } from './RealtimeHeader';
|
||||
import { RealtimePaths } from './RealtimePaths';
|
||||
|
|
@ -16,6 +16,7 @@ import { percentFilter } from '@/lib/filters';
|
|||
|
||||
export function RealtimePage({ websiteId }: { websiteId: string }) {
|
||||
const { data, isLoading, error } = useRealtimeQuery(websiteId);
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
if (isLoading || error) {
|
||||
return <PageBody isLoading={isLoading} error={error} />;
|
||||
|
|
@ -48,7 +49,7 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
|
|||
<Panel>
|
||||
<RealtimeCountries data={countries} />
|
||||
</Panel>
|
||||
<Panel gridColumn="span 2" padding="0">
|
||||
<Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
|
||||
<WorldMap data={countries} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Eye, FileText } from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { useMessages, useSessionActivityQuery, useTimezone } from '@/components/hooks';
|
||||
import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks';
|
||||
import { EventData } from '@/components/metrics/EventData';
|
||||
|
||||
export function SessionActivity({
|
||||
|
|
@ -36,6 +36,7 @@ export function SessionActivity({
|
|||
startDate,
|
||||
endDate,
|
||||
);
|
||||
const { isMobile } = useMobile();
|
||||
let lastDay = null;
|
||||
|
||||
return (
|
||||
|
|
@ -50,16 +51,16 @@ export function SessionActivity({
|
|||
{showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
|
||||
<Row alignItems="center" gap="6" height="40px">
|
||||
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
||||
{formatTimezoneDate(createdAt, 'pp')}
|
||||
<Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
|
||||
</StatusLight>
|
||||
<Row alignItems="center" gap="2">
|
||||
<Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
|
||||
<Text>
|
||||
<Text wrap="nowrap">
|
||||
{eventName
|
||||
? formatMessage(labels.triggeredEvent)
|
||||
: formatMessage(labels.viewedPage)}
|
||||
</Text>
|
||||
<Text weight="bold" style={{ maxWidth: '400px' }} truncate>
|
||||
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
|
||||
{eventName || urlPath}
|
||||
</Text>
|
||||
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
import { REALTIME_RANGE } from '@/lib/constants';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { timezoneParam } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getRealtimeData } from '@/queries/sql';
|
||||
import { startOfMinute, subMinutes } from 'date-fns';
|
||||
import z from 'zod';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
timezone: timezoneParam,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
const { auth, query, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export async function POST(request: Request) {
|
|||
region,
|
||||
city,
|
||||
distinctId: id,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,15 +55,16 @@ export function LoginForm() {
|
|||
name="username"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
<TextField autoComplete="username" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={formatMessage(labels.password)}
|
||||
data-test="input-password"
|
||||
name="password"
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<PasswordField />
|
||||
<PasswordField autoComplete="current-password" />
|
||||
</FormField>
|
||||
<FormButtons>
|
||||
<FormSubmitButton
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function LogoutPage() {
|
|||
async function logout() {
|
||||
await post('/auth/logout');
|
||||
|
||||
router.push('/login');
|
||||
window.location.href = `${process.env.basePath || ''}/login`;
|
||||
}
|
||||
|
||||
removeClientAuthToken();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getItem, removeItem } from '@/lib/storage';
|
||||
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/websites');
|
||||
useEffect(() => {
|
||||
const lastTeam = getItem(LAST_TEAM_CONFIG);
|
||||
|
||||
if (lastTeam) {
|
||||
redirect(`/teams/${lastTeam}/websites`);
|
||||
} else {
|
||||
removeItem(LAST_TEAM_CONFIG);
|
||||
|
||||
redirect(`/websites`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { Text } from '@umami/react-zen';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useLocale, useTimezone } from '@/components/hooks';
|
||||
import { isInvalidDate } from '@/lib/date';
|
||||
|
||||
export function DateDistance({ date }: { date: Date }) {
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { dateLocale } = useLocale();
|
||||
|
||||
if (!isInvalidDate(date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text title={formatTimezoneDate(date.toISOString(), 'PPPpp')}>
|
||||
<Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })}
|
||||
</Text>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Heading, Icon, Row, RowProps, Text, Column } from '@umami/react-zen';
|
||||
import { Heading, Icon, Row, Text, Column, Grid } from '@umami/react-zen';
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
|
|
@ -8,7 +8,6 @@ export function PageHeader({
|
|||
icon,
|
||||
showBorder = true,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
|
|
@ -18,16 +17,13 @@ export function PageHeader({
|
|||
allowEdit?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
} & RowProps) {
|
||||
}) {
|
||||
return (
|
||||
<Row
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
<Grid
|
||||
columns={{ xs: '1fr', md: '1fr 1fr' }}
|
||||
paddingY="6"
|
||||
marginBottom="6"
|
||||
border={showBorder ? 'bottom' : undefined}
|
||||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Column gap="2">
|
||||
{label}
|
||||
|
|
@ -46,6 +42,6 @@ export function PageHeader({
|
|||
)}
|
||||
</Column>
|
||||
<Row justifyContent="flex-end">{children}</Row>
|
||||
</Row>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,13 @@
|
|||
import { useTimezone } from '@/components/hooks/useTimezone';
|
||||
import { REALTIME_INTERVAL } from '@/lib/constants';
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
export interface RealtimeData {
|
||||
countries: Record<string, number>;
|
||||
events: any[];
|
||||
pageviews: any[];
|
||||
referrers: Record<string, number>;
|
||||
timestamp: number;
|
||||
series: {
|
||||
views: any[];
|
||||
visitors: any[];
|
||||
};
|
||||
totals: {
|
||||
views: number;
|
||||
visitors: number;
|
||||
events: number;
|
||||
countries: number;
|
||||
};
|
||||
urls: Record<string, number>;
|
||||
visitors: any[];
|
||||
}
|
||||
import { RealtimeData } from '@/lib/types';
|
||||
|
||||
export function useRealtimeQuery(websiteId: string) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { timezone } = useTimezone();
|
||||
const { data, isLoading, error } = useQuery<RealtimeData>({
|
||||
queryKey: ['realtime', { websiteId, timezone }],
|
||||
queryKey: ['realtime', { websiteId }],
|
||||
queryFn: async () => {
|
||||
return get(`/realtime/${websiteId}`, { timezone });
|
||||
return get(`/realtime/${websiteId}`);
|
||||
},
|
||||
enabled: !!websiteId,
|
||||
refetchInterval: REALTIME_INTERVAL,
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ export function useDateParameters() {
|
|||
const {
|
||||
dateRange: { startDate, endDate, unit },
|
||||
} = useDateRange();
|
||||
const { timezone, toUtc } = useTimezone();
|
||||
const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
|
||||
|
||||
return {
|
||||
startAt: +toUtc(startDate),
|
||||
endAt: +toUtc(endDate),
|
||||
startDate: toUtc(startDate).toISOString(),
|
||||
endDate: toUtc(endDate).toISOString(),
|
||||
startAt: +localToUtc(startDate),
|
||||
endAt: +localToUtc(endDate),
|
||||
startDate: localToUtc(startDate).toISOString(),
|
||||
endDate: localToUtc(endDate).toISOString(),
|
||||
unit,
|
||||
timezone,
|
||||
timezone: canonicalizeTimezone(timezone),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useLocale } from '@/components/hooks/useLocale';
|
|||
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
|
||||
import { getItem } from '@/lib/storage';
|
||||
|
||||
export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
|
||||
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
|
||||
const {
|
||||
query: { date = '', offset = 0, compare = 'prev' },
|
||||
} = useNavigation();
|
||||
|
|
@ -15,6 +15,7 @@ export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
|
|||
const dateRangeObject = parseDateRange(
|
||||
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
|
||||
locale,
|
||||
options.timezone,
|
||||
);
|
||||
|
||||
return !options.ignoreOffset && offset
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { setItem } from '@/lib/storage';
|
||||
import { TIMEZONE_CONFIG } from '@/lib/constants';
|
||||
import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
|
||||
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
|
||||
import { useApp, setTimezone } from '@/store/app';
|
||||
import { useLocale } from './useLocale';
|
||||
import { getTimezone } from '@/lib/date';
|
||||
|
||||
const selector = (state: { timezone: string }) => state.timezone;
|
||||
|
||||
export function useTimezone() {
|
||||
const timezone = useApp(selector);
|
||||
const localTimeZone = getTimezone();
|
||||
const { dateLocale } = useLocale();
|
||||
|
||||
const saveTimezone = (value: string) => {
|
||||
|
|
@ -26,6 +28,38 @@ export function useTimezone() {
|
|||
);
|
||||
};
|
||||
|
||||
const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
|
||||
return data.map(item => {
|
||||
const date = new Date(item[column]);
|
||||
|
||||
const format = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
const parts = format.formatToParts(date);
|
||||
const get = type => parts.find(p => p.type === type)?.value;
|
||||
|
||||
const year = get('year');
|
||||
const month = get('month');
|
||||
const day = get('day');
|
||||
const hour = get('hour');
|
||||
const minute = get('minute');
|
||||
const second = get('second');
|
||||
|
||||
return {
|
||||
...item,
|
||||
[column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toUtc = (date: Date | string | number) => {
|
||||
return zonedTimeToUtc(date, timezone);
|
||||
};
|
||||
|
|
@ -34,5 +68,28 @@ export function useTimezone() {
|
|||
return utcToZonedTime(date, timezone);
|
||||
};
|
||||
|
||||
return { timezone, saveTimezone, formatTimezoneDate, toUtc, fromUtc };
|
||||
const localToUtc = (date: Date | string | number) => {
|
||||
return zonedTimeToUtc(date, localTimeZone);
|
||||
};
|
||||
|
||||
const localFromUtc = (date: Date | string | number) => {
|
||||
return utcToZonedTime(date, localTimeZone);
|
||||
};
|
||||
|
||||
const canonicalizeTimezone = (timezone: string): string => {
|
||||
return TIMEZONE_LEGACY[timezone] ?? timezone;
|
||||
};
|
||||
|
||||
return {
|
||||
timezone,
|
||||
localTimeZone,
|
||||
toUtc,
|
||||
fromUtc,
|
||||
localToUtc,
|
||||
localFromUtc,
|
||||
saveTimezone,
|
||||
formatTimezoneDate,
|
||||
formatSeriesTimezone,
|
||||
canonicalizeTimezone,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function WebsiteDateFilter({
|
|||
query: { compare = 'prev', offset = 0 },
|
||||
} = useNavigation();
|
||||
const disableForward = isAllTime || isAfter(dateRange.endDate, new Date());
|
||||
const showCompare = allowCompare && !isAllTime;
|
||||
|
||||
const websiteDateRange = useDateRangeQuery(websiteId);
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ export function WebsiteDateFilter({
|
|||
}, [dateRange]);
|
||||
|
||||
return (
|
||||
<Row gap>
|
||||
<Row wrap="wrap" gap>
|
||||
{showButtons && !isAllTime && !isCustomRange && (
|
||||
<Row gap="1">
|
||||
<Button onPress={() => handleIncrement(-1)} variant="outline">
|
||||
|
|
@ -85,7 +86,7 @@ export function WebsiteDateFilter({
|
|||
renderDate={+offset !== 0}
|
||||
/>
|
||||
</Row>
|
||||
{allowCompare && !isAllTime && (
|
||||
{showCompare && (
|
||||
<Row alignItems="center" gap>
|
||||
<Text weight="bold">VS</Text>
|
||||
<Row width="200px">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { colord } from 'colord';
|
||||
import { BarChart, BarChartProps } from '@/components/charts/BarChart';
|
||||
import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useDateRange,
|
||||
useLocale,
|
||||
useTimezone,
|
||||
useWebsiteEventsSeriesQuery,
|
||||
} from '@/components/hooks';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
import { colord } from 'colord';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface EventsChartProps extends BarChartProps {
|
||||
websiteId: string;
|
||||
|
|
@ -12,10 +18,11 @@ export interface EventsChartProps extends BarChartProps {
|
|||
}
|
||||
|
||||
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
|
||||
const { timezone } = useTimezone();
|
||||
const {
|
||||
dateRange: { startDate, endDate, unit },
|
||||
} = useDateRange();
|
||||
const { locale } = useLocale();
|
||||
} = useDateRange({ timezone: timezone });
|
||||
const { locale, dateLocale } = useLocale();
|
||||
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
|
||||
const [label, setLabel] = useState<string>(focusLabel);
|
||||
|
||||
|
|
@ -32,20 +39,32 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
|
|||
return obj;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
datasets: Object.keys(map).map((key, index) => {
|
||||
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: map[key],
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
focusLabel,
|
||||
};
|
||||
if (!map || Object.keys(map).length === 0) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
datasets: Object.keys(map).map((key, index) => {
|
||||
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
}),
|
||||
focusLabel,
|
||||
};
|
||||
}
|
||||
}, [data, startDate, endDate, unit, focusLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,6 +73,8 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
|
|||
}
|
||||
}, [focusLabel]);
|
||||
|
||||
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
|
||||
|
||||
return (
|
||||
<LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
|
||||
{chartData && (
|
||||
|
|
@ -63,7 +84,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
|
|||
maxDate={endDate}
|
||||
unit={unit}
|
||||
stacked={true}
|
||||
renderXLabel={renderDateLabels(unit, locale)}
|
||||
renderXLabel={renderXLabel}
|
||||
height="400px"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function ListTable({
|
|||
showPercentage={showPercentage}
|
||||
change={renderChange ? renderChange(row, index) : null}
|
||||
currency={currency}
|
||||
isMobile={isPhone}
|
||||
isPhone={isPhone}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -101,7 +101,7 @@ const AnimatedRow = ({
|
|||
animate,
|
||||
showPercentage = true,
|
||||
currency,
|
||||
isMobile,
|
||||
isPhone,
|
||||
}) => {
|
||||
const props = useSpring({
|
||||
width: percent,
|
||||
|
|
@ -120,7 +120,7 @@ const AnimatedRow = ({
|
|||
gap
|
||||
>
|
||||
<Row alignItems="center">
|
||||
<Text truncate={true} style={{ maxWidth: isMobile ? '200px' : '400px' }}>
|
||||
<Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -25,16 +25,16 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
|
|||
const { getRegionName } = useRegionNames(locale);
|
||||
|
||||
const { label, country, domain } = data;
|
||||
const isType = ['browser', 'country', 'device', 'os'].includes(type);
|
||||
|
||||
switch (type) {
|
||||
case 'browser':
|
||||
case 'os':
|
||||
return (
|
||||
<FilterLink
|
||||
type="browser"
|
||||
type={type}
|
||||
value={label}
|
||||
label={formatValue(label, 'browser')}
|
||||
icon={<TypeIcon type="browser" value={label} />}
|
||||
label={formatValue(label, type)}
|
||||
icon={<TypeIcon type={type} value={label} />}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
|
|||
type="device"
|
||||
value={labels[label] && label}
|
||||
label={formatValue(label, 'device')}
|
||||
icon={<TypeIcon type="device" value={label?.toLowerCase()} />}
|
||||
icon={<TypeIcon type="device" value={label} />}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -141,14 +141,6 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
|
|||
<FilterLink
|
||||
type={type}
|
||||
value={label}
|
||||
icon={
|
||||
isType && (
|
||||
<TypeIcon
|
||||
type={type as 'browser' | 'country' | 'device' | 'os'}
|
||||
value={label?.toLowerCase()?.replaceAll(/\W/g, '-')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export interface MetricsBarProps extends GridProps {
|
|||
|
||||
export function MetricsBar({ children, ...props }: MetricsBarProps) {
|
||||
return (
|
||||
<Grid columns="repeat(auto-fit, minmax(140px, 1fr))" gap {...props}>
|
||||
<Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { startOfMinute, subMinutes, isBefore } from 'date-fns';
|
|||
import { PageviewsChart } from './PageviewsChart';
|
||||
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
|
||||
import { RealtimeData } from '@/lib/types';
|
||||
import { useTimezone } from '@/components/hooks';
|
||||
|
||||
export interface RealtimeChartProps {
|
||||
data: RealtimeData;
|
||||
|
|
@ -11,6 +12,7 @@ export interface RealtimeChartProps {
|
|||
}
|
||||
|
||||
export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
|
||||
const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
|
||||
const endDate = startOfMinute(new Date());
|
||||
const startDate = subMinutes(endDate, REALTIME_RANGE);
|
||||
const prevEndDate = useRef(endDate);
|
||||
|
|
@ -21,8 +23,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
|
|||
}
|
||||
|
||||
return {
|
||||
pageviews: data.series.views,
|
||||
sessions: data.series.visitors,
|
||||
pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
|
||||
sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
|
||||
};
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
|
|
@ -38,8 +40,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
|
|||
return (
|
||||
<PageviewsChart
|
||||
{...props}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
minDate={fromUtc(startDate)}
|
||||
maxDate={fromUtc(endDate)}
|
||||
unit={unit}
|
||||
data={chartData}
|
||||
animationDuration={animationDuration}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
|
|||
|
||||
function getDateSQL(field: string, unit: string, timezone?: string) {
|
||||
if (timezone) {
|
||||
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
|
||||
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
|
||||
}
|
||||
return `toDateTime(date_trunc('${unit}', ${field}))`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const TIMEZONE_CONFIG = 'umami.timezone';
|
|||
export const DATE_RANGE_CONFIG = 'umami.date-range';
|
||||
export const THEME_CONFIG = 'zen.theme';
|
||||
export const DASHBOARD_CONFIG = 'umami.dashboard';
|
||||
export const LAST_TEAM_CONFIG = 'umami.last-team';
|
||||
export const VERSION_CHECK = 'umami.version-check';
|
||||
export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
|
||||
export const HOMEPAGE_URL = 'https://umami.is';
|
||||
|
|
@ -658,3 +659,24 @@ export const CURRENCIES = [
|
|||
{ id: 'OMR', name: 'Omani Rial' },
|
||||
{ id: 'GHS', name: 'Ghanaian Cedi' },
|
||||
];
|
||||
|
||||
export const TIMEZONE_LEGACY: Record<string, string> = {
|
||||
'Asia/Batavia': 'Asia/Jakarta',
|
||||
'Asia/Calcutta': 'Asia/Kolkata',
|
||||
'Asia/Chongqing': 'Asia/Shanghai',
|
||||
'Asia/Harbin': 'Asia/Shanghai',
|
||||
'Asia/Jayapura': 'Asia/Pontianak',
|
||||
'Asia/Katmandu': 'Asia/Kathmandu',
|
||||
'Asia/Macao': 'Asia/Macau',
|
||||
'Asia/Rangoon': 'Asia/Yangon',
|
||||
'Asia/Saigon': 'Asia/Ho_Chi_Minh',
|
||||
'Europe/Kiev': 'Europe/Kyiv',
|
||||
'Europe/Zaporozhye': 'Europe/Kyiv',
|
||||
'Etc/UTC': 'UTC',
|
||||
'US/Arizona': 'America/Phoenix',
|
||||
'US/Central': 'America/Chicago',
|
||||
'US/Eastern': 'America/New_York',
|
||||
'US/Mountain': 'America/Denver',
|
||||
'US/Pacific': 'America/Los_Angeles',
|
||||
'US/Samoa': 'Pacific/Pago_Pago',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,44 +1,45 @@
|
|||
import {
|
||||
addMinutes,
|
||||
addHours,
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
subMinutes,
|
||||
subHours,
|
||||
subDays,
|
||||
subMonths,
|
||||
subYears,
|
||||
startOfMinute,
|
||||
startOfHour,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
startOfYear,
|
||||
endOfHour,
|
||||
endOfDay,
|
||||
endOfWeek,
|
||||
endOfMonth,
|
||||
endOfYear,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
differenceInCalendarDays,
|
||||
differenceInCalendarWeeks,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarYears,
|
||||
format,
|
||||
max,
|
||||
min,
|
||||
isDate,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
endOfMinute,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
isEqual,
|
||||
} from 'date-fns';
|
||||
import { getDateLocale } from '@/lib/lang';
|
||||
import { DateRange } from '@/lib/types';
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
addYears,
|
||||
differenceInCalendarDays,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
differenceInCalendarYears,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
endOfDay,
|
||||
endOfHour,
|
||||
endOfMinute,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
format,
|
||||
isBefore,
|
||||
isDate,
|
||||
isEqual,
|
||||
isSameDay,
|
||||
max,
|
||||
min,
|
||||
startOfDay,
|
||||
startOfHour,
|
||||
startOfMinute,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subHours,
|
||||
subMinutes,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subYears,
|
||||
} from 'date-fns';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
|
||||
export const TIME_UNIT = {
|
||||
minute: 'minute',
|
||||
|
|
@ -135,7 +136,7 @@ export function parseDateValue(value: string) {
|
|||
return { num: +num, unit };
|
||||
}
|
||||
|
||||
export function parseDateRange(value: string, locale = 'en-US'): DateRange {
|
||||
export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -156,7 +157,8 @@ export function parseDateRange(value: string, locale = 'en-US'): DateRange {
|
|||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const date = new Date();
|
||||
const now = timezone ? utcToZonedTime(date, timezone) : date;
|
||||
const dateLocale = getDateLocale(locale);
|
||||
const { num = 1, unit } = parseDateValue(value);
|
||||
|
||||
|
|
@ -367,3 +369,7 @@ export function getDateRangeValue(startDate: Date, endDate: Date) {
|
|||
export function getMonthDateRangeValue(date: Date) {
|
||||
return getDateRangeValue(startOfMonth(date), endOfMonth(date));
|
||||
}
|
||||
|
||||
export function isInvalidDate(date: any) {
|
||||
return date instanceof Date && isNaN(date.getTime());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
export const IP_ADDRESS_HEADERS = [
|
||||
'true-client-ip', // CDN
|
||||
'x-real-ip', // Reverse proxy
|
||||
'x-forwarded-for',
|
||||
'cf-connecting-ip', // Cloudflare
|
||||
'fastly-client-ip', // Fastly
|
||||
'x-nf-client-connection-ip', // Netlify
|
||||
'do-connecting-ip', // Digital Ocean
|
||||
'x-appengine-user-ip', // Google App Ending
|
||||
'x-real-ip', // Reverse proxy
|
||||
'x-appengine-user-ip', // Google App Engine
|
||||
'x-forwarded-for',
|
||||
'forwarded',
|
||||
'x-client-ip',
|
||||
'x-cluster-client-ip',
|
||||
'x-forwarded',
|
||||
'forwarded',
|
||||
];
|
||||
|
||||
export function getIpAddress(headers: Headers) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ const DATE_FORMATS = {
|
|||
year: 'YYYY-01-01 HH24:00:00',
|
||||
};
|
||||
|
||||
const DATE_FORMATS_UTC = {
|
||||
minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
|
||||
hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
|
||||
day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
|
||||
month: 'YYYY-MM-01"T"HH24:00:00"Z"',
|
||||
year: 'YYYY-01-01"T"HH24:00:00"Z"',
|
||||
};
|
||||
|
||||
function getAddIntervalQuery(field: string, interval: string): string {
|
||||
return `${field} + interval '${interval}'`;
|
||||
}
|
||||
|
|
@ -40,11 +48,11 @@ function getCastColumnQuery(field: string, type: string): string {
|
|||
}
|
||||
|
||||
function getDateSQL(field: string, unit: string, timezone?: string): string {
|
||||
if (timezone) {
|
||||
if (timezone && timezone !== 'utc') {
|
||||
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
|
||||
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`;
|
||||
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
|
||||
}
|
||||
|
||||
function getDateWeeklySQL(field: string, timezone?: string) {
|
||||
|
|
|
|||
|
|
@ -116,3 +116,23 @@ export interface PageResult<T> {
|
|||
sortDescending?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface RealtimeData {
|
||||
countries: Record<string, number>;
|
||||
events: any[];
|
||||
pageviews: any[];
|
||||
referrers: Record<string, number>;
|
||||
timestamp: number;
|
||||
series: {
|
||||
views: any[];
|
||||
visitors: any[];
|
||||
};
|
||||
totals: {
|
||||
views: number;
|
||||
visitors: number;
|
||||
events: number;
|
||||
countries: number;
|
||||
};
|
||||
urls: Record<string, number>;
|
||||
visitors: any[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,26 +135,31 @@ export async function resetWebsite(websiteId: string) {
|
|||
const { client, transaction } = prisma;
|
||||
const cloudMode = !!process.env.CLOUD_MODE;
|
||||
|
||||
return transaction([
|
||||
client.eventData.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.sessionData.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.websiteEvent.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.session.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.website.update({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
resetAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]).then(async data => {
|
||||
return transaction(
|
||||
[
|
||||
client.eventData.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.sessionData.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.websiteEvent.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.session.deleteMany({
|
||||
where: { websiteId },
|
||||
}),
|
||||
client.website.update({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
resetAt: new Date(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
{
|
||||
timeout: 30000,
|
||||
},
|
||||
).then(async data => {
|
||||
if (cloudMode) {
|
||||
await redis.client.set(
|
||||
`website:${websiteId}`,
|
||||
|
|
|
|||
|
|
@ -19,20 +19,20 @@ async function relationalQuery(websiteId: string, eventId: string) {
|
|||
|
||||
return rawQuery(
|
||||
`
|
||||
select website_id as "websiteId",
|
||||
session_id as "sessionId",
|
||||
event_id as "eventId",
|
||||
url_path as "urlPath",
|
||||
event_name as "eventName",
|
||||
data_key as "dataKey",
|
||||
string_value as "stringValue",
|
||||
number_value as "numberValue",
|
||||
date_value as "dateValue",
|
||||
data_type as "dataType",
|
||||
created_at as "createdAt"
|
||||
select event_data.website_id as "websiteId",
|
||||
event_data.website_event_id as "eventId",
|
||||
website_event.event_name as "eventName",
|
||||
event_data.data_key as "dataKey",
|
||||
event_data.string_value as "stringValue",
|
||||
event_data.number_value as "numberValue",
|
||||
event_data.date_value as "dateValue",
|
||||
event_data.data_type as "dataType",
|
||||
event_data.created_at as "createdAt"
|
||||
from event_data
|
||||
website_id = {{websiteId::uuid}}
|
||||
event_id = {{eventId::uuid}}
|
||||
join website_event on website_event.event_id = event_data.website_event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
where event_data.website_id = {{websiteId::uuid}}
|
||||
and event_data.website_event_id = {{eventId::uuid}}
|
||||
`,
|
||||
{ websiteId, eventId },
|
||||
FUNCTION_NAME,
|
||||
|
|
@ -45,9 +45,7 @@ async function clickhouseQuery(websiteId: string, eventId: string): Promise<Even
|
|||
return rawQuery(
|
||||
`
|
||||
select website_id as websiteId,
|
||||
session_id as sessionId,
|
||||
event_id as eventId,
|
||||
url_path as urlPath,
|
||||
event_name as eventName,
|
||||
data_key as dataKey,
|
||||
string_value as stringValue,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
browser as browser,
|
||||
page_title as "pageTitle",
|
||||
website_event.event_type as "eventType",
|
||||
website_event.event_name as "eventName"
|
||||
website_event.event_name as "eventName",
|
||||
event_id IN (select website_event_id
|
||||
from event_data
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and created_at between {{startDate}} and {{endDate}}) AS "hasData"
|
||||
from website_event
|
||||
${cohortQuery}
|
||||
join session on session.session_id = website_event.session_id
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
|
||||
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
|
||||
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
|
||||
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
|
||||
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
|
||||
|
||||
function increment(data: object, key: string) {
|
||||
if (key) {
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ async function relationalQuery(
|
|||
return rawQuery(
|
||||
`
|
||||
select
|
||||
sum(t.c) as "pageviews",
|
||||
cast(coalesce(sum(t.c), 0) as bigint) as "pageviews",
|
||||
count(distinct t.session_id) as "visitors",
|
||||
count(distinct t.visit_id) as "visits",
|
||||
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
|
||||
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
|
||||
coalesce(sum(case when t.c = 1 then 1 else 0 end), 0) as "bounces",
|
||||
cast(coalesce(sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}), 0) as bigint) as "totaltime"
|
||||
from (
|
||||
select
|
||||
website_event.session_id,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async function clickhouseQuery(
|
|||
websiteId: string,
|
||||
filters: QueryFilters,
|
||||
): Promise<{ x: string; y: number }[]> {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { parseFilters, rawQuery, getDateSQL } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ async function relationalQuery(
|
|||
for (let i = 1; i <= steps; i++) {
|
||||
const endQuery = i < steps ? ',' : '';
|
||||
selectQuery += `s.e${i},`;
|
||||
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN event ELSE NULL END) AS e${i}${endQuery}`;
|
||||
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
|
||||
groupByQuery += `s.e${i}${endQuery} `;
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ async function clickhouseQuery(
|
|||
for (let i = 1; i <= steps; i++) {
|
||||
const endQuery = i < steps ? ',' : '';
|
||||
selectQuery += `s.e${i},`;
|
||||
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN event ELSE NULL END) AS e${i}${endQuery}`;
|
||||
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
|
||||
groupByQuery += `s.e${i}${endQuery} `;
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ async function clickhouseQuery(
|
|||
WITH events AS (
|
||||
select distinct
|
||||
visit_id,
|
||||
coalesce(nullIf(event_name, ''), url_path) event,
|
||||
coalesce(nullIf(event_name, ''), url_path) "event",
|
||||
row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number
|
||||
from website_event
|
||||
${cohortQuery}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ async function clickhouseQuery(
|
|||
user_activities AS (
|
||||
select distinct
|
||||
website_event.session_id,
|
||||
(${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400 as day_number
|
||||
toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
|
||||
from website_event
|
||||
join cohort_items
|
||||
on website_event.session_id = cohort_items.session_id
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ async function relationalQuery(
|
|||
currency,
|
||||
});
|
||||
|
||||
const joinQuery = filterQuery
|
||||
? `join website_event
|
||||
on website_event.website_id = revenue.website_id
|
||||
and website_event.session_id = revenue.session_id
|
||||
and website_event.event_id = revenue.event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}`
|
||||
: '';
|
||||
|
||||
const chart = await rawQuery(
|
||||
`
|
||||
select
|
||||
|
|
@ -48,17 +57,12 @@ async function relationalQuery(
|
|||
${getDateSQL('revenue.created_at', unit, timezone)} t,
|
||||
sum(revenue.revenue) y
|
||||
from revenue
|
||||
join website_event
|
||||
on website_event.website_id = revenue.website_id
|
||||
and website_event.session_id = revenue.session_id
|
||||
and website_event.event_id = revenue.event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
${joinQuery}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where revenue.website_id = {{websiteId::uuid}}
|
||||
and revenue.created_at between {{startDate}} and {{endDate}}
|
||||
and revenue.currency like {{currency}}
|
||||
and revenue.currency ilike {{currency}}
|
||||
${filterQuery}
|
||||
group by x, t
|
||||
order by t
|
||||
|
|
@ -72,19 +76,14 @@ async function relationalQuery(
|
|||
session.country as name,
|
||||
sum(revenue) value
|
||||
from revenue
|
||||
join website_event
|
||||
on website_event.website_id = revenue.website_id
|
||||
and website_event.session_id = revenue.session_id
|
||||
and website_event.event_id = revenue.event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
${joinQuery}
|
||||
join session
|
||||
on session.website_id = revenue.website_id
|
||||
and session.session_id = revenue.session_id
|
||||
${cohortQuery}
|
||||
where revenue.website_id = {{websiteId::uuid}}
|
||||
and revenue.created_at between {{startDate}} and {{endDate}}
|
||||
and revenue.currency = {{currency}}
|
||||
and revenue.currency ilike {{currency}}
|
||||
${filterQuery}
|
||||
group by session.country
|
||||
`,
|
||||
|
|
@ -98,23 +97,18 @@ async function relationalQuery(
|
|||
count(distinct revenue.event_id) as count,
|
||||
count(distinct revenue.session_id) as unique_count
|
||||
from revenue
|
||||
join website_event
|
||||
on website_event.website_id = revenue.website_id
|
||||
and website_event.session_id = revenue.session_id
|
||||
and website_event.event_id = revenue.event_id
|
||||
and website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
${joinQuery}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where revenue.website_id = {{websiteId::uuid}}
|
||||
and revenue.created_at between {{startDate}} and {{endDate}}
|
||||
and revenue.currency = {{currency}}
|
||||
and revenue.currency ilike {{currency}}
|
||||
${filterQuery}
|
||||
`,
|
||||
queryParams,
|
||||
).then(result => result?.[0]);
|
||||
|
||||
total.average = total.count > 0 ? total.sum / total.count : 0;
|
||||
total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0;
|
||||
|
||||
return { chart, country, total };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,44 @@
|
|||
import { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function createSession(data: Prisma.SessionCreateInput) {
|
||||
const {
|
||||
id,
|
||||
websiteId,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
distinctId,
|
||||
} = data;
|
||||
const FUNCTION_NAME = 'createSession';
|
||||
|
||||
try {
|
||||
return await prisma.client.session.create({
|
||||
data: {
|
||||
id,
|
||||
websiteId,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
distinctId,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message.toLowerCase().includes('unique constraint')) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
export async function createSession(data: Prisma.SessionCreateInput) {
|
||||
const { rawQuery } = prisma;
|
||||
|
||||
await rawQuery(
|
||||
`
|
||||
insert into session (
|
||||
session_id,
|
||||
website_id,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
distinct_id,
|
||||
created_at
|
||||
)
|
||||
values (
|
||||
{{id}},
|
||||
{{websiteId}},
|
||||
{{browser}},
|
||||
{{os}},
|
||||
{{device}},
|
||||
{{screen}},
|
||||
{{language}},
|
||||
{{country}},
|
||||
{{region}},
|
||||
{{city}},
|
||||
{{distinctId}},
|
||||
{{createdAt}}
|
||||
)
|
||||
on conflict (session_id) do nothing
|
||||
`,
|
||||
data,
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu
|
|||
event_type as "eventType",
|
||||
event_name as "eventName",
|
||||
visit_id as "visitId",
|
||||
event_id IN (select event_id
|
||||
event_id IN (select website_event_id
|
||||
from event_data
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and session_id = {{sessionId::uuid}}) AS "hasData"
|
||||
and created_at between {{startDate}} and {{endDate}}) AS "hasData"
|
||||
from website_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and session_id = {{sessionId::uuid}}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async function clickhouseQuery(
|
|||
websiteId: string,
|
||||
filters: QueryFilters,
|
||||
): Promise<{ x: string; y: number }[]> {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { parseFilters, rawQuery, getDateSQL } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
const _false = 'false';
|
||||
const _true = 'true';
|
||||
const attr = currentScript.getAttribute.bind(currentScript);
|
||||
|
||||
const website = attr(_data + 'website-id');
|
||||
const hostUrl = attr(_data + 'host-url');
|
||||
const beforeSend = attr(_data + 'before-send');
|
||||
|
|
@ -27,6 +28,8 @@
|
|||
const excludeSearch = attr(_data + 'exclude-search') === _true;
|
||||
const excludeHash = attr(_data + 'exclude-hash') === _true;
|
||||
const domain = attr(_data + 'domains') || '';
|
||||
const credentials = attr(_data + 'fetch-credentials') || 'omit';
|
||||
|
||||
const domains = domain.split(',').map(n => n.trim());
|
||||
const host =
|
||||
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
|
||||
|
|
@ -45,7 +48,7 @@
|
|||
if (excludeSearch) u.search = '';
|
||||
if (excludeHash) u.hash = '';
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
|
@ -165,7 +168,7 @@
|
|||
'Content-Type': 'application/json',
|
||||
...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }),
|
||||
},
|
||||
credentials: 'omit',
|
||||
credentials,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue