Merge branch 'dev' into analytics

This commit is contained in:
Mike Cao 2025-04-25 21:59:57 -07:00
commit 79b4c1312e
10 changed files with 81 additions and 40 deletions

View file

@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */ import dotenv from 'dotenv';
require('dotenv').config(); import { createRequire } from 'module';
dotenv.config();
const require = createRequire(import.meta.url);
const pkg = require('./package.json'); const pkg = require('./package.json');
const TRACKER_SCRIPT = '/script.js'; const TRACKER_SCRIPT = '/script.js';
@ -12,6 +16,7 @@ const corsMaxAge = process.env.CORS_MAX_AGE;
const defaultLocale = process.env.DEFAULT_LOCALE; const defaultLocale = process.env.DEFAULT_LOCALE;
const disableLogin = process.env.DISABLE_LOGIN; const disableLogin = process.env.DISABLE_LOGIN;
const disableUI = process.env.DISABLE_UI; const disableUI = process.env.DISABLE_UI;
const faviconURL = process.env.FAVICON_URL;
const forceSSL = process.env.FORCE_SSL; const forceSSL = process.env.FORCE_SSL;
const frameAncestors = process.env.ALLOWED_FRAME_URLS; const frameAncestors = process.env.ALLOWED_FRAME_URLS;
const privateMode = process.env.PRIVATE_MODE; const privateMode = process.env.PRIVATE_MODE;
@ -180,17 +185,17 @@ if (cloudMode && cloudUrl) {
} }
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { export default {
reactStrictMode: false, reactStrictMode: false,
env: { env: {
basePath, basePath,
cloudMode, cloudMode,
cloudUrl, cloudUrl,
configUrl: '/config',
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale, defaultLocale,
disableLogin, disableLogin,
disableUI, disableUI,
faviconURL,
privateMode, privateMode,
}, },
basePath, basePath,
@ -237,5 +242,3 @@ const config = {
return [...redirects]; return [...redirects];
}, },
}; };
module.exports = config;

View file

@ -1,7 +1,7 @@
{ {
"name": "umami", "name": "umami",
"version": "2.18.0", "version": "2.18.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
"homepage": "https://umami.is", "homepage": "https://umami.is",

View file

@ -4,27 +4,33 @@ import EventsDataTable from './EventsDataTable';
import EventsMetricsBar from './EventsMetricsBar'; import EventsMetricsBar from './EventsMetricsBar';
import EventsChart from '@/components/metrics/EventsChart'; import EventsChart from '@/components/metrics/EventsChart';
import { GridRow } from '@/components/layout/Grid'; import { GridRow } from '@/components/layout/Grid';
import MetricsTable from '@/components/metrics/MetricsTable'; import EventsTable from '@/components/metrics/EventsTable';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { Item, Tabs } from 'react-basics'; import { Item, Tabs } from 'react-basics';
import { useState } from 'react'; import { useState } from 'react';
import EventProperties from './EventProperties'; import EventProperties from './EventProperties';
export default function EventsPage({ websiteId }) { export default function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
const [tab, setTab] = useState('activity'); const [tab, setTab] = useState('activity');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleLabelClick = (value: string) => {
setLabel(value !== label ? value : '');
};
return ( return (
<> <>
<WebsiteHeader websiteId={websiteId} /> <WebsiteHeader websiteId={websiteId} />
<EventsMetricsBar websiteId={websiteId} /> <EventsMetricsBar websiteId={websiteId} />
<GridRow columns="two-one"> <GridRow columns="two-one">
<EventsChart websiteId={websiteId} /> <EventsChart websiteId={websiteId} focusLabel={label} />
<MetricsTable <EventsTable
websiteId={websiteId} websiteId={websiteId}
type="event" type="event"
title={formatMessage(labels.events)} title={formatMessage(labels.events)}
metric={formatMessage(labels.actions)} metric={formatMessage(labels.actions)}
onLabelClick={handleLabelClick}
/> />
</GridRow> </GridRow>
<div> <div>

View file

@ -55,7 +55,7 @@ export async function POST(request: Request) {
title, title,
tag, tag,
timestamp, timestamp,
id = '', id,
} = payload; } = payload;
// Cache check // Cache check
@ -101,7 +101,7 @@ export async function POST(request: Request) {
const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
const visitSalt = hash(startOfHour(createdAt).toUTCString()); const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt, id); const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
// Find session // Find session
if (!clickhouse.enabled && !cache?.sessionId) { if (!clickhouse.enabled && !cache?.sessionId) {
@ -148,6 +148,10 @@ export async function POST(request: Request) {
const urlQuery = currentUrl.search.substring(1); const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, ''); const urlDomain = currentUrl.hostname.replace(/^www./, '');
let referrerPath: string;
let referrerQuery: string;
let referrerDomain: string;
// UTM Params // UTM Params
const utmSource = currentUrl.searchParams.get('utm_source'); const utmSource = currentUrl.searchParams.get('utm_source');
const utmMedium = currentUrl.searchParams.get('utm_medium'); const utmMedium = currentUrl.searchParams.get('utm_medium');
@ -167,10 +171,6 @@ export async function POST(request: Request) {
urlPath = urlPath.replace(/(.+)\/$/, '$1'); urlPath = urlPath.replace(/(.+)\/$/, '$1');
} }
let referrerPath: string;
let referrerQuery: string;
let referrerDomain: string;
if (referrer) { if (referrer) {
const referrerUrl = new URL(referrer, base); const referrerUrl = new URL(referrer, base);

View file

@ -34,7 +34,7 @@ export function Chart({
className, className,
chartOptions, chartOptions,
}: ChartProps) { }: ChartProps) {
const canvas = useRef(); const canvas = useRef(null);
const chart = useRef(null); const chart = useRef(null);
const [legendItems, setLegendItems] = useState([]); const [legendItems, setLegendItems] = useState([]);
@ -86,7 +86,7 @@ export function Chart({
dataset.data = data?.datasets[index]?.data; dataset.data = data?.datasets[index]?.data;
if (chart.current.legend.legendItems[index]) { if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label; chart.current.legend.legendItems[index].text = data.datasets[index]?.label;
} }
} }
}); });
@ -95,6 +95,12 @@ export function Chart({
} }
} }
if (data.focusLabel !== null) {
chart.current.data.datasets.forEach(ds => {
ds.hidden = data.focusLabel ? ds.label !== data.focusLabel : false;
});
}
chart.current.options = options; chart.current.options = options;
// Allow config changes before update // Allow config changes before update
@ -105,16 +111,6 @@ export function Chart({
setLegendItems(chart.current.legend.legendItems); setLegendItems(chart.current.legend.legendItems);
}; };
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data, options]);
const handleLegendClick = (item: LegendItem) => { const handleLegendClick = (item: LegendItem) => {
if (type === 'bar') { if (type === 'bar') {
const { datasetIndex } = item; const { datasetIndex } = item;
@ -136,6 +132,16 @@ export function Chart({
setLegendItems(chart.current.legend.legendItems); setLegendItems(chart.current.legend.legendItems);
}; };
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data, options]);
return ( return (
<> <>
<div className={classNames(styles.chart, className)}> <div className={classNames(styles.chart, className)}>

View file

@ -1,4 +1,4 @@
import { GROUPED_DOMAINS } from '@/lib/constants'; import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
function getHostName(url: string) { function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
@ -10,10 +10,10 @@ export function Favicon({ domain, ...props }) {
return null; return null;
} }
const url = process.env.faviconURL || FAVICON_URL;
const hostName = domain ? getHostName(domain) : null; const hostName = domain ? getHostName(domain) : null;
const src = hostName const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
? `https://icons.duckduckgo.com/ip3/${GROUPED_DOMAINS[hostName]?.domain || hostName}.ico` const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
: null;
return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null; return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
} }

View file

@ -1,21 +1,23 @@
import { useMemo, useState, useEffect } from 'react';
import { colord } from 'colord'; import { colord } from 'colord';
import BarChart from '@/components/charts/BarChart'; import BarChart from '@/components/charts/BarChart';
import { useDateRange, useLocale, useWebsiteEventsSeries } from '@/components/hooks'; import { useDateRange, useLocale, useWebsiteEventsSeries } from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS } from '@/lib/constants';
import { useMemo } from 'react';
export interface EventsChartProps { export interface EventsChartProps {
websiteId: string; websiteId: string;
className?: string; className?: string;
focusLabel?: string;
} }
export function EventsChart({ websiteId, className }: EventsChartProps) { export function EventsChart({ websiteId, className, focusLabel }: EventsChartProps) {
const { const {
dateRange: { startDate, endDate, unit, value }, dateRange: { startDate, endDate, unit, value },
} = useDateRange(websiteId); } = useDateRange(websiteId);
const { locale } = useLocale(); const { locale } = useLocale();
const { data, isLoading } = useWebsiteEventsSeries(websiteId); const { data, isLoading } = useWebsiteEventsSeries(websiteId);
const [label, setLabel] = useState<string>(focusLabel);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!data) return []; if (!data) return [];
@ -42,8 +44,15 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
borderWidth: 1, borderWidth: 1,
}; };
}), }),
focusLabel,
}; };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit, focusLabel]);
useEffect(() => {
if (label !== focusLabel) {
setLabel(focusLabel);
}
}, [focusLabel]);
return ( return (
<BarChart <BarChart

View file

@ -1,12 +1,28 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable'; import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
export function EventsTable(props: MetricsTableProps) { export interface EventsTableProps extends MetricsTableProps {
onLabelClick?: (value: string) => void;
}
export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
function handleDataLoad(data: any) { const handleDataLoad = (data: any) => {
props.onDataLoad?.(data); props.onDataLoad?.(data);
} };
const renderLabel = ({ x: label }) => {
if (onLabelClick) {
return (
<div onClick={() => onLabelClick(label)} style={{ cursor: 'pointer' }}>
{label}
</div>
);
}
return label;
};
return ( return (
<MetricsTable <MetricsTable
@ -15,6 +31,7 @@ export function EventsTable(props: MetricsTableProps) {
type="event" type="event"
metric={formatMessage(labels.actions)} metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad} onDataLoad={handleDataLoad}
renderLabel={renderLabel}
/> />
); );
} }

View file

@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
export const CURRENT_VERSION = process.env.currentVersion; export const CURRENT_VERSION = process.env.currentVersion;
export const AUTH_TOKEN = 'umami.auth'; export const AUTH_TOKEN = 'umami.auth';
export const LOCALE_CONFIG = 'umami.locale'; export const LOCALE_CONFIG = 'umami.locale';
@ -12,6 +11,7 @@ export const HOMEPAGE_URL = 'https://umami.is';
export const REPO_URL = 'https://github.com/umami-software/umami'; export const REPO_URL = 'https://github.com/umami-software/umami';
export const UPDATES_URL = 'https://api.umami.is/v1/updates'; export const UPDATES_URL = 'https://api.umami.is/v1/updates';
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png'; export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US'; export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
export const DEFAULT_THEME = 'light'; export const DEFAULT_THEME = 'light';

View file

@ -52,7 +52,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
limit 1000) limit 1000)
select * from events select * from events
`, `,
{ ...params, query: `%${search}%` }, { ...params, search: `%${search}%` },
pageParams, pageParams,
); );
} }