Compare commits

..

6 commits

Author SHA1 Message Date
Francis Cao
64a6379c3c fix realtime logs for mobile
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-10 01:07:11 -08:00
Francis Cao
f3e246c64b fix hasdata queries, add hasData to website events, fix sessionactivity truncation, 2025-11-09 23:58:20 -08:00
Francis Cao
9230f3cb7b manually include basePath 2025-11-09 22:03:06 -08:00
Francis Cao
f30724629c Fix null and string return types from getWebsiteStats 2025-11-09 21:37:35 -08:00
Francis Cao
c44f6f8c9c Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-11-09 21:19:46 -08:00
Francis Cao
bf548c5aca Fix revenue bigInt but and case insensitive currency 2025-11-09 21:19:38 -08:00
12 changed files with 116 additions and 65 deletions

View file

@ -16,7 +16,7 @@ export function App({ children }) {
} }
if (error) { if (error) {
window.location.href = '/login'; window.location.href = `${process.env.basePath || ''}/login`;
return null; return null;
} }

View file

@ -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 { useFormat, useMessages, useNavigation } from '@/components/hooks';
import { Avatar } from '@/components/common/Avatar'; import { Avatar } from '@/components/common/Avatar';
import Link from 'next/link'; import Link from 'next/link';
import { Eye } from '@/components/icons'; import { Eye, FileText } from '@/components/icons';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon'; import { TypeIcon } from '@/components/common/TypeIcon';
import { EventData } from '@/components/metrics/EventData';
export function EventsTable(props: DataTableProps) { export function EventsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +45,7 @@ export function EventsTable(props: DataTableProps) {
> >
{row.eventName || row.urlPath} {row.eventName || row.urlPath}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>
); );
}} }}
@ -72,3 +86,22 @@ export function EventsTable(props: DataTableProps) {
</DataTable> </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>
);
};

View file

@ -9,6 +9,7 @@ import {
useCountryNames, useCountryNames,
useLocale, useLocale,
useMessages, useMessages,
useMobile,
useNavigation, useNavigation,
useTimezone, useTimezone,
useWebsite, useWebsite,
@ -40,6 +41,7 @@ export function RealtimeLog({ data }: { data: any }) {
const { countryNames } = useCountryNames(locale); const { countryNames } = useCountryNames(locale);
const [filter, setFilter] = useState(TYPE_ALL); const [filter, setFilter] = useState(TYPE_ALL);
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { isPhone } = useMobile();
const buttons = [ const buttons = [
{ {
@ -123,12 +125,18 @@ export function RealtimeLog({ data }: { data: any }) {
const row = logs[index]; const row = logs[index];
return ( return (
<Row alignItems="center" style={style} gap> <Row alignItems="center" style={style} gap>
<Link href={updateParams({ session: row.sessionId })}> <Row minWidth="30px">
<Avatar seed={row.sessionId} size={32} /> <Link href={updateParams({ session: row.sessionId })}>
</Link> <Avatar seed={row.sessionId} size={32} />
<Row width="100px">{getTime(row)}</Row> </Link>
</Row>
<Row minWidth="100px">
<Text wrap="nowrap">{getTime(row)}</Text>
</Row>
<IconLabel icon={getIcon(row)}> <IconLabel icon={getIcon(row)}>
<Text>{getDetail(row)}</Text> <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
{getDetail(row)}
</Text>
</IconLabel> </IconLabel>
</Row> </Row>
); );
@ -168,10 +176,22 @@ export function RealtimeLog({ data }: { data: any }) {
return ( return (
<Column gap> <Column gap>
<Heading size="2">{formatMessage(labels.activity)}</Heading> <Heading size="2">{formatMessage(labels.activity)}</Heading>
<Row alignItems="center" justifyContent="space-between"> {isPhone ? (
<SearchField value={search} onSearch={setSearch} /> <>
<FilterButtons items={buttons} value={filter} onChange={setFilter} /> <Row>
</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> <Column>
{logs?.length === 0 && <Empty />} {logs?.length === 0 && <Empty />}
{logs?.length > 0 && ( {logs?.length > 0 && (

View file

@ -6,7 +6,7 @@ import { PageBody } from '@/components/common/PageBody';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { RealtimeChart } from '@/components/metrics/RealtimeChart'; import { RealtimeChart } from '@/components/metrics/RealtimeChart';
import { WorldMap } from '@/components/metrics/WorldMap'; import { WorldMap } from '@/components/metrics/WorldMap';
import { useRealtimeQuery } from '@/components/hooks'; import { useMobile, useRealtimeQuery } from '@/components/hooks';
import { RealtimeLog } from './RealtimeLog'; import { RealtimeLog } from './RealtimeLog';
import { RealtimeHeader } from './RealtimeHeader'; import { RealtimeHeader } from './RealtimeHeader';
import { RealtimePaths } from './RealtimePaths'; import { RealtimePaths } from './RealtimePaths';
@ -16,6 +16,7 @@ import { percentFilter } from '@/lib/filters';
export function RealtimePage({ websiteId }: { websiteId: string }) { export function RealtimePage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId); const { data, isLoading, error } = useRealtimeQuery(websiteId);
const { isMobile } = useMobile();
if (isLoading || error) { if (isLoading || error) {
return <PageBody isLoading={isLoading} error={error} />; return <PageBody isLoading={isLoading} error={error} />;
@ -48,7 +49,7 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
<Panel> <Panel>
<RealtimeCountries data={countries} /> <RealtimeCountries data={countries} />
</Panel> </Panel>
<Panel gridColumn="span 2" padding="0"> <Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
<WorldMap data={countries} /> <WorldMap data={countries} />
</Panel> </Panel>
</GridRow> </GridRow>

View file

@ -14,7 +14,7 @@ import {
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Eye, FileText } from '@/components/icons'; import { Eye, FileText } from '@/components/icons';
import { Lightning } from '@/components/svg'; 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'; import { EventData } from '@/components/metrics/EventData';
export function SessionActivity({ export function SessionActivity({
@ -36,6 +36,7 @@ export function SessionActivity({
startDate, startDate,
endDate, endDate,
); );
const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
return ( return (
@ -50,16 +51,16 @@ export function SessionActivity({
{showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>} {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
<Row alignItems="center" gap="6" height="40px"> <Row alignItems="center" gap="6" height="40px">
<StatusLight color={`#${visitId?.substring(0, 6)}`}> <StatusLight color={`#${visitId?.substring(0, 6)}`}>
{formatTimezoneDate(createdAt, 'pp')} <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
</StatusLight> </StatusLight>
<Row alignItems="center" gap="2"> <Row alignItems="center" gap="2">
<Icon>{eventName ? <Lightning /> : <Eye />}</Icon> <Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
<Text> <Text wrap="nowrap">
{eventName {eventName
? formatMessage(labels.triggeredEvent) ? formatMessage(labels.triggeredEvent)
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: '400px' }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || urlPath}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}

View file

@ -13,7 +13,7 @@ export function LogoutPage() {
async function logout() { async function logout() {
await post('/auth/logout'); await post('/auth/logout');
window.location.href = '/login'; window.location.href = `${process.env.basePath || ''}/login`;
} }
removeClientAuthToken(); removeClientAuthToken();

View file

@ -57,7 +57,7 @@ export function ListTable({
showPercentage={showPercentage} showPercentage={showPercentage}
change={renderChange ? renderChange(row, index) : null} change={renderChange ? renderChange(row, index) : null}
currency={currency} currency={currency}
isMobile={isPhone} isPhone={isPhone}
/> />
); );
}; };
@ -101,7 +101,7 @@ const AnimatedRow = ({
animate, animate,
showPercentage = true, showPercentage = true,
currency, currency,
isMobile, isPhone,
}) => { }) => {
const props = useSpring({ const props = useSpring({
width: percent, width: percent,
@ -120,7 +120,7 @@ const AnimatedRow = ({
gap gap
> >
<Row alignItems="center"> <Row alignItems="center">
<Text truncate={true} style={{ maxWidth: isMobile ? '200px' : '400px' }}> <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
{label} {label}
</Text> </Text>
</Row> </Row>

View file

@ -19,20 +19,20 @@ async function relationalQuery(websiteId: string, eventId: string) {
return rawQuery( return rawQuery(
` `
select website_id as "websiteId", select event_data.website_id as "websiteId",
session_id as "sessionId", event_data.website_event_id as "eventId",
event_id as "eventId", website_event.event_name as "eventName",
url_path as "urlPath", event_data.data_key as "dataKey",
event_name as "eventName", event_data.string_value as "stringValue",
data_key as "dataKey", event_data.number_value as "numberValue",
string_value as "stringValue", event_data.date_value as "dateValue",
number_value as "numberValue", event_data.data_type as "dataType",
date_value as "dateValue", event_data.created_at as "createdAt"
data_type as "dataType",
created_at as "createdAt"
from event_data from event_data
website_id = {{websiteId::uuid}} join website_event on website_event.event_id = event_data.website_event_id
event_id = {{eventId::uuid}} and website_event.website_id = {{websiteId::uuid}}
where event_data.website_id = {{websiteId::uuid}}
and event_data.website_event_id = {{eventId::uuid}}
`, `,
{ websiteId, eventId }, { websiteId, eventId },
FUNCTION_NAME, FUNCTION_NAME,
@ -45,9 +45,7 @@ async function clickhouseQuery(websiteId: string, eventId: string): Promise<Even
return rawQuery( return rawQuery(
` `
select website_id as websiteId, select website_id as websiteId,
session_id as sessionId,
event_id as eventId, event_id as eventId,
url_path as urlPath,
event_name as eventName, event_name as eventName,
data_key as dataKey, data_key as dataKey,
string_value as stringValue, string_value as stringValue,

View file

@ -45,7 +45,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
browser as browser, browser as browser,
page_title as "pageTitle", page_title as "pageTitle",
website_event.event_type as "eventType", 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 from website_event
${cohortQuery} ${cohortQuery}
join session on session.session_id = website_event.session_id join session on session.session_id = website_event.session_id

View file

@ -36,11 +36,11 @@ async function relationalQuery(
return rawQuery( return rawQuery(
` `
select 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.session_id) as "visitors",
count(distinct t.visit_id) as "visits", count(distinct t.visit_id) as "visits",
sum(case when t.c = 1 then 1 else 0 end) as "bounces", coalesce(sum(case when t.c = 1 then 1 else 0 end), 0) as "bounces",
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" cast(coalesce(sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}), 0) as bigint) as "totaltime"
from ( from (
select select
website_event.session_id, website_event.session_id,

View file

@ -41,6 +41,15 @@ async function relationalQuery(
currency, 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( const chart = await rawQuery(
` `
select select
@ -48,17 +57,12 @@ async function relationalQuery(
${getDateSQL('revenue.created_at', unit, timezone)} t, ${getDateSQL('revenue.created_at', unit, timezone)} t,
sum(revenue.revenue) y sum(revenue.revenue) y
from revenue from revenue
join website_event ${joinQuery}
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}}
${cohortQuery} ${cohortQuery}
${joinSessionQuery} ${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency like {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
group by x, t group by x, t
order by t order by t
@ -72,19 +76,14 @@ async function relationalQuery(
session.country as name, session.country as name,
sum(revenue) value sum(revenue) value
from revenue from revenue
join website_event ${joinQuery}
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}}
join session join session
on session.website_id = revenue.website_id on session.website_id = revenue.website_id
and session.session_id = revenue.session_id and session.session_id = revenue.session_id
${cohortQuery} ${cohortQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency = {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
group by session.country group by session.country
`, `,
@ -98,23 +97,18 @@ async function relationalQuery(
count(distinct revenue.event_id) as count, count(distinct revenue.event_id) as count,
count(distinct revenue.session_id) as unique_count count(distinct revenue.session_id) as unique_count
from revenue from revenue
join website_event ${joinQuery}
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}}
${cohortQuery} ${cohortQuery}
${joinSessionQuery} ${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}} where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}} and revenue.created_at between {{startDate}} and {{endDate}}
and revenue.currency = {{currency}} and revenue.currency ilike {{currency}}
${filterQuery} ${filterQuery}
`, `,
queryParams, queryParams,
).then(result => result?.[0]); ).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 }; return { chart, country, total };
} }

View file

@ -29,10 +29,10 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu
event_type as "eventType", event_type as "eventType",
event_name as "eventName", event_name as "eventName",
visit_id as "visitId", visit_id as "visitId",
event_id IN (select event_id event_id IN (select website_event_id
from event_data from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and session_id = {{sessionId::uuid}}) AS "hasData" and created_at between {{startDate}} and {{endDate}}) AS "hasData"
from website_event from website_event
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and session_id = {{sessionId::uuid}} and session_id = {{sessionId::uuid}}