Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run

This commit is contained in:
Mike Cao 2025-12-03 18:39:45 -08:00
commit 1483241494
93 changed files with 3147 additions and 1296 deletions

View file

@ -21,7 +21,11 @@ export function LinksTable(props: DataTableProps) {
<DataColumn id="slug" label={formatMessage(labels.link)}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>

View file

@ -1,4 +1,4 @@
import { Icon, Text } from '@umami/react-zen';
import { IconLabel } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useLink, useMessages, useSlug } from '@/components/hooks';
@ -10,12 +10,9 @@ export function LinkHeader() {
const link = useLink();
return (
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
<PageHeader title={link.name} description={link.url} icon={<Link />}>
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -21,7 +21,11 @@ export function PixelsTable(props: DataTableProps) {
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>

View file

@ -1,4 +1,4 @@
import { Icon, Text } from '@umami/react-zen';
import { IconLabel } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, usePixel, useSlug } from '@/components/hooks';
@ -10,12 +10,9 @@ export function PixelHeader() {
const pixel = usePixel();
return (
<PageHeader title={pixel.name} icon={<Grid2x2 />} marginBottom="3">
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false}>
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
<PageHeader title={pixel.name} icon={<Grid2x2 />}>
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -1,6 +1,8 @@
import Link from 'next/link';
import { DataGrid } from '@/components/common/DataGrid';
import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks';
import { Favicon } from '@/index';
import { Icon, Row } from '@umami/react-zen';
import { WebsitesTable } from './WebsitesTable';
export function WebsitesDataTable({
@ -21,7 +23,12 @@ export function WebsitesDataTable({
const { renderUrl } = useNavigation();
const renderLink = (row: any) => (
<Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
<Row alignItems="center" gap="3">
<Icon size="md" color="muted">
<Favicon domain={row.domain} />
</Icon>
<Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
</Row>
);
return (

View file

@ -13,12 +13,18 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings');
const { formatMessage, labels } = useMessages();
if (isSettings) {
return null;
}
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
<PageHeader
title={website.name}
icon={<Favicon domain={website.domain} />}
titleHref={renderUrl(`/websites/${website.id}`, false)}
>
<Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} />
@ -29,7 +35,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<Icon>
<Edit />
</Icon>
<Text>Edit</Text>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
</Row>
)}

View file

@ -7,7 +7,7 @@ import { checkPassword } from '@/lib/password';
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { getUserByUsername } from '@/queries/prisma';
import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
export async function POST(request: Request) {
const schema = z.object({
@ -39,8 +39,10 @@ export async function POST(request: Request) {
token = createSecureToken({ userId: user.id, role }, secret());
}
const teams = await getAllUserTeams(id);
return json({
token,
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams },
});
}

View file

@ -17,6 +17,7 @@ export async function POST(request: Request) {
const errors = [];
let index = 0;
let cache = null;
for (const data of body) {
// Recreate a fresh Request since `new Request(request)` will have the following error:
// > Cannot read private member #state from an object whose class did not declare it
@ -33,9 +34,12 @@ export async function POST(request: Request) {
});
const response = await send.POST(newRequest);
const responseJson = await response.json();
if (!response.ok) {
errors.push({ index, response: await response.json() });
errors.push({ index, response: responseJson });
} else {
cache ??= responseJson.cache;
}
index++;
@ -46,6 +50,7 @@ export async function POST(request: Request) {
processed: body.length - errors.length,
errors: errors.length,
details: errors,
cache,
});
} catch (e) {
return serverError(e);

View file

@ -41,6 +41,9 @@ const schema = z.object({
userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(),
id: z.string().optional(),
browser: z.string().optional(),
os: z.string().optional(),
device: z.string().optional(),
})
.refine(
data => {

View file

@ -25,8 +25,7 @@ export function LoginForm() {
onSuccess: async ({ token, user }) => {
setClientAuthToken(token);
setUser(user);
router.push('/websites');
router.push('/');
},
});
};

View file

@ -2,7 +2,7 @@
import { redirect } from 'next/navigation';
import { useEffect } from 'react';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { getItem, removeItem } from '@/lib/storage';
import { getItem } from '@/lib/storage';
export default function RootPage() {
useEffect(() => {
@ -11,8 +11,6 @@ export default function RootPage() {
if (lastTeam) {
redirect(`/teams/${lastTeam}/websites`);
} else {
removeItem(LAST_TEAM_CONFIG);
redirect(`/websites`);
}
}, []);

View file

@ -1,5 +1,6 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
@ -10,6 +11,16 @@ import { Header } from './Header';
export function SharePage({ shareId }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme();
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
if (isLoading || !shareToken) {
return null;

View file

@ -1,8 +1,13 @@
import { Icon, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import Link, { type LinkProps } from 'next/link';
import type { ReactNode } from 'react';
import { ExternalLink as LinkIcon } from '@/components/icons';
export function ExternalLink({ href, children, ...props }) {
export function ExternalLink({
href,
children,
...props
}: LinkProps & { href: string; children: ReactNode }) {
return (
<Row alignItems="center" overflow="hidden" gap>
<Text title={href} truncate>

View file

@ -9,6 +9,7 @@ export interface LinkButtonProps extends ButtonProps {
scroll?: boolean;
variant?: any;
prefetch?: boolean;
asAnchor?: boolean;
children?: ReactNode;
}
@ -19,15 +20,22 @@ export function LinkButton({
target,
prefetch,
children,
asAnchor,
...props
}: LinkButtonProps) {
const { dir } = useLocale();
return (
<Button {...props} variant={variant} asChild>
<Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
{children}
</Link>
{asAnchor ? (
<a href={href} target={target}>
{children}
</a>
) : (
<Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
{children}
</Link>
)}
</Button>
);
}

View file

@ -1,5 +1,6 @@
import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { LinkButton } from './LinkButton';
export function PageHeader({
title,
@ -7,6 +8,7 @@ export function PageHeader({
label,
icon,
showBorder = true,
titleHref,
children,
}: {
title: string;
@ -14,6 +16,7 @@ export function PageHeader({
label?: ReactNode;
icon?: ReactNode;
showBorder?: boolean;
titleHref?: string;
allowEdit?: boolean;
className?: string;
children?: ReactNode;
@ -33,7 +36,13 @@ export function PageHeader({
{icon}
</Icon>
)}
{title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>}
{title && titleHref ? (
<LinkButton href={titleHref} variant="quiet">
<Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
</LinkButton>
) : (
title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
)}
</Row>
{description && (
<Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>
@ -41,7 +50,9 @@ export function PageHeader({
</Text>
)}
</Column>
<Row justifyContent="flex-end">{children}</Row>
<Row justifyContent="flex-end" alignItems="center">
{children}
</Row>
</Grid>
);
}

View file

@ -14,6 +14,7 @@ import {
Text,
} from '@umami/react-zen';
import { ArrowRight } from 'lucide-react';
import type { Key } from 'react';
import {
useConfig,
useLoginQuery,
@ -33,7 +34,8 @@ import {
Users,
} from '@/components/icons';
import { Switch } from '@/components/svg';
import { DOCS_URL } from '@/lib/constants';
import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem } from '@/lib/storage';
export interface TeamsButtonProps {
showText?: boolean;
@ -44,7 +46,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
const { user } = useLoginQuery();
const { cloudMode } = useConfig();
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
const { teamId, router } = useNavigation();
const { isMobile } = useMobile();
const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']);
@ -54,7 +56,16 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
};
const handleAction = async () => {};
const handleAction = async (key: Key) => {
if (key === 'user') {
removeItem(LAST_TEAM_CONFIG);
if (cloudMode) {
window.location.href = '/';
} else {
router.push('/');
}
}
};
return (
<MenuTrigger>
@ -84,16 +95,16 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
</Pressable>
<Popover placement="bottom start">
<Column minWidth="300px">
<Menu autoFocus="last" onAction={handleAction}>
<Menu autoFocus="last">
<SubmenuTrigger>
<MenuItem id="teams" showChecked={false} showSubMenuIcon>
<IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
</MenuItem>
<Popover placement={isMobile ? 'bottom start' : 'right top'}>
<Column minWidth="300px">
<Menu selectionMode="single" selectedKeys={selectedKeys}>
<Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}>
<MenuSection title={formatMessage(labels.myAccount)}>
<MenuItem id="user" href={getUrl('/')}>
<MenuItem id="user">
<IconLabel icon={<User />} label={user.username} />
</MenuItem>
</MenuSection>

View file

@ -1,6 +1,6 @@
import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
import { isAfter } from 'date-fns';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
import { ChevronRight } from '@/components/icons';
import { getDateRangeValue } from '@/lib/date';
@ -45,13 +45,9 @@ export function WebsiteDateFilter({
}
};
const handleIncrement = useCallback(
(increment: number) => {
router.push(updateParams({ offset: +offset + increment }));
},
[offset],
);
const handleIncrement = increment => {
router.push(updateParams({ offset: Number(offset) + increment }));
};
const handleSelect = (compare: any) => {
router.push(updateParams({ compare }));
};

View file

@ -1,5 +1,6 @@
import { StatusLight, Text } from '@umami/react-zen';
import { useMemo } from 'react';
import { LinkButton } from '@/components/common/LinkButton';
import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
export function ActiveUsers({
@ -27,10 +28,12 @@ export function ActiveUsers({
}
return (
<StatusLight variant="success">
<Text size="2" weight="medium">
{count} {formatMessage(labels.online)}
</Text>
</StatusLight>
<LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
<StatusLight variant="success">
<Text size="2" weight="medium">
{count} {formatMessage(labels.online)}
</Text>
</StatusLight>
</LinkButton>
);
}

View file

@ -22,7 +22,12 @@ export function Legend({
return (
<Row key={text} onClick={() => onClick(item)}>
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
<Text size="2" color={hidden ? 'disabled' : undefined} wrap="nowrap">
<Text
size="2"
color={hidden ? 'disabled' : undefined}
truncate={true}
style={{ maxWidth: '300px' }}
>
{text}
</Text>
</StatusLight>

View file

@ -16,6 +16,7 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, REALTIME_RANGE);
const prevEndDate = useRef(endDate);
const prevData = useRef<string | null>(null);
const chartData = useMemo(() => {
if (!data) {
@ -28,14 +29,22 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
};
}, [data, startDate, endDate, unit]);
// Don't animate the bars shifting over because it looks weird
const animationDuration = useMemo(() => {
// Don't animate the bars shifting over because it looks weird
if (isBefore(prevEndDate.current, endDate)) {
prevEndDate.current = endDate;
return 0;
}
// Don't animate when data hasn't changed
const serialized = JSON.stringify(chartData);
if (prevData.current === serialized) {
return 0;
}
prevData.current = serialized;
return DEFAULT_ANIMATION_DURATION;
}, [endDate]);
}, [endDate, chartData]);
return (
<PageviewsChart

View file

@ -289,6 +289,7 @@
"label.websites": "المواقع",
"label.window": "النافذة",
"label.yesterday": "الأمس",
"label.behavior": "السلوك",
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "Сярэдняе",
"label.back": "Назад",
"label.before": "Да",
"label.behavior": "Паводзіны",
"label.boards": "Дошкі",
"label.bounce-rate": "Паказчык адмоваў",
"label.breakdown": "Разбіўка",

View file

@ -20,6 +20,7 @@
"label.average": "Средно",
"label.back": "Назад",
"label.before": "Преди",
"label.behavior": "Поведение",
"label.boards": "Дъски",
"label.bounce-rate": "Kоефициент на отказ",
"label.breakdown": "Разбивка",

View file

@ -20,6 +20,7 @@
"label.average": "গড়",
"label.back": "পেছনে",
"label.before": "পূর্বে",
"label.behavior": "আচরণ",
"label.boards": "বোর্ডসমূহ",
"label.bounce-rate": "উপরে উঠার হার",
"label.breakdown": "ভাঙ্গন",

View file

@ -20,6 +20,7 @@
"label.average": "Prosjek",
"label.back": "Nazad",
"label.before": "Prije",
"label.behavior": "Ponašanje",
"label.boards": "Ploče",
"label.bounce-rate": "Stopa napuštanja",
"label.breakdown": "Pregled po kategorijama",

View file

@ -20,6 +20,7 @@
"label.average": "Mitjana",
"label.back": "Enrere",
"label.before": "Abans",
"label.behavior": "Comportament",
"label.boards": "Taulers",
"label.bounce-rate": "Percentatge de rebot",
"label.breakdown": "Desglossament",

View file

@ -20,6 +20,7 @@
"label.average": "Průměr",
"label.back": "Zpět",
"label.before": "Před",
"label.behavior": "Chování",
"label.boards": "Nástěnky",
"label.bounce-rate": "Okamžité opuštění",
"label.breakdown": "Rozpis",

View file

@ -20,6 +20,7 @@
"label.average": "Gennemsnit",
"label.back": "Tilbage",
"label.before": "Før",
"label.behavior": "Adfærd",
"label.boards": "Tavler",
"label.bounce-rate": "Afvisningsprocent",
"label.breakdown": "Opdeling",

View file

@ -20,6 +20,7 @@
"label.average": "Durchschnitt",
"label.back": "Zrugg",
"label.before": "Vor",
"label.behavior": "Verhalte",
"label.boards": "Boards",
"label.bounce-rate": "Absprungsrate",
"label.breakdown": "Uufschlüsselig",

View file

@ -20,6 +20,7 @@
"label.average": "Durchschnitt",
"label.back": "Zurück",
"label.before": "Vor",
"label.behavior": "Verhalten",
"label.boards": "Boards",
"label.bounce-rate": "Absprungrate",
"label.breakdown": "Aufschlüsselung",

View file

@ -23,6 +23,7 @@
"label.boards": "Boards",
"label.bounce-rate": "Ποσοστό αναπήδησης",
"label.breakdown": "Breakdown",
"label.behavior": "Συμπεριφορά",
"label.browser": "Browser",
"label.browsers": "Προγράμματα περιήγησης",
"label.campaigns": "Campaigns",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "Back",
"label.before": "Before",
"label.behavior": "Behavior",
"label.boards": "Boards",
"label.bounce-rate": "Bounce rate",
"label.breakdown": "Breakdown",

View file

@ -289,6 +289,7 @@
"label.websites": "Websites",
"label.window": "Window",
"label.yesterday": "Yesterday",
"label.behavior": "Behavior",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.bad-request": "Bad request",

View file

@ -290,6 +290,7 @@
"label.websites": "Sitios web",
"label.window": "Ventana",
"label.yesterday": "Ayer",
"label.behavior": "Comportamiento",
"message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "میانگین",
"label.back": "بازگشت",
"label.before": "قبل از",
"label.behavior": "رفتار",
"label.boards": "بردها",
"label.bounce-rate": "نرخ ریزش",
"label.breakdown": "تفکیک",

View file

@ -289,6 +289,7 @@
"label.websites": "Verkkosivut",
"label.window": "Window",
"label.yesterday": "Yesterday",
"label.behavior": "Behavior",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
"message.bad-request": "Bad request",

View file

@ -20,6 +20,7 @@
"label.average": "Miðal",
"label.back": "Aftur",
"label.before": "Áðrenn",
"label.behavior": "Atferð",
"label.boards": "Borð",
"label.bounce-rate": "Bounce prosenttal",
"label.breakdown": "Sundurgreining",

View file

@ -289,6 +289,9 @@
"label.websites": "Sites",
"label.window": "Fenêtre",
"label.yesterday": "Hier",
"label.behavior": "Comportement",
"label.traffic": "Trafic",
"label.segments": "Segments",
"message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
"message.bad-request": "Bad request",
@ -315,13 +318,13 @@
"message.no-teams": "Vous n'avez pas créé d'équipe.",
"message.no-users": "Aucun utilisateur.",
"message.no-websites-configured": "Vous n'avez pas configuré de site.",
"message.not-found": "Not found",
"message.nothing-selected": "Nothing selected.",
"message.not-found": "Non trouvé!",
"message.nothing-selected": "Rien n'est sélectionné.",
"message.page-not-found": "Page non trouvée.",
"message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.",
"message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
"message.saved": "Enregistré.",
"message.sever-error": "Server error",
"message.sever-error": "Erreur serveur",
"message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :",
"message.team-already-member": "Vous êtes déjà membre de cette équipe.",
"message.team-not-found": "Équipe non trouvée.",
@ -331,7 +334,7 @@
"message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.",
"message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.",
"message.triggered-event": "Évènement déclenché",
"message.unauthorized": "Unauthorized",
"message.unauthorized": "Non authorisé!",
"message.user-deleted": "Utilisateur supprimé.",
"message.viewed-page": "Page vue",
"message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}"

View file

@ -289,6 +289,7 @@
"label.websites": "Sitios web",
"label.window": "Ventá",
"label.yesterday": "Onte",
"label.behavior": "Comportamento",
"message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.",
"message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
"message.bad-request": "Bad request",

View file

@ -278,6 +278,7 @@
"label.value": "Value",
"label.view": "View",
"label.view-details": "פרטים נוספים",
"label.behavior": "התנהגות",
"label.view-only": "View only",
"label.views": "צפיות",
"label.views-per-visit": "Views per visit",

View file

@ -20,6 +20,7 @@
"label.average": "औसत",
"label.back": "पीछे",
"label.before": "पहले",
"label.behavior": "व्यवहार",
"label.boards": "बोर्ड्स",
"label.bounce-rate": "उछाल दर",
"label.breakdown": "विभाजन",

View file

@ -20,6 +20,7 @@
"label.average": "Prosjek",
"label.back": "Natrag ",
"label.before": "Prije",
"label.behavior": "Ponašanje",
"label.boards": "Ploče",
"label.bounce-rate": "Stopa napuštanja",
"label.breakdown": "Raspad",

View file

@ -20,6 +20,7 @@
"label.average": "Átlag",
"label.back": "Vissza",
"label.before": "Előtt",
"label.behavior": "Viselkedés",
"label.boards": "Táblák",
"label.bounce-rate": "Visszafordulási arány",
"label.breakdown": "Bontás",

View file

@ -20,6 +20,7 @@
"label.average": "Rata-rata",
"label.back": "Kembali",
"label.before": "Sebelum",
"label.behavior": "Perilaku",
"label.boards": "Papan",
"label.bounce-rate": "Rasio pentalan",
"label.breakdown": "Rincian",

View file

@ -20,6 +20,7 @@
"label.average": "Media",
"label.back": "Indietro",
"label.before": "Prima",
"label.behavior": "Comportamento",
"label.boards": "Bacheche",
"label.bounce-rate": "Frequenza di rimbalzo",
"label.breakdown": "Dettaglio",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "戻る",
"label.before": "直前",
"label.behavior": "行動",
"label.boards": "ボード",
"label.bounce-rate": "直帰率",
"label.breakdown": "故障",

View file

@ -20,6 +20,7 @@
"label.average": "ជាមធ្យម",
"label.back": "ថយក្រោយ",
"label.before": "មុន",
"label.behavior": "អាកប្បកិរិយា",
"label.boards": "ក្តារ",
"label.bounce-rate": "ចំនួនវិលត្រឡប់",
"label.breakdown": "បំបែកលម្អិត",

View file

@ -20,6 +20,7 @@
"label.average": "평균",
"label.back": "뒤로",
"label.before": "이전",
"label.behavior": "행동",
"label.boards": "보드",
"label.bounce-rate": "이탈률",
"label.breakdown": "세부 사항",

View file

@ -20,6 +20,7 @@
"label.average": "Vidurkis",
"label.back": "Atgal",
"label.before": "Prieš",
"label.behavior": "Elgsena",
"label.boards": "Lentos",
"label.bounce-rate": "Atmetimo rodiklis",
"label.breakdown": "Išskaidymas",

View file

@ -20,6 +20,7 @@
"label.average": "Дундаж",
"label.back": "Буцах",
"label.before": "Өмнө",
"label.behavior": "Зан төлөв",
"label.boards": "Самбарууд",
"label.bounce-rate": "Нэг хуудас үзээд гарсан",
"label.breakdown": "Задаргаа",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "Kembali",
"label.before": "Before",
"label.behavior": "Behavior",
"label.boards": "Boards",
"label.bounce-rate": "Kadar lantunan",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "ပျမ်းမျှ",
"label.back": "နောက်သို့",
"label.before": "မတိုင်မီ",
"label.behavior": "အပြုအမူ",
"label.boards": "Boards",
"label.bounce-rate": "Bounce နှုန်း",
"label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု",

View file

@ -20,6 +20,7 @@
"label.average": "Gjennomsnnitt",
"label.back": "Tilbake",
"label.before": "Før",
"label.behavior": "Atferd",
"label.boards": "Tavler",
"label.bounce-rate": "Avvisningsfrekvens",
"label.breakdown": "Nedbrytning",

View file

@ -20,6 +20,7 @@
"label.average": "Gemiddelde",
"label.back": "Terug",
"label.before": "Voor",
"label.behavior": "Gedrag",
"label.boards": "Borden",
"label.bounce-rate": "Bouncepercentage",
"label.breakdown": "Opsplitsen",

View file

@ -20,6 +20,7 @@
"label.average": "Średnia",
"label.back": "Powrót",
"label.before": "Przed",
"label.behavior": "Zachowanie",
"label.boards": "Tablice",
"label.bounce-rate": "Współczynnik odrzuceń",
"label.breakdown": "Rozbicie",

View file

@ -20,6 +20,7 @@
"label.average": "Média",
"label.back": "Voltar",
"label.before": "Antes",
"label.behavior": "Comportamento",
"label.boards": "Quadros",
"label.bounce-rate": "Taxa de rejeição",
"label.breakdown": "Detalhamento",

View file

@ -20,6 +20,7 @@
"label.average": "Média",
"label.back": "Voltar",
"label.before": "Antes",
"label.behavior": "Comportamento",
"label.boards": "Quadros",
"label.bounce-rate": "Taxa de rejeição",
"label.breakdown": "Detalhamento",

View file

@ -20,6 +20,7 @@
"label.average": "Mediu",
"label.back": "Înapoi",
"label.before": "Înainte",
"label.behavior": "Comportament",
"label.boards": "Panouri",
"label.bounce-rate": "Rata de respingere",
"label.breakdown": "Detaliat",

View file

@ -20,6 +20,7 @@
"label.average": "Средний",
"label.back": "Назад",
"label.before": "До",
"label.behavior": "Поведение",
"label.boards": "Доски",
"label.bounce-rate": "Отказы",
"label.breakdown": "Авария",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "ආපසු",
"label.before": "Before",
"label.behavior": "අචරණය",
"label.boards": "Boards",
"label.bounce-rate": "Bounce rate",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "Priemer",
"label.back": "Späť",
"label.before": "Pred",
"label.behavior": "Správanie",
"label.boards": "Tabule",
"label.bounce-rate": "Okamžité opustenie",
"label.breakdown": "Rozpis",

View file

@ -20,6 +20,7 @@
"label.average": "Povprečno",
"label.back": "Nazaj",
"label.before": "Pred",
"label.behavior": "Obnašanje",
"label.boards": "Table",
"label.bounce-rate": "Odbojna stopnja",
"label.breakdown": "Razčlenitev",

View file

@ -20,6 +20,7 @@
"label.average": "Genomsnitt",
"label.back": "Tillbaka",
"label.before": "Före",
"label.behavior": "Beteende",
"label.boards": "Anslagstavlor",
"label.bounce-rate": "Avvisningsfrekvens",
"label.breakdown": "Analys",

View file

@ -21,6 +21,7 @@
"label.back": "பின்னால்",
"label.before": "Before",
"label.boards": "Boards",
"label.behavior": "நடத்தை",
"label.bounce-rate": "துள்ளல் விகிதம்",
"label.breakdown": "Breakdown",
"label.browser": "Browser",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "ย้อนกลับ",
"label.before": "Before",
"label.behavior": "พฤติกรรม",
"label.boards": "Boards",
"label.bounce-rate": "อัตราตีกลับ",
"label.breakdown": "Breakdown",

View file

@ -20,6 +20,7 @@
"label.average": "Ortalama",
"label.back": "Geri",
"label.before": "Önce",
"label.behavior": "Davranış",
"label.boards": "Panolar",
"label.bounce-rate": "Tek sayfa ziyaret oranı",
"label.breakdown": "Dağılım",

View file

@ -20,6 +20,7 @@
"label.average": "Середній",
"label.back": "Назад",
"label.before": "До",
"label.behavior": "Поведінка",
"label.boards": "Дошки",
"label.bounce-rate": "Показник відмов",
"label.breakdown": "Розподіл",

View file

@ -20,6 +20,7 @@
"label.average": "Average",
"label.back": "پیچھے",
"label.before": "Before",
"label.behavior": "رویے",
"label.boards": "Boards",
"label.bounce-rate": "اچھال کی شرح",
"label.breakdown": "Breakdown",

View file

@ -15,6 +15,7 @@
"label.average": "Oʻrtacha",
"label.back": "Orqaga",
"label.before": "Oldin",
"label.behavior": "Xulq-atvor",
"label.bounce-rate": "Chiqib ketish darajasi",
"label.breakdown": "Tahlil",
"label.browser": "Brauzer",

View file

@ -15,6 +15,7 @@
"label.average": "Trung bình",
"label.back": "Quay lại",
"label.before": "Trước đó",
"label.behavior": "Hành vi",
"label.bounce-rate": "Tỷ lệ thoát trang",
"label.breakdown": "Phân tích chi tiết",
"label.browser": "Trình duyệt",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "返回",
"label.before": "之前",
"label.behavior": "行为",
"label.boards": "看板",
"label.bounce-rate": "跳出率",
"label.breakdown": "故障",

View file

@ -20,6 +20,7 @@
"label.average": "平均",
"label.back": "返回",
"label.before": "之前",
"label.behavior": "行為",
"label.boards": "看板",
"label.bounce-rate": "跳出率",
"label.breakdown": "細項分析",

View file

@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { v4, v5 } from 'uuid';
import { v4, v5, v7 } from 'uuid';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
@ -57,7 +57,9 @@ export function secret() {
}
export function uuid(...args: any) {
if (!args.length) return v4();
if (args.length) {
return v5(hash(...args, secret()), v5.DNS);
}
return v5(hash(...args, secret()), v5.DNS);
return process.env.USE_UUIDV7 ? v7() : v4();
}

View file

@ -114,9 +114,9 @@ export async function getClientInfo(request: Request, payload: Record<string, an
const country = safeDecodeURIComponent(location?.country);
const region = safeDecodeURIComponent(location?.region);
const city = safeDecodeURIComponent(location?.city);
const browser = browserName(userAgent);
const os = detectOS(userAgent) as string;
const device = getDevice(userAgent, payload?.screen);
const browser = payload?.browser ?? browserName(userAgent);
const os = payload?.os ?? (detectOS(userAgent) as string);
const device = payload?.device ?? getDevice(userAgent, payload?.screen);
return { userAgent, browser, os, ip, country, region, city, device };
}

View file

@ -206,6 +206,10 @@ async function rawQuery(sql: string, data: Record<string, any>, name?: string):
return `$${params.length}${type ?? ''}`;
});
if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
return client.$replica().$queryRawUnsafe(query, ...params);
}
return client.$queryRawUnsafe(query, ...params);
}
@ -296,10 +300,6 @@ function getSchema() {
}
function getClient() {
if (!process.env.DATABASE_URL) {
return null;
}
const url = process.env.DATABASE_URL;
const replicaUrl = process.env.DATABASE_REPLICA_URL;
const logQuery = process.env.LOG_QUERY;
@ -307,43 +307,49 @@ function getClient() {
const connectionUrl = new URL(url);
const schema = connectionUrl.searchParams.get('schema') ?? undefined;
const adapter = new PrismaPg({ connectionString: url.toString() }, { schema });
const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
const prisma = new PrismaClient({
adapter,
const baseClient = new PrismaClient({
adapter: baseAdapter,
errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
if (replicaUrl) {
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl.toString() }, { schema });
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
prisma.$extends(
readReplicas({
replicas: [replicaClient],
}),
);
if (logQuery) {
baseClient.$on('query', log);
}
if (!replicaUrl) {
log('Prisma initialized');
globalThis[PRISMA] ??= baseClient;
return baseClient;
}
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
if (logQuery) {
prisma.$on('query' as never, log);
replicaClient.$on('query', log);
}
log('Prisma initialized');
const extended = baseClient.$extends(
readReplicas({
replicas: [replicaClient],
}),
);
if (!globalThis[PRISMA]) {
globalThis[PRISMA] = prisma;
}
log('Prisma initialized (with replica)');
globalThis[PRISMA] ??= extended;
return prisma;
return extended;
}
const client: PrismaClient = globalThis[PRISMA] || getClient();
const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>;
export default {
client,

View file

@ -137,6 +137,9 @@ export async function resetWebsite(websiteId: string) {
return transaction(
[
client.revenue.deleteMany({
where: { websiteId },
}),
client.eventData.deleteMany({
where: { websiteId },
}),
@ -175,35 +178,44 @@ export async function deleteWebsite(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.report.deleteMany({
where: {
websiteId,
},
}),
cloudMode
? client.website.update({
data: {
deletedAt: new Date(),
},
where: { id: websiteId },
})
: client.website.delete({
where: { id: websiteId },
}),
]).then(async data => {
return transaction(
[
client.revenue.deleteMany({
where: { websiteId },
}),
client.eventData.deleteMany({
where: { websiteId },
}),
client.sessionData.deleteMany({
where: { websiteId },
}),
client.websiteEvent.deleteMany({
where: { websiteId },
}),
client.session.deleteMany({
where: { websiteId },
}),
client.report.deleteMany({
where: { websiteId },
}),
client.segment.deleteMany({
where: { websiteId },
}),
cloudMode
? client.website.update({
data: {
deletedAt: new Date(),
},
where: { id: websiteId },
})
: client.website.delete({
where: { id: websiteId },
}),
],
{
timeout: 30000,
},
).then(async data => {
if (cloudMode) {
await redis.client.del(`website:${websiteId}`);
}