mirror of
https://github.com/umami-software/umami.git
synced 2026-02-20 04:25:39 +01:00
Merge branch 'master' into feat/events-page-filters
This commit is contained in:
commit
737a323c91
113 changed files with 4827 additions and 3380 deletions
|
|
@ -22,10 +22,6 @@ export function App({ children }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (config.uiDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ export function UpdateNotice({ user, config }) {
|
|||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||
const pathname = usePathname();
|
||||
const [dismissed, setDismissed] = useState(checked);
|
||||
|
||||
const allowUpdate =
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
user?.isAdmin &&
|
||||
!config?.updatesDisabled &&
|
||||
!config?.privateMode &&
|
||||
!pathname.includes('/share/') &&
|
||||
!process.env.cloudMode &&
|
||||
!process.env.privateMode &&
|
||||
!dismissed;
|
||||
|
||||
const updateCheck = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export function LanguageSetting() {
|
|||
const [search, setSearch] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale, saveLocale } = useLocale();
|
||||
|
||||
const options = search
|
||||
? Object.keys(languages).filter(n => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useContext } from 'react';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './ReportBody.module.css';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
|
||||
export function ReportBody({ children }) {
|
||||
const { report } = useContext(ReportContext);
|
||||
|
|
@ -9,7 +10,14 @@ export function ReportBody({ children }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.body}>{children}</div>;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{report.type !== 'revenue' && report.type !== 'attribution' && (
|
||||
<DownloadButton filename={report.name} data={report.data} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportBody;
|
||||
|
|
|
|||
|
|
@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
|
|||
|
||||
const ReportComponent = reports[report.type];
|
||||
|
||||
if (!ReportComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ReportComponent reportId={reportId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,31 @@ function toArray(data: { [key: string]: number } = {}) {
|
|||
.sort(firstBy('value', -1));
|
||||
}
|
||||
|
||||
function parseParameters(data: any[]) {
|
||||
return data.reduce((obj, { url_query, num }) => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams(url_query);
|
||||
|
||||
for (const [key, value] of searchParams) {
|
||||
if (key.match(/^utm_(\w+)$/)) {
|
||||
const name = value;
|
||||
if (!obj[key]) {
|
||||
obj[key] = { [name]: Number(num) };
|
||||
} else if (!obj[key][name]) {
|
||||
obj[key][name] = Number(num);
|
||||
} else {
|
||||
obj[key][name] += Number(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export default function UTMView() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { report } = useContext(ReportContext);
|
||||
|
|
@ -27,7 +52,7 @@ export default function UTMView() {
|
|||
return (
|
||||
<div>
|
||||
{UTM_PARAMS.map(param => {
|
||||
const items = toArray(data[param]);
|
||||
const items = toArray(parseParameters(data)[param]);
|
||||
const chartData = {
|
||||
labels: items.map(({ name }) => name),
|
||||
datasets: [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import WebsiteExpandedView from './WebsiteExpandedView';
|
|||
import WebsiteHeader from './WebsiteHeader';
|
||||
import WebsiteMetricsBar from './WebsiteMetricsBar';
|
||||
import WebsiteTableView from './WebsiteTableView';
|
||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
||||
import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
|
||||
|
||||
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
||||
const pathname = usePathname();
|
||||
|
|
@ -17,7 +17,7 @@ export default function WebsiteDetailsPage({ websiteId }: { websiteId: string })
|
|||
const { view } = query;
|
||||
|
||||
const params = Object.keys(query).reduce((obj, key) => {
|
||||
if (FILTER_COLUMNS[key]) {
|
||||
if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
|
||||
obj[key] = query[key];
|
||||
}
|
||||
return obj;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Dropdown, Item } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import { useDateRange, useMessages, useSticky } from '@/components/hooks';
|
||||
import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks';
|
||||
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
|
||||
import MetricCard from '@/components/metrics/MetricCard';
|
||||
import MetricsBar from '@/components/metrics/MetricsBar';
|
||||
|
|
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
|
|||
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
|
||||
import useStore, { setWebsiteDateCompare } from '@/store/websites';
|
||||
import WebsiteFilterButton from './WebsiteFilterButton';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
import styles from './WebsiteMetricsBar.module.css';
|
||||
|
||||
export function WebsiteMetricsBar({
|
||||
|
|
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
|
|||
websiteId,
|
||||
compareMode && dateCompare,
|
||||
);
|
||||
const {
|
||||
query: { view },
|
||||
} = useNavigation();
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
|
||||
|
|
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
|
|||
</MetricsBar>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
<div>
|
||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||
{!view && <ExportButton websiteId={websiteId} />}
|
||||
</div>
|
||||
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
|
||||
{compareMode && (
|
||||
<div className={styles.vs}>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
|||
const pathname = usePathname();
|
||||
const tableProps = {
|
||||
websiteId,
|
||||
allowDownload: false,
|
||||
limit: 10,
|
||||
};
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import WebsiteHeader from '../WebsiteHeader';
|
|||
import WebsiteMetricsBar from '../WebsiteMetricsBar';
|
||||
import FilterTags from '@/components/metrics/FilterTags';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
||||
import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
|
||||
import WebsiteChart from '../WebsiteChart';
|
||||
import WebsiteCompareTables from './WebsiteCompareTables';
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ export function WebsiteComparePage({ websiteId }) {
|
|||
const { query } = useNavigation();
|
||||
|
||||
const params = Object.keys(query).reduce((obj, key) => {
|
||||
if (FILTER_COLUMNS[key]) {
|
||||
if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
|
||||
obj[key] = query[key];
|
||||
}
|
||||
return obj;
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@
|
|||
color: var(--primary400);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { GridColumn, GridTable } from 'react-basics';
|
||||
import { useMemo } from 'react';
|
||||
import { GridColumn, GridTable, Flexbox, Button, ButtonGroup, Loading } from 'react-basics';
|
||||
import { useEventDataProperties, useEventDataValues, useMessages } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import PieChart from '@/components/charts/PieChart';
|
||||
import ListTable from '@/components/metrics/ListTable';
|
||||
import { useState } from 'react';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import styles from './EventProperties.module.css';
|
||||
|
|
@ -9,22 +11,38 @@ import styles from './EventProperties.module.css';
|
|||
export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [propertyView, setPropertyView] = useState('table');
|
||||
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId);
|
||||
const { data: values } = useEventDataValues(websiteId, eventName, propertyName);
|
||||
const chartData =
|
||||
propertyName && values
|
||||
? {
|
||||
labels: values.map(({ value }) => value),
|
||||
datasets: [
|
||||
{
|
||||
data: values.map(({ total }) => total),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const propertySum = useMemo(() => {
|
||||
return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
|
||||
}, [values]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!propertyName || !values) return null;
|
||||
return {
|
||||
labels: values.map(({ value }) => value),
|
||||
datasets: [
|
||||
{
|
||||
data: values.map(({ total }) => total),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [propertyName, values]);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!propertyName || !values || propertySum === 0) return [];
|
||||
return values.map(({ value, total }) => ({
|
||||
x: value,
|
||||
y: total,
|
||||
z: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [propertyName, values, propertySum]);
|
||||
|
||||
const handleRowClick = row => {
|
||||
setEventName(row.eventName);
|
||||
|
|
@ -52,9 +70,25 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
|||
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
|
||||
</GridTable>
|
||||
{propertyName && (
|
||||
<div className={styles.chart}>
|
||||
<div className={styles.title}>{propertyName}</div>
|
||||
<PieChart key={propertyName + eventName} type="doughnut" data={chartData} />
|
||||
<div className={styles.data}>
|
||||
<Flexbox className={styles.header} gap={12} justifyContent="space-between">
|
||||
<div className={styles.title}>{`${eventName}: ${propertyName}`}</div>
|
||||
<ButtonGroup
|
||||
selectedKey={propertyView}
|
||||
onSelect={key => setPropertyView(key as string)}
|
||||
>
|
||||
<Button key="table">{formatMessage(labels.table)}</Button>
|
||||
<Button key="chart">{formatMessage(labels.chart)}</Button>
|
||||
</ButtonGroup>
|
||||
</Flexbox>
|
||||
|
||||
{!values ? (
|
||||
<Loading icon="dots" />
|
||||
) : propertyView === 'table' ? (
|
||||
<ListTable data={tableData} />
|
||||
) : (
|
||||
<PieChart key={propertyName + eventName} type="doughnut" data={chartData} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ import { Item, Tabs } from 'react-basics';
|
|||
import { useState } from 'react';
|
||||
import EventProperties from './EventProperties';
|
||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
|
||||
export default function EventsPage({ websiteId }) {
|
||||
const [label, setLabel] = useState(null);
|
||||
const [tab, setTab] = useState('activity');
|
||||
const [tab, setTab] = useState(getItem('eventTab') || 'activity');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { query } = useNavigation();
|
||||
|
||||
|
|
@ -28,6 +29,10 @@ export default function EventsPage({ websiteId }) {
|
|||
}
|
||||
return obj;
|
||||
}, {});
|
||||
const onSelect = (value: 'activity' | 'properties') => {
|
||||
setItem('eventTab', value);
|
||||
setTab(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -45,11 +50,7 @@ export default function EventsPage({ websiteId }) {
|
|||
/>
|
||||
</GridRow>
|
||||
<div>
|
||||
<Tabs
|
||||
selectedKey={tab}
|
||||
onSelect={(value: any) => setTab(value)}
|
||||
style={{ marginBottom: 30 }}
|
||||
>
|
||||
<Tabs selectedKey={tab} onSelect={onSelect} style={{ marginBottom: 30 }}>
|
||||
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
||||
<Item key="properties">{formatMessage(labels.properties)}</Item>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
'use server';
|
||||
|
||||
export async function getConfig() {
|
||||
export type Config = {
|
||||
faviconUrl: string | undefined;
|
||||
privateMode: boolean;
|
||||
telemetryDisabled: boolean;
|
||||
trackerScriptName: string | undefined;
|
||||
updatesDisabled: boolean;
|
||||
};
|
||||
|
||||
export async function getConfig(): Promise<Config> {
|
||||
return {
|
||||
faviconUrl: process.env.FAVICON_URL,
|
||||
privateMode: !!process.env.PRIVATE_MODE,
|
||||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||
uiDisabled: !!process.env.DISABLE_UI,
|
||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
report.parameters = JSON.parse(report.parameters);
|
||||
|
||||
return json(report);
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +60,7 @@ export async function POST(
|
|||
type,
|
||||
name,
|
||||
description,
|
||||
parameters: JSON.stringify(parameters),
|
||||
parameters: parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export async function POST(request: Request) {
|
|||
type,
|
||||
name,
|
||||
description,
|
||||
parameters: JSON.stringify(parameters),
|
||||
parameters: parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
|
|
|
|||
|
|
@ -2,25 +2,25 @@ import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
|
|||
|
||||
export async function GET() {
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
process.env.DISABLE_TELEMETRY &&
|
||||
process.env.NODE_ENV !== 'production' ||
|
||||
process.env.DISABLE_TELEMETRY ||
|
||||
process.env.PRIVATE_MODE
|
||||
) {
|
||||
const script = `
|
||||
(()=>{const i=document.createElement('img');
|
||||
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
|
||||
i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
|
||||
document.body.appendChild(i);})();
|
||||
`;
|
||||
|
||||
return new Response(script.replace(/\s\s+/g, ''), {
|
||||
return new Response('/* telemetry disabled */', {
|
||||
headers: {
|
||||
'content-type': 'text/javascript',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('/* telemetry disabled */', {
|
||||
const script = `
|
||||
(()=>{const i=document.createElement('img');
|
||||
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
|
||||
i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
|
||||
document.body.appendChild(i);})();
|
||||
`;
|
||||
|
||||
return new Response(script.replace(/\s\s+/g, ''), {
|
||||
headers: {
|
||||
'content-type': 'text/javascript',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { startOfHour, startOfMonth } from 'date-fns';
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
||||
import { fetchSession, fetchWebsite } from '@/lib/load';
|
||||
import { fetchWebsite } from '@/lib/load';
|
||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||
import { createToken, parseToken } from '@/lib/jwt';
|
||||
import { secret, uuid, hash } from '@/lib/crypto';
|
||||
|
|
@ -103,32 +103,24 @@ export async function POST(request: Request) {
|
|||
|
||||
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
|
||||
|
||||
// Find session
|
||||
// Create a session if not found
|
||||
if (!clickhouse.enabled && !cache?.sessionId) {
|
||||
const session = await fetchSession(websiteId, sessionId);
|
||||
|
||||
// Create a session if not found
|
||||
if (!session) {
|
||||
try {
|
||||
await createSession({
|
||||
id: sessionId,
|
||||
websiteId,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
distinctId: id,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!e.message.toLowerCase().includes('unique constraint')) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
await createSession(
|
||||
{
|
||||
id: sessionId,
|
||||
websiteId,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
distinctId: id,
|
||||
},
|
||||
{ skipDuplicates: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Visit info
|
||||
|
|
@ -145,7 +137,8 @@ export async function POST(request: Request) {
|
|||
const base = hostname ? `https://${hostname}` : 'https://localhost';
|
||||
const currentUrl = new URL(url, base);
|
||||
|
||||
let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname;
|
||||
let urlPath =
|
||||
currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
|
||||
const urlQuery = currentUrl.search.substring(1);
|
||||
const urlDomain = currentUrl.hostname.replace(/^www./, '');
|
||||
|
||||
|
|
@ -169,7 +162,7 @@ export async function POST(request: Request) {
|
|||
const twclid = currentUrl.searchParams.get('twclid');
|
||||
|
||||
if (process.env.REMOVE_TRAILING_SLASH) {
|
||||
urlPath = urlPath.replace(/(.+)\/$/, '$1');
|
||||
urlPath = urlPath.replace(/\/(?=(#.*)?$)/, '');
|
||||
}
|
||||
|
||||
if (referrer) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/requ
|
|||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
||||
import { getEventMetrics } from '@/queries';
|
||||
import { getEventStats } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
|
|
@ -32,14 +32,14 @@ export async function GET(
|
|||
}
|
||||
|
||||
const filters = {
|
||||
...getRequestFilters(query),
|
||||
...(await getRequestFilters(query)),
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
};
|
||||
|
||||
const data = await getEventMetrics(websiteId, filters);
|
||||
const data = await getEventStats(websiteId, filters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
|
|||
73
src/app/api/websites/[websiteId]/export/route.ts
Normal file
73
src/app/api/websites/[websiteId]/export/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { z } from 'zod';
|
||||
import JSZip from 'jszip';
|
||||
import Papa from 'papaparse';
|
||||
import { getRequestFilters, parseRequest } from '@/lib/request';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { pagingParams } from '@/lib/schema';
|
||||
import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().int(),
|
||||
endAt: z.coerce.number().int(),
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { startAt, endAt } = query;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const startDate = new Date(+startAt);
|
||||
const endDate = new Date(+endAt);
|
||||
|
||||
const filters = {
|
||||
...(await getRequestFilters(query)),
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||
getEventMetrics(websiteId, 'event', filters),
|
||||
getPageviewMetrics(websiteId, 'url', filters),
|
||||
getPageviewMetrics(websiteId, 'referrer', filters),
|
||||
getSessionMetrics(websiteId, 'browser', filters),
|
||||
getSessionMetrics(websiteId, 'os', filters),
|
||||
getSessionMetrics(websiteId, 'device', filters),
|
||||
getSessionMetrics(websiteId, 'country', filters),
|
||||
]);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
const parse = (data: any) => {
|
||||
return Papa.unparse(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
};
|
||||
|
||||
zip.file('events.csv', parse(events));
|
||||
zip.file('pages.csv', parse(pages));
|
||||
zip.file('referrers.csv', parse(referrers));
|
||||
zip.file('browsers.csv', parse(browsers));
|
||||
zip.file('os.csv', parse(os));
|
||||
zip.file('devices.csv', parse(devices));
|
||||
zip.file('countries.csv', parse(countries));
|
||||
|
||||
const content = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const base64 = content.toString('base64');
|
||||
|
||||
return json({ zip: base64 });
|
||||
}
|
||||
|
|
@ -15,7 +15,12 @@ import {
|
|||
} from '@/lib/constants';
|
||||
import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized, badRequest } from '@/lib/response';
|
||||
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
|
||||
import {
|
||||
getPageviewMetrics,
|
||||
getSessionMetrics,
|
||||
getEventMetrics,
|
||||
getChannelMetrics,
|
||||
} from '@/queries';
|
||||
import { filterParams } from '@/lib/schema';
|
||||
|
||||
export async function GET(
|
||||
|
|
@ -48,7 +53,7 @@ export async function GET(
|
|||
const { startDate, endDate } = await getRequestDateRange(query);
|
||||
const column = FILTER_COLUMNS[type] || type;
|
||||
const filters = {
|
||||
...getRequestFilters(query),
|
||||
...(await getRequestFilters(query)),
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
|
@ -85,7 +90,13 @@ export async function GET(
|
|||
}
|
||||
|
||||
if (EVENT_COLUMNS.includes(type)) {
|
||||
const data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
|
||||
let data;
|
||||
|
||||
if (type === 'event') {
|
||||
data = await getEventMetrics(websiteId, type, filters, limit, offset);
|
||||
} else {
|
||||
data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
|
||||
}
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export async function GET(
|
|||
const { startDate, endDate, unit } = await getRequestDateRange(query);
|
||||
|
||||
const filters = {
|
||||
...getRequestFilters(query),
|
||||
...(await getRequestFilters(query)),
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/lib/auth';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, ok, unauthorized } from '@/lib/response';
|
||||
import { segmentTypeParam } from '@/lib/schema';
|
||||
import { deleteSegment, getSegment, updateSegment } from '@/queries';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, segmentId } = await params;
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
return json(segment);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
type: segmentTypeParam,
|
||||
name: z.string().max(200),
|
||||
parameters: z.object({}).passthrough(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, segmentId } = await params;
|
||||
const { type, name, parameters } = body;
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const result = await updateSegment(segmentId, {
|
||||
type,
|
||||
name,
|
||||
parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, segmentId } = await params;
|
||||
|
||||
const segment = await getSegment(segmentId);
|
||||
|
||||
if (!segment) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!(await canDeleteWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
await deleteSegment(segmentId);
|
||||
|
||||
return ok();
|
||||
}
|
||||
67
src/app/api/websites/[websiteId]/segments/route.ts
Normal file
67
src/app/api/websites/[websiteId]/segments/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { canUpdateWebsite, canViewWebsite } from '@/lib/auth';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { segmentTypeParam } from '@/lib/schema';
|
||||
import { createSegment, getWebsiteSegments } from '@/queries';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
type: segmentTypeParam,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { type } = query;
|
||||
|
||||
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const segments = await getWebsiteSegments(websiteId, type);
|
||||
|
||||
return json(segments);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
type: segmentTypeParam,
|
||||
name: z.string().max(200),
|
||||
parameters: z.object({}).passthrough(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { type, name, parameters } = body;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const result = await createSegment({
|
||||
id: uuid(),
|
||||
websiteId,
|
||||
type,
|
||||
name,
|
||||
parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
|||
|
||||
const { startDate, endDate } = await getRequestDateRange(query);
|
||||
|
||||
const filters = getRequestFilters(query);
|
||||
const filters = await getRequestFilters(query);
|
||||
|
||||
const metrics = await getWebsiteSessionStats(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
|||
endDate,
|
||||
);
|
||||
|
||||
const filters = getRequestFilters(query);
|
||||
const filters = await getRequestFilters(query);
|
||||
|
||||
const metrics = await getWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { getValues } from '@/queries';
|
||||
import { parseRequest, getRequestDateRange } from '@/lib/request';
|
||||
import { EVENT_COLUMNS, FILTER_COLUMNS, FILTER_GROUPS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { getWebsiteSegments, getValues } from '@/queries';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
|
|
@ -30,11 +30,17 @@ export async function GET(
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) {
|
||||
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) {
|
||||
return badRequest('Invalid type.');
|
||||
}
|
||||
|
||||
const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
|
||||
let values;
|
||||
|
||||
if (FILTER_GROUPS[type]) {
|
||||
values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name }));
|
||||
} else {
|
||||
values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
|
||||
}
|
||||
|
||||
return json(values.filter(n => n).sort());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export async function POST(request: Request) {
|
|||
domain: z.string().max(500),
|
||||
shareId: z.string().max(50).nullable().optional(),
|
||||
teamId: z.string().nullable().optional(),
|
||||
id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
@ -34,14 +35,14 @@ export async function POST(request: Request) {
|
|||
return error();
|
||||
}
|
||||
|
||||
const { name, domain, shareId, teamId } = body;
|
||||
const { id, name, domain, shareId, teamId } = body;
|
||||
|
||||
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
id: uuid(),
|
||||
id: id ?? uuid(),
|
||||
createdBy: auth.user.id,
|
||||
name,
|
||||
domain,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import '@/styles/index.css';
|
|||
import '@/styles/variables.css';
|
||||
|
||||
export default function ({ children }) {
|
||||
if (process.env.DISABLE_UI) {
|
||||
return (
|
||||
<html>
|
||||
<body></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" data-scroll="0">
|
||||
<head>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import LoginForm from './LoginForm';
|
|||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
if (process.env.disableLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<LoginForm />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { Metadata } from 'next';
|
|||
import LoginPage from './LoginPage';
|
||||
|
||||
export default async function () {
|
||||
if (process.env.DISABLE_LOGIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { setUser } from '@/store/app';
|
|||
import { removeClientAuthToken } from '@/lib/client';
|
||||
|
||||
export function LogoutPage() {
|
||||
const disabled = !!(process.env.disableLogin || process.env.cloudMode);
|
||||
const router = useRouter();
|
||||
const { post } = useApi();
|
||||
const disabled = process.env.cloudMode;
|
||||
|
||||
useEffect(() => {
|
||||
async function logout() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import LogoutPage from './LogoutPage';
|
|||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
if (process.env.DISABLE_LOGIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LogoutPage />;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue