mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Merge branch 'dev' into jajaja
# Conflicts: # package.json # yarn.lock
This commit is contained in:
commit
c71e9b5707
11 changed files with 126 additions and 30 deletions
|
|
@ -4,6 +4,18 @@ export const config = {
|
||||||
matcher: '/:path*',
|
matcher: '/:path*',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const apiHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, DELETE, POST, PUT',
|
||||||
|
'Access-Control-Max-Age': process.env.CORS_MAX_AGE || '86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackerHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'public, max-age=86400, must-revalidate',
|
||||||
|
};
|
||||||
|
|
||||||
function customCollectEndpoint(req) {
|
function customCollectEndpoint(req) {
|
||||||
const collectEndpoint = process.env.COLLECT_API_ENDPOINT;
|
const collectEndpoint = process.env.COLLECT_API_ENDPOINT;
|
||||||
|
|
||||||
|
|
@ -13,7 +25,7 @@ function customCollectEndpoint(req) {
|
||||||
|
|
||||||
if (pathname.endsWith(collectEndpoint)) {
|
if (pathname.endsWith(collectEndpoint)) {
|
||||||
url.pathname = '/api/send';
|
url.pathname = '/api/send';
|
||||||
return NextResponse.rewrite(url);
|
return NextResponse.rewrite(url, { headers: apiHeaders });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +40,7 @@ function customScriptName(req) {
|
||||||
|
|
||||||
if (names.find(name => pathname.endsWith(name))) {
|
if (names.find(name => pathname.endsWith(name))) {
|
||||||
url.pathname = '/script.js';
|
url.pathname = '/script.js';
|
||||||
return NextResponse.rewrite(url);
|
return NextResponse.rewrite(url, { headers: trackerHeaders });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,7 @@ export default {
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|tsx)$': 'ts-jest',
|
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -27,19 +27,19 @@ export function SessionActivity({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.timeline}>
|
<div className={styles.timeline}>
|
||||||
{data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => {
|
{data.map(({ id, createdAt, urlPath, eventName, visitId }) => {
|
||||||
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
||||||
lastDay = createdAt;
|
lastDay = createdAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={eventId}>
|
<Fragment key={id}>
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className={styles.header}>{formatTimezoneDate(createdAt, 'PPPP')}</div>
|
<div className={styles.header}>{formatTimezoneDate(createdAt, 'PPPP')}</div>
|
||||||
)}
|
)}
|
||||||
<div key={eventId} className={styles.row}>
|
<div className={styles.row}>
|
||||||
<div className={styles.time}>
|
<div className={styles.time}>
|
||||||
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
||||||
{formatTimezoneDate(createdAt, 'h:mm:ss aaa')}
|
{formatTimezoneDate(createdAt, 'pp')}
|
||||||
</StatusLight>
|
</StatusLight>
|
||||||
</div>
|
</div>
|
||||||
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json } from '@/lib/response';
|
import { json } from '@/lib/response';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { auth, error } = await parseRequest(request);
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,6 @@ const schema = z.object({
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// Bot check
|
|
||||||
if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
|
|
||||||
return json({ beep: 'boop' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -86,6 +81,11 @@ export async function POST(request: Request) {
|
||||||
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
|
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
|
||||||
await getClientInfo(request, payload);
|
await getClientInfo(request, payload);
|
||||||
|
|
||||||
|
// Bot check
|
||||||
|
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
||||||
|
return json({ beep: 'boop' });
|
||||||
|
}
|
||||||
|
|
||||||
// IP block
|
// IP block
|
||||||
if (hasBlockedIp(ip)) {
|
if (hasBlockedIp(ip)) {
|
||||||
return forbidden();
|
return forbidden();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const formats = {
|
||||||
millisecond: 'T',
|
millisecond: 'T',
|
||||||
second: 'pp',
|
second: 'pp',
|
||||||
minute: 'p',
|
minute: 'p',
|
||||||
hour: 'h:mm aaa - PP',
|
hour: 'p - PP',
|
||||||
day: 'PPPP',
|
day: 'PPPP',
|
||||||
week: 'PPPP',
|
week: 'PPPP',
|
||||||
month: 'LLLL yyyy',
|
month: 'LLLL yyyy',
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,24 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDomain = (x: string) => {
|
||||||
|
for (const { domain, match } of GROUPED_DOMAINS) {
|
||||||
|
if (Array.isArray(match) ? match.some(str => x.includes(str)) : x.includes(match)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '_other';
|
||||||
|
};
|
||||||
|
|
||||||
const groupedFilter = (data: any[]) => {
|
const groupedFilter = (data: any[]) => {
|
||||||
const groups = { _other: 0 };
|
const groups = { _other: 0 };
|
||||||
|
|
||||||
for (const { x, y } of data) {
|
for (const { x, y } of data) {
|
||||||
for (const { domain, match } of GROUPED_DOMAINS) {
|
const domain = getDomain(x);
|
||||||
if (Array.isArray(match) ? match.some(str => x.includes(str)) : x.includes(match)) {
|
if (!groups[domain]) {
|
||||||
if (!groups[domain]) {
|
groups[domain] = 0;
|
||||||
groups[domain] = 0;
|
|
||||||
}
|
|
||||||
groups[domain] += +y;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
groups._other += +y;
|
groups[domain] += +y;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(groups)
|
return Object.keys(groups)
|
||||||
|
|
|
||||||
81
src/lib/__tests__/charts.test.ts
Normal file
81
src/lib/__tests__/charts.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { renderNumberLabels, renderDateLabels } from '../charts';
|
||||||
|
import { formatDate } from '../date';
|
||||||
|
|
||||||
|
// test for renderNumberLabels
|
||||||
|
|
||||||
|
describe('renderNumberLabels', () => {
|
||||||
|
test.each([
|
||||||
|
['1000000', '1.0m'],
|
||||||
|
['2500000', '2.5m'],
|
||||||
|
])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['12500', '12.5k']])(
|
||||||
|
"formats numbers ≥ 10K as 'X.Xk' (%s → %s)",
|
||||||
|
(input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['999', '999']])(
|
||||||
|
'calls formatNumber for values < 1000 (%s → %s)',
|
||||||
|
(input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['0', '0'],
|
||||||
|
['-5000', '-5000'],
|
||||||
|
])('handles edge cases correctly (%s → %s)', (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderDateLabels', () => {
|
||||||
|
const mockValues = [{ value: '2024-03-23T10:00:00Z' }, { value: '2024-03-24T15:30:00Z' }];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
jest.spyOn(require('@/lib/date'), 'formatDate');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks(); // Reset spy to prevent interference
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['minute', 'h:mm', 'en-US'],
|
||||||
|
['hour', 'p', 'en-US'],
|
||||||
|
['day', 'MMM d', 'en-US'],
|
||||||
|
['month', 'MMM', 'en-US'],
|
||||||
|
['year', 'yyyy', 'en-US'],
|
||||||
|
])('formats date correctly for unit: %s', (unit, expectedFormat, locale) => {
|
||||||
|
const formatLabel = renderDateLabels(unit, locale);
|
||||||
|
const formatted = formatLabel('label', 0, mockValues);
|
||||||
|
|
||||||
|
expect(formatDate).toHaveBeenCalledWith(new Date(mockValues[0].value), expectedFormat, locale);
|
||||||
|
expect(formatted).toBe(formatDate(new Date(mockValues[0].value), expectedFormat, locale));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns label for unknown unit', () => {
|
||||||
|
const formatLabel = renderDateLabels('unknown', 'en-US');
|
||||||
|
expect(formatLabel('original-label', 0, mockValues)).toBe('original-label');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws error for invalid date input', () => {
|
||||||
|
const invalidValues = [{ value: 'invalid-date' }];
|
||||||
|
const formatLabel = renderDateLabels('day', 'en-US');
|
||||||
|
|
||||||
|
expect(() => formatLabel('label', 0, invalidValues)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,15 +11,15 @@ export function renderDateLabels(unit: string, locale: string) {
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 'minute':
|
case 'minute':
|
||||||
return formatDate(d, 'h:mm', locale);
|
return formatDate(d, 'p', locale).split(' ')[0];
|
||||||
case 'hour':
|
case 'hour':
|
||||||
return formatDate(d, 'p', locale);
|
return formatDate(d, 'p', locale);
|
||||||
case 'day':
|
case 'day':
|
||||||
return formatDate(d, 'MMM d', locale);
|
return formatDate(d, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year
|
||||||
case 'month':
|
case 'month':
|
||||||
return formatDate(d, 'MMM', locale);
|
return formatDate(d, 'MMM', locale);
|
||||||
case 'year':
|
case 'year':
|
||||||
return formatDate(d, 'YYY', locale);
|
return formatDate(d, 'yyyy', locale);
|
||||||
default:
|
default:
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"incremental": false,
|
"incremental": false,
|
||||||
"types": ["jest"],
|
"types": ["jest"],
|
||||||
"typeRoots": ["node_modules/@types"],
|
"typeRoots": ["node_modules/@types"],
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue