analytics page
This commit is contained in:
@@ -135,3 +135,29 @@ export interface PostWithTags {
|
|||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
last_edited_date?: string | null;
|
last_edited_date?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VisitorAnalytics {
|
||||||
|
id: string;
|
||||||
|
user_id?: string | null;
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
referrer?: string | null;
|
||||||
|
user_agent?: string | null;
|
||||||
|
ip_address?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
device_type?: string | null;
|
||||||
|
browser?: string | null;
|
||||||
|
os?: string | null;
|
||||||
|
session_id?: string | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsQuery {
|
||||||
|
userId?: string;
|
||||||
|
path?: string;
|
||||||
|
startDate?: Date | string;
|
||||||
|
endDate?: Date | string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|||||||
498
src/routes/analytics.tsx
Normal file
498
src/routes/analytics.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { createSignal, Show, For, createEffect, ErrorBoundary } from "solid-js";
|
||||||
|
import { Title } from "@solidjs/meta";
|
||||||
|
import { redirect, query, createAsync, useNavigate } from "@solidjs/router";
|
||||||
|
import { getEvent } from "vinxi/http";
|
||||||
|
import { api } from "~/lib/api";
|
||||||
|
|
||||||
|
const checkAdmin = query(async (): Promise<boolean> => {
|
||||||
|
"use server";
|
||||||
|
const { getUserID } = await import("~/server/auth");
|
||||||
|
const { env } = await import("~/env/server");
|
||||||
|
const event = getEvent()!;
|
||||||
|
const userId = await getUserID(event);
|
||||||
|
|
||||||
|
if (!userId || userId !== env.ADMIN_ID) {
|
||||||
|
throw redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, "checkAdminAccess");
|
||||||
|
|
||||||
|
export const route = {
|
||||||
|
load: () => checkAdmin()
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PerformanceTarget {
|
||||||
|
good: number;
|
||||||
|
acceptable: number;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERFORMANCE_TARGETS: Record<string, PerformanceTarget> = {
|
||||||
|
lcp: { good: 2500, acceptable: 4000, label: "LCP", unit: "ms" },
|
||||||
|
fcp: { good: 1800, acceptable: 3000, label: "FCP", unit: "ms" },
|
||||||
|
ttfb: { good: 800, acceptable: 1800, label: "TTFB", unit: "ms" },
|
||||||
|
cls: { good: 0.1, acceptable: 0.25, label: "CLS", unit: "" },
|
||||||
|
avgDuration: {
|
||||||
|
good: 3000,
|
||||||
|
acceptable: 5000,
|
||||||
|
label: "Avg Duration",
|
||||||
|
unit: "ms"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPerformanceRating(
|
||||||
|
metric: string,
|
||||||
|
value: number
|
||||||
|
): "good" | "acceptable" | "poor" {
|
||||||
|
const target = PERFORMANCE_TARGETS[metric];
|
||||||
|
if (!target) return "acceptable";
|
||||||
|
|
||||||
|
if (value <= target.good) return "good";
|
||||||
|
if (value <= target.acceptable) return "acceptable";
|
||||||
|
return "poor";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatingColor(rating: "good" | "acceptable" | "poor"): string {
|
||||||
|
switch (rating) {
|
||||||
|
case "good":
|
||||||
|
return "text-green-600 dark:text-green-400";
|
||||||
|
case "acceptable":
|
||||||
|
return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
case "poor":
|
||||||
|
return "text-red-600 dark:text-red-400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string {
|
||||||
|
switch (rating) {
|
||||||
|
case "good":
|
||||||
|
return "bg-green-100 dark:bg-green-900/30";
|
||||||
|
case "acceptable":
|
||||||
|
return "bg-yellow-100 dark:bg-yellow-900/30";
|
||||||
|
case "poor":
|
||||||
|
return "bg-red-100 dark:bg-red-900/30";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes.toFixed(0)}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return new Intl.NumberFormat().format(Math.round(num));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
const adminCheck = createAsync(() => checkAdmin());
|
||||||
|
|
||||||
|
const [timeWindow, setTimeWindow] = createSignal(7);
|
||||||
|
const [selectedPath, setSelectedPath] = createSignal<string | null>(null);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const summary = createAsync(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
return await api.analytics.getSummary.query({ days: timeWindow() });
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load analytics");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathStats = createAsync(async () => {
|
||||||
|
const path = selectedPath();
|
||||||
|
if (!path) return null;
|
||||||
|
try {
|
||||||
|
return await api.analytics.getPathStats.query({
|
||||||
|
path,
|
||||||
|
days: timeWindow()
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load path stats:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title>Analytics Dashboard - Admin</Title>
|
||||||
|
<div class="min-h-screen bg-gray-50 px-4 py-8 dark:bg-gray-900">
|
||||||
|
<div class="mx-auto max-w-7xl">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="mb-2 text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Analytics Dashboard
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
Visitor analytics and performance metrics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Window Selector */}
|
||||||
|
<div class="mb-6 flex gap-2">
|
||||||
|
<For each={[1, 7, 30, 90]}>
|
||||||
|
{(days) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setTimeWindow(days)}
|
||||||
|
class={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
|
timeWindow() === days
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{days === 1 ? "24h" : `${days}d`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="mb-6 rounded-lg bg-red-100 p-4 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
<p class="font-semibold">Error loading analytics</p>
|
||||||
|
<p class="text-sm">{error()}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={summary()}>
|
||||||
|
{(data) => (
|
||||||
|
<>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Total Requests
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(data().totalVisits)}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatNumber(data().totalPageVisits)} pages,{" "}
|
||||||
|
{formatNumber(data().totalApiCalls)} API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Unique Visitors
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(data().uniqueVisitors)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Authenticated Users
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(data().uniqueUsers)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Avg. Visits/Day
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(data().totalVisits / timeWindow())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Pages */}
|
||||||
|
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Top Pages
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<For each={data().topPages}>
|
||||||
|
{(pathData) => {
|
||||||
|
const percentage =
|
||||||
|
(pathData.count / data().totalPageVisits) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="cursor-pointer rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => setSelectedPath(pathData.path)}
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="font-mono text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{pathData.path}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(pathData.count)} visits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-blue-600"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{percentage.toFixed(1)}% of page traffic
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top API Calls */}
|
||||||
|
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Top API Calls
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<For each={data().topApiCalls}>
|
||||||
|
{(apiData) => {
|
||||||
|
const percentage =
|
||||||
|
(apiData.count / data().totalApiCalls) * 100;
|
||||||
|
return (
|
||||||
|
<div class="rounded-lg p-3">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">
|
||||||
|
{apiData.path}
|
||||||
|
</span>
|
||||||
|
<span class="ml-4 text-sm font-semibold whitespace-nowrap text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(apiData.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-purple-600"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{percentage.toFixed(1)}% of API traffic
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device & Browser Stats */}
|
||||||
|
<div class="mb-8 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
{/* Device Types */}
|
||||||
|
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Device Types
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<For each={data().deviceTypes}>
|
||||||
|
{(device) => {
|
||||||
|
const totalDevices = data().deviceTypes.reduce(
|
||||||
|
(sum, d) => sum + d.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const percentage =
|
||||||
|
totalDevices > 0
|
||||||
|
? (device.count / totalDevices) * 100
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
||||||
|
{device.type}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatNumber(device.count)} (
|
||||||
|
{percentage.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-purple-600"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browsers */}
|
||||||
|
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Browsers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<For each={data().browsers}>
|
||||||
|
{(browser) => {
|
||||||
|
const totalBrowsers = data().browsers.reduce(
|
||||||
|
(sum, b) => sum + b.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const percentage =
|
||||||
|
totalBrowsers > 0
|
||||||
|
? (browser.count / totalBrowsers) * 100
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
||||||
|
{browser.browser}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatNumber(browser.count)} (
|
||||||
|
{percentage.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-green-600"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Referrers */}
|
||||||
|
<Show when={data().topReferrers.length > 0}>
|
||||||
|
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Top Referrers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={data().topReferrers}>
|
||||||
|
{(referrer) => (
|
||||||
|
<div class="flex justify-between border-b border-gray-100 py-2 dark:border-gray-700">
|
||||||
|
<span class="max-w-md truncate text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{referrer.referrer}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(referrer.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Path Details Modal/Section */}
|
||||||
|
<Show when={selectedPath() && pathStats()}>
|
||||||
|
{(stats) => (
|
||||||
|
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Path Details: {selectedPath()}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPath(null)}
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Total Visits
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(stats().totalVisits)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Unique Visitors
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(stats().uniqueVisitors)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Avg. Duration
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{stats().avgDurationMs
|
||||||
|
? `${(stats().avgDurationMs! / 1000).toFixed(1)}s`
|
||||||
|
: "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visits by Day */}
|
||||||
|
<Show when={stats().visitsByDay.length > 0}>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Visits by Day
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={stats().visitsByDay}>
|
||||||
|
{(day) => {
|
||||||
|
const maxVisits = Math.max(
|
||||||
|
...stats().visitsByDay.map((d) => d.count)
|
||||||
|
);
|
||||||
|
const percentage = (day.count / maxVisits) * 100;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{new Date(day.date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatNumber(day.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full bg-blue-600"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,8 @@ const getAuthState = query(async () => {
|
|||||||
const privilegeLevel = await getPrivilegeLevel(event);
|
const privilegeLevel = await getPrivilegeLevel(event);
|
||||||
const userID = await getUserID(event);
|
const userID = await getUserID(event);
|
||||||
|
|
||||||
// Return 401 for non-admin users
|
|
||||||
if (privilegeLevel !== "admin") {
|
if (privilegeLevel !== "admin") {
|
||||||
throw new Response("Unauthorized", { status: 401 });
|
throw redirect("/401");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { privilegeLevel, userID };
|
return { privilegeLevel, userID };
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ const getPostForEdit = query(async (id: string) => {
|
|||||||
const privilegeLevel = await getPrivilegeLevel(event);
|
const privilegeLevel = await getPrivilegeLevel(event);
|
||||||
const userID = await getUserID(event);
|
const userID = await getUserID(event);
|
||||||
|
|
||||||
// Return 401 for non-admin users
|
|
||||||
if (privilegeLevel !== "admin") {
|
if (privilegeLevel !== "admin") {
|
||||||
throw new Response("Unauthorized", { status: 401 });
|
throw redirect("/401");
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|||||||
369
src/server/analytics.ts
Normal file
369
src/server/analytics.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { ConnectionFactory } from "./database";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types";
|
||||||
|
|
||||||
|
export interface AnalyticsEntry {
|
||||||
|
userId?: string | null;
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
referrer?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
deviceType?: string | null;
|
||||||
|
browser?: string | null;
|
||||||
|
os?: string | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO VisitorAnalytics (
|
||||||
|
id, user_id, path, method, referrer, user_agent, ip_address,
|
||||||
|
country, device_type, browser, os, session_id, duration_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
uuid(),
|
||||||
|
entry.userId || null,
|
||||||
|
entry.path,
|
||||||
|
entry.method,
|
||||||
|
entry.referrer || null,
|
||||||
|
entry.userAgent || null,
|
||||||
|
entry.ipAddress || null,
|
||||||
|
entry.country || null,
|
||||||
|
entry.deviceType || null,
|
||||||
|
entry.browser || null,
|
||||||
|
entry.os || null,
|
||||||
|
entry.sessionId || null,
|
||||||
|
entry.durationMs || null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to log visitor analytics:", error, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAnalytics(
|
||||||
|
query: AnalyticsQuery
|
||||||
|
): Promise<VisitorAnalytics[]> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
let sql = "SELECT * FROM VisitorAnalytics WHERE 1=1";
|
||||||
|
const args: any[] = [];
|
||||||
|
|
||||||
|
if (query.userId) {
|
||||||
|
sql += " AND user_id = ?";
|
||||||
|
args.push(query.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.path) {
|
||||||
|
sql += " AND path = ?";
|
||||||
|
args.push(query.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startDate) {
|
||||||
|
sql += " AND created_at >= ?";
|
||||||
|
args.push(
|
||||||
|
typeof query.startDate === "string"
|
||||||
|
? query.startDate
|
||||||
|
: query.startDate.toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.endDate) {
|
||||||
|
sql += " AND created_at <= ?";
|
||||||
|
args.push(
|
||||||
|
typeof query.endDate === "string"
|
||||||
|
? query.endDate
|
||||||
|
: query.endDate.toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY created_at DESC";
|
||||||
|
|
||||||
|
if (query.limit) {
|
||||||
|
sql += " LIMIT ?";
|
||||||
|
args.push(query.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.offset) {
|
||||||
|
sql += " OFFSET ?";
|
||||||
|
args.push(query.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await conn.execute({ sql, args });
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.id as string,
|
||||||
|
user_id: row.user_id as string | null,
|
||||||
|
path: row.path as string,
|
||||||
|
method: row.method as string,
|
||||||
|
referrer: row.referrer as string | null,
|
||||||
|
user_agent: row.user_agent as string | null,
|
||||||
|
ip_address: row.ip_address as string | null,
|
||||||
|
country: row.country as string | null,
|
||||||
|
device_type: row.device_type as string | null,
|
||||||
|
browser: row.browser as string | null,
|
||||||
|
os: row.os as string | null,
|
||||||
|
session_id: row.session_id as string | null,
|
||||||
|
duration_ms: row.duration_ms as number | null,
|
||||||
|
created_at: row.created_at as string
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||||
|
totalVisits: number;
|
||||||
|
totalPageVisits: number;
|
||||||
|
totalApiCalls: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
uniqueUsers: number;
|
||||||
|
topPages: Array<{ path: string; count: number }>;
|
||||||
|
topApiCalls: Array<{ path: string; count: number }>;
|
||||||
|
topReferrers: Array<{ referrer: string; count: number }>;
|
||||||
|
deviceTypes: Array<{ type: string; count: number }>;
|
||||||
|
browsers: Array<{ browser: string; count: number }>;
|
||||||
|
}> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const totalVisitsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const totalPageVisitsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= datetime('now', '-${days} days')
|
||||||
|
AND path NOT LIKE '/api/%'`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const totalPageVisits = (totalPageVisitsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const totalApiCallsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= datetime('now', '-${days} days')
|
||||||
|
AND path LIKE '/api/%'`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const uniqueVisitorsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
||||||
|
WHERE ip_address IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const uniqueUsersResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(DISTINCT user_id) as count FROM VisitorAnalytics
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const uniqueUsers = (uniqueUsersResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const topPagesResult = await conn.execute({
|
||||||
|
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= datetime('now', '-${days} days')
|
||||||
|
AND path NOT LIKE '/api/%'
|
||||||
|
GROUP BY path
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const topPages = topPagesResult.rows.map((row) => ({
|
||||||
|
path: row.path as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
const topApiCallsResult = await conn.execute({
|
||||||
|
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE created_at >= datetime('now', '-${days} days')
|
||||||
|
AND path LIKE '/api/%'
|
||||||
|
GROUP BY path
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const topApiCalls = topApiCallsResult.rows.map((row) => ({
|
||||||
|
path: row.path as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
const topReferrersResult = await conn.execute({
|
||||||
|
sql: `SELECT referrer, COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE referrer IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')
|
||||||
|
GROUP BY referrer
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const topReferrers = topReferrersResult.rows.map((row) => ({
|
||||||
|
referrer: row.referrer as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deviceTypesResult = await conn.execute({
|
||||||
|
sql: `SELECT device_type, COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE device_type IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')
|
||||||
|
GROUP BY device_type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const deviceTypes = deviceTypesResult.rows.map((row) => ({
|
||||||
|
type: row.device_type as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
const browsersResult = await conn.execute({
|
||||||
|
sql: `SELECT browser, COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE browser IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')
|
||||||
|
GROUP BY browser
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
const browsers = browsersResult.rows.map((row) => ({
|
||||||
|
browser: row.browser as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalVisits,
|
||||||
|
totalPageVisits,
|
||||||
|
totalApiCalls,
|
||||||
|
uniqueVisitors,
|
||||||
|
uniqueUsers,
|
||||||
|
topPages,
|
||||||
|
topApiCalls,
|
||||||
|
topReferrers,
|
||||||
|
deviceTypes,
|
||||||
|
browsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPathAnalytics(
|
||||||
|
path: string,
|
||||||
|
days: number = 30
|
||||||
|
): Promise<{
|
||||||
|
totalVisits: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
avgDurationMs: number | null;
|
||||||
|
visitsByDay: Array<{ date: string; count: number }>;
|
||||||
|
}> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const totalVisitsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
|
||||||
|
WHERE path = ?
|
||||||
|
AND created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: [path]
|
||||||
|
});
|
||||||
|
const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const uniqueVisitorsResult = await conn.execute({
|
||||||
|
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
|
||||||
|
WHERE path = ?
|
||||||
|
AND ip_address IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: [path]
|
||||||
|
});
|
||||||
|
const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0;
|
||||||
|
|
||||||
|
const avgDurationResult = await conn.execute({
|
||||||
|
sql: `SELECT AVG(duration_ms) as avg FROM VisitorAnalytics
|
||||||
|
WHERE path = ?
|
||||||
|
AND duration_ms IS NOT NULL
|
||||||
|
AND created_at >= datetime('now', '-${days} days')`,
|
||||||
|
args: [path]
|
||||||
|
});
|
||||||
|
const avgDurationMs = avgDurationResult.rows[0]?.avg as number | null;
|
||||||
|
|
||||||
|
const visitsByDayResult = await conn.execute({
|
||||||
|
sql: `SELECT DATE(created_at) as date, COUNT(*) as count
|
||||||
|
FROM VisitorAnalytics
|
||||||
|
WHERE path = ?
|
||||||
|
AND created_at >= datetime('now', '-${days} days')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date DESC`,
|
||||||
|
args: [path]
|
||||||
|
});
|
||||||
|
const visitsByDay = visitsByDayResult.rows.map((row) => ({
|
||||||
|
date: row.date as string,
|
||||||
|
count: row.count as number
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalVisits,
|
||||||
|
uniqueVisitors,
|
||||||
|
avgDurationMs,
|
||||||
|
visitsByDay
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupOldAnalytics(
|
||||||
|
olderThanDays: number
|
||||||
|
): Promise<number> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `DELETE FROM VisitorAnalytics
|
||||||
|
WHERE created_at < datetime('now', '-${olderThanDays} days')
|
||||||
|
RETURNING id`,
|
||||||
|
args: []
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUserAgent(userAgent?: string): {
|
||||||
|
deviceType: string | null;
|
||||||
|
browser: string | null;
|
||||||
|
os: string | null;
|
||||||
|
} {
|
||||||
|
if (!userAgent) {
|
||||||
|
return { deviceType: null, browser: null, os: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
let deviceType: string | null = "desktop";
|
||||||
|
if (ua.includes("mobile")) deviceType = "mobile";
|
||||||
|
else if (ua.includes("tablet") || ua.includes("ipad")) deviceType = "tablet";
|
||||||
|
|
||||||
|
let browser: string | null = null;
|
||||||
|
if (ua.includes("edg")) browser = "edge";
|
||||||
|
else if (ua.includes("chrome")) browser = "chrome";
|
||||||
|
else if (ua.includes("firefox")) browser = "firefox";
|
||||||
|
else if (ua.includes("safari") && !ua.includes("chrome")) browser = "safari";
|
||||||
|
else if (ua.includes("opera") || ua.includes("opr")) browser = "opera";
|
||||||
|
|
||||||
|
let os: string | null = null;
|
||||||
|
if (ua.includes("windows")) os = "windows";
|
||||||
|
else if (ua.includes("mac")) os = "macos";
|
||||||
|
else if (ua.includes("linux")) os = "linux";
|
||||||
|
else if (ua.includes("android")) os = "android";
|
||||||
|
else if (ua.includes("iphone") || ua.includes("ipad")) os = "ios";
|
||||||
|
|
||||||
|
return { deviceType, browser, os };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
|
||||||
|
const { deviceType, browser, os } = parseUserAgent(
|
||||||
|
entry.userAgent || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
deviceType: entry.deviceType || deviceType,
|
||||||
|
browser: entry.browser || browser,
|
||||||
|
os: entry.os || os
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { authRouter } from "./routers/auth";
|
import { authRouter } from "./routers/auth";
|
||||||
import { auditRouter } from "./routers/audit";
|
import { auditRouter } from "./routers/audit";
|
||||||
|
import { analyticsRouter } from "./routers/analytics";
|
||||||
import { databaseRouter } from "./routers/database";
|
import { databaseRouter } from "./routers/database";
|
||||||
import { lineageRouter } from "./routers/lineage";
|
import { lineageRouter } from "./routers/lineage";
|
||||||
import { miscRouter } from "./routers/misc";
|
import { miscRouter } from "./routers/misc";
|
||||||
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
|
|||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
audit: auditRouter,
|
audit: auditRouter,
|
||||||
|
analytics: analyticsRouter,
|
||||||
database: databaseRouter,
|
database: databaseRouter,
|
||||||
lineage: lineageRouter,
|
lineage: lineageRouter,
|
||||||
misc: miscRouter,
|
misc: miscRouter,
|
||||||
|
|||||||
86
src/server/api/routers/analytics.ts
Normal file
86
src/server/api/routers/analytics.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createTRPCRouter, adminProcedure } from "../utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
queryAnalytics,
|
||||||
|
getAnalyticsSummary,
|
||||||
|
getPathAnalytics,
|
||||||
|
cleanupOldAnalytics
|
||||||
|
} from "~/server/analytics";
|
||||||
|
|
||||||
|
export const analyticsRouter = createTRPCRouter({
|
||||||
|
getLogs: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(1000).default(100),
|
||||||
|
offset: z.number().min(0).default(0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const logs = await queryAnalytics({
|
||||||
|
userId: input.userId,
|
||||||
|
path: input.path,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
limit: input.limit,
|
||||||
|
offset: input.offset
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
count: logs.length,
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSummary: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.number().min(1).max(365).default(30)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const summary = await getAnalyticsSummary(input.days);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
timeWindow: `${input.days} days`
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPathStats: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
days: z.number().min(1).max(365).default(30)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const stats = await getPathAnalytics(input.path, input.days);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: input.path,
|
||||||
|
...stats,
|
||||||
|
timeWindow: `${input.days} days`
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
cleanup: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
olderThanDays: z.number().min(1).max(365).default(90)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const deleted = await cleanupOldAnalytics(input.olderThanDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted,
|
||||||
|
olderThanDays: input.olderThanDays
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import type { APIEvent } from "@solidjs/start/server";
|
|||||||
import { getCookie, setCookie } from "vinxi/http";
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
import { jwtVerify, type JWTPayload } from "jose";
|
import { jwtVerify, type JWTPayload } from "jose";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
|
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||||
|
import { getRequestIP } from "vinxi/http";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
event: APIEvent;
|
event: APIEvent;
|
||||||
@@ -33,6 +35,33 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
||||||
|
const path = req.url || event.request?.url || "unknown";
|
||||||
|
const method = req.method || event.request?.method || "GET";
|
||||||
|
const userAgent =
|
||||||
|
req.headers?.["user-agent"] ||
|
||||||
|
event.request?.headers?.get("user-agent") ||
|
||||||
|
undefined;
|
||||||
|
const referrer =
|
||||||
|
req.headers?.referer ||
|
||||||
|
req.headers?.referrer ||
|
||||||
|
event.request?.headers?.get("referer") ||
|
||||||
|
undefined;
|
||||||
|
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||||
|
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
|
||||||
|
|
||||||
|
logVisit(
|
||||||
|
enrichAnalyticsEntry({
|
||||||
|
userId,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
userAgent,
|
||||||
|
referrer,
|
||||||
|
ipAddress,
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user