mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Fix multiple issues: export empty datasets, reset large data, geo-location tracking, and timezone consistency
This commit is contained in:
parent
6ba9c1c40c
commit
372d1d86f4
9 changed files with 441 additions and 80 deletions
|
|
@ -0,0 +1,60 @@
|
|||
import { GET } from '../route';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@/lib/request', () => ({
|
||||
getQueryFilters: jest.fn().mockResolvedValue({}),
|
||||
parseRequest: jest.fn().mockResolvedValue({
|
||||
auth: { userId: 'test-user' },
|
||||
query: {},
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/response', () => ({
|
||||
unauthorized: jest.fn().mockReturnValue({ status: 401, json: () => {} }),
|
||||
json: jest.fn().mockImplementation((data) => ({ status: 200, json: () => Promise.resolve(data) })),
|
||||
}));
|
||||
|
||||
jest.mock('@/permissions', () => ({
|
||||
canViewWebsite: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/sql', () => ({
|
||||
getEventMetrics: jest.fn().mockResolvedValue([]),
|
||||
getPageviewMetrics: jest.fn().mockResolvedValue([]),
|
||||
getSessionMetrics: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe('Export API Route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return error when no data is available', async () => {
|
||||
const request = new Request('http://localhost:3000');
|
||||
const params = Promise.resolve({ websiteId: 'test-website' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({ error: 'no_data' });
|
||||
});
|
||||
|
||||
it('should return zip data when data is available', async () => {
|
||||
// Mock some data being returned
|
||||
const mockData = [{ id: 1, name: 'Test' }];
|
||||
|
||||
require('@/queries/sql').getEventMetrics.mockResolvedValueOnce(mockData);
|
||||
require('@/queries/sql').getPageviewMetrics.mockResolvedValueOnce([]);
|
||||
require('@/queries/sql').getSessionMetrics.mockResolvedValueOnce([]);
|
||||
|
||||
const request = new Request('http://localhost:3000');
|
||||
const params = Promise.resolve({ websiteId: 'test-website' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toHaveProperty('zip');
|
||||
expect(typeof data.zip).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
@ -40,6 +40,21 @@ export async function GET(
|
|||
getSessionMetrics(websiteId, { type: 'country' }, filters),
|
||||
]);
|
||||
|
||||
// Check if all datasets are empty
|
||||
const hasData = [
|
||||
events,
|
||||
pages,
|
||||
referrers,
|
||||
browsers,
|
||||
os,
|
||||
devices,
|
||||
countries
|
||||
].some(dataset => dataset && dataset.length > 0);
|
||||
|
||||
if (!hasData) {
|
||||
return json({ error: 'no_data' });
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
const parse = (data: any) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, Tooltip, TooltipTrigger, LoadingButton } from '@umami/react-zen';
|
||||
import { Icon, Tooltip, TooltipTrigger, LoadingButton, useToast } from '@umami/react-zen';
|
||||
import { Download } from '@/components/icons';
|
||||
import { useMessages, useApi } from '@/components/hooks';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
|
@ -7,7 +7,8 @@ import { useDateParameters } from '@/components/hooks/useDateParameters';
|
|||
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
||||
|
||||
export function ExportButton({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const date = useDateParameters();
|
||||
const filters = useFilterParameters();
|
||||
|
|
@ -17,16 +18,29 @@ export function ExportButton({ websiteId }: { websiteId: string }) {
|
|||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { zip } = await get(`/websites/${websiteId}/export`, {
|
||||
try {
|
||||
const response = await get(`/websites/${websiteId}/export`, {
|
||||
...date,
|
||||
...filters,
|
||||
...searchParams,
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
await loadZip(zip);
|
||||
|
||||
// Check if there's an error indicating no data
|
||||
if (response.error === 'no_data') {
|
||||
toast(formatMessage(messages.noDataAvailable));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with export if there's data
|
||||
await loadZip(response.zip);
|
||||
} catch (error) {
|
||||
// Handle any other errors
|
||||
toast(formatMessage(messages.error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,28 +1,113 @@
|
|||
import { getIpAddress } from '../ip';
|
||||
import { getLocation } from '../detect';
|
||||
import isLocalhost from 'is-localhost-ip';
|
||||
import maxmind from 'maxmind';
|
||||
|
||||
const IP = '127.0.0.1';
|
||||
const BAD_IP = '127.127.127.127';
|
||||
// Mock the dependencies
|
||||
jest.mock('is-localhost-ip', () => jest.fn());
|
||||
jest.mock('maxmind', () => ({
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
test('getIpAddress: Custom header', () => {
|
||||
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
||||
|
||||
expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP);
|
||||
describe('getLocation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete global.maxmind;
|
||||
});
|
||||
|
||||
test('getIpAddress: CloudFlare header', () => {
|
||||
expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP);
|
||||
it('should return null for localhost IPs', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await getLocation('127.0.0.1', new Headers(), false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('getIpAddress: Standard header', () => {
|
||||
expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
|
||||
it('should return location data from provider headers', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('cf-ipcountry', 'KR');
|
||||
headers.set('cf-region-code', '11');
|
||||
headers.set('cf-ipcity', 'Seoul');
|
||||
|
||||
const result = await getLocation('1.2.3.4', headers, false);
|
||||
|
||||
expect(result).toEqual({
|
||||
country: 'KR',
|
||||
region: 'KR-11',
|
||||
city: 'Seoul',
|
||||
});
|
||||
});
|
||||
|
||||
test('getIpAddress: CloudFlare header is lower priority than standard header', () => {
|
||||
expect(getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP }))).toEqual(
|
||||
IP,
|
||||
);
|
||||
it('should return location data from MaxMind database', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const mockMaxmindDb = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
country: { iso_code: 'KR' },
|
||||
subdivisions: [{ iso_code: '11' }],
|
||||
city: { names: { en: 'Seoul' } },
|
||||
}),
|
||||
};
|
||||
|
||||
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
|
||||
|
||||
const result = await getLocation('1.2.3.4', new Headers(), false);
|
||||
|
||||
expect(result).toEqual({
|
||||
country: 'KR',
|
||||
region: 'KR-11',
|
||||
city: 'Seoul',
|
||||
});
|
||||
});
|
||||
|
||||
test('getIpAddress: No header', () => {
|
||||
expect(getIpAddress(new Headers())).toEqual(null);
|
||||
it('should try multiple sources for country code', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const mockMaxmindDb = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
registered_country: { iso_code: 'KR' },
|
||||
subdivisions: [{ iso_code: '11' }],
|
||||
city: { names: { en: 'Seoul' } },
|
||||
}),
|
||||
};
|
||||
|
||||
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
|
||||
|
||||
const result = await getLocation('1.2.3.4', new Headers(), false);
|
||||
|
||||
expect(result).toEqual({
|
||||
country: 'KR',
|
||||
region: 'KR-11',
|
||||
city: 'Seoul',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if no country code is available', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const mockMaxmindDb = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
// No country information
|
||||
subdivisions: [{ iso_code: '11' }],
|
||||
city: { names: { en: 'Seoul' } },
|
||||
}),
|
||||
};
|
||||
|
||||
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
|
||||
|
||||
const result = await getLocation('1.2.3.4', new Headers(), false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
(isLocalhost as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
(maxmind.open as jest.Mock).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await getLocation('1.2.3.4', new Headers(), false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -84,6 +84,7 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
|||
}
|
||||
|
||||
// Database lookup
|
||||
try {
|
||||
if (!globalThis[MAXMIND]) {
|
||||
const dir = path.join(process.cwd(), 'geo');
|
||||
|
||||
|
|
@ -92,13 +93,23 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
|||
);
|
||||
}
|
||||
|
||||
const result = globalThis[MAXMIND]?.get(stripPort(ip));
|
||||
// Strip port from IP address before lookup
|
||||
const cleanIp = stripPort(ip);
|
||||
const result = globalThis[MAXMIND]?.get(cleanIp);
|
||||
|
||||
if (result) {
|
||||
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
||||
// Try multiple sources for country code to ensure we get a value
|
||||
const country =
|
||||
result.country?.iso_code ||
|
||||
result.registered_country?.iso_code ||
|
||||
result.represented_country?.iso_code ||
|
||||
result.continent?.code;
|
||||
|
||||
const region = result.subdivisions?.[0]?.iso_code;
|
||||
const city = result.city?.names?.en;
|
||||
|
||||
// Only return location data if we have at least a country code
|
||||
if (country) {
|
||||
return {
|
||||
country,
|
||||
region: getRegionCode(country, region),
|
||||
|
|
@ -106,6 +117,14 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
|||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't crash the application
|
||||
console.error('Geo-location lookup failed:', error);
|
||||
}
|
||||
|
||||
// Return null if no location data could be determined
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getClientInfo(request: Request, payload: Record<string, any>) {
|
||||
const userAgent = payload?.userAgent || request.headers.get('user-agent');
|
||||
|
|
|
|||
109
src/queries/prisma/__tests__/website.test.ts
Normal file
109
src/queries/prisma/__tests__/website.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { resetWebsite } from '../website';
|
||||
|
||||
// Mock the prisma client
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
client: {
|
||||
eventData: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
sessionData: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
websiteEvent: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
session: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
website: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'test-website' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/redis', () => ({
|
||||
default: {
|
||||
client: {
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('resetWebsite', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should reset website data in batches to avoid transaction timeouts', async () => {
|
||||
const websiteId = 'test-website';
|
||||
|
||||
// Mock deleteMany to return 10000 records deleted on first call, then 0
|
||||
require('@/lib/prisma').default.client.eventData.deleteMany
|
||||
.mockResolvedValueOnce({ count: 10000 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.sessionData.deleteMany
|
||||
.mockResolvedValueOnce({ count: 10000 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.websiteEvent.deleteMany
|
||||
.mockResolvedValueOnce({ count: 10000 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.session.deleteMany
|
||||
.mockResolvedValueOnce({ count: 10000 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await resetWebsite(websiteId);
|
||||
|
||||
// Verify that deleteMany was called with the correct parameters
|
||||
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledWith({
|
||||
where: { websiteId },
|
||||
take: 10000,
|
||||
});
|
||||
|
||||
// Verify that the website update was called
|
||||
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
resetAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single batch deletion when data is small', async () => {
|
||||
const websiteId = 'test-website';
|
||||
|
||||
// Mock deleteMany to return 0 records deleted (no more data)
|
||||
require('@/lib/prisma').default.client.eventData.deleteMany
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.sessionData.deleteMany
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.websiteEvent.deleteMany
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
require('@/lib/prisma').default.client.session.deleteMany
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await resetWebsite(websiteId);
|
||||
|
||||
// Verify that deleteMany was called once for each model
|
||||
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(require('@/lib/prisma').default.client.sessionData.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(require('@/lib/prisma').default.client.websiteEvent.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(require('@/lib/prisma').default.client.session.deleteMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify that the website update was called
|
||||
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
resetAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -132,38 +132,41 @@ export async function updateWebsite(
|
|||
}
|
||||
|
||||
export async function resetWebsite(websiteId: string) {
|
||||
const { client, transaction } = prisma;
|
||||
const { client } = 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({
|
||||
// For large datasets, we need to delete data in chunks to avoid transaction timeouts
|
||||
// We'll delete data in batches of 10000 records at a time
|
||||
const deleteInBatches = async (model: any, where: any) => {
|
||||
let deletedCount;
|
||||
do {
|
||||
const result = await model.deleteMany({
|
||||
where,
|
||||
take: 10000, // Limit to 10000 records per batch
|
||||
});
|
||||
deletedCount = result.count;
|
||||
} while (deletedCount === 10000); // Continue until we delete less than 10000 records
|
||||
};
|
||||
|
||||
// Delete data in batches to avoid transaction timeouts
|
||||
await deleteInBatches(client.eventData, { websiteId });
|
||||
await deleteInBatches(client.sessionData, { websiteId });
|
||||
await deleteInBatches(client.websiteEvent, { websiteId });
|
||||
await deleteInBatches(client.session, { websiteId });
|
||||
|
||||
// Update the website reset timestamp
|
||||
const data = await client.website.update({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
resetAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]).then(async data => {
|
||||
});
|
||||
|
||||
if (cloudMode) {
|
||||
await redis.client.set(
|
||||
`website:${websiteId}`,
|
||||
data.find(website => website.id),
|
||||
);
|
||||
await redis.client.set(`website:${websiteId}`, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWebsite(websiteId: string) {
|
||||
|
|
|
|||
50
src/queries/sql/__tests__/getRealtimeData.test.ts
Normal file
50
src/queries/sql/__tests__/getRealtimeData.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { getRealtimeData } from '../getRealtimeData';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('../getRealtimeActivity', () => ({
|
||||
getRealtimeActivity: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('../pageviews/getPageviewStats', () => ({
|
||||
getPageviewStats: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('../sessions/getSessionStats', () => ({
|
||||
getSessionStats: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe('getRealtimeData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should pass timezone parameter to stats functions', async () => {
|
||||
const websiteId = 'test-website';
|
||||
const filters = { timezone: 'America/New_York' };
|
||||
|
||||
await getRealtimeData(websiteId, filters);
|
||||
|
||||
// Verify that getPageviewStats was called with the timezone
|
||||
expect(require('../pageviews/getPageviewStats').getPageviewStats)
|
||||
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
|
||||
|
||||
// Verify that getSessionStats was called with the timezone
|
||||
expect(require('../sessions/getSessionStats').getSessionStats)
|
||||
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
|
||||
});
|
||||
|
||||
it('should default to UTC timezone when not provided', async () => {
|
||||
const websiteId = 'test-website';
|
||||
const filters = {};
|
||||
|
||||
await getRealtimeData(websiteId, filters);
|
||||
|
||||
// Verify that getPageviewStats was called with default UTC timezone
|
||||
expect(require('../pageviews/getPageviewStats').getPageviewStats)
|
||||
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
|
||||
|
||||
// Verify that getSessionStats was called with default UTC timezone
|
||||
expect(require('../sessions/getSessionStats').getSessionStats)
|
||||
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
|
||||
});
|
||||
});
|
||||
|
|
@ -14,10 +14,16 @@ function increment(data: object, key: string) {
|
|||
}
|
||||
|
||||
export async function getRealtimeData(websiteId: string, filters: QueryFilters) {
|
||||
// Extract timezone from filters to ensure consistent timezone usage
|
||||
const { timezone = 'utc' } = filters;
|
||||
|
||||
// Pass timezone to the stats functions to ensure consistent time formatting
|
||||
const statsFilters = { ...filters, timezone };
|
||||
|
||||
const [activity, pageviews, sessions] = await Promise.all([
|
||||
getRealtimeActivity(websiteId, filters),
|
||||
getPageviewStats(websiteId, filters),
|
||||
getSessionStats(websiteId, filters),
|
||||
getPageviewStats(websiteId, statsFilters),
|
||||
getSessionStats(websiteId, statsFilters),
|
||||
]);
|
||||
|
||||
const uniques = new Set();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue