diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index bb399379..7cac90e3 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -27,6 +27,7 @@ import ShieldIcon from "@mui/icons-material/Shield"; import SettingsIcon from "@mui/icons-material/Settings"; import HistoryIcon from "@mui/icons-material/History"; import LogoutIcon from "@mui/icons-material/Logout"; +import BarChartIcon from "@mui/icons-material/BarChart"; type User = { id: string; @@ -37,6 +38,7 @@ type User = { const NAV_ITEMS = [ { href: "/", label: "Overview", icon: DashboardIcon }, + { href: "/analytics", label: "Analytics", icon: BarChartIcon }, { href: "/proxy-hosts", label: "Proxy Hosts", icon: DnsIcon }, { href: "/access-lists", label: "Access Lists", icon: SecurityIcon }, { href: "/certificates", label: "Certificates", icon: ShieldIcon }, diff --git a/app/(dashboard)/OverviewClient.tsx b/app/(dashboard)/OverviewClient.tsx index 1dd8bcdd..98def3b6 100644 --- a/app/(dashboard)/OverviewClient.tsx +++ b/app/(dashboard)/OverviewClient.tsx @@ -17,13 +17,20 @@ type RecentEvent = { created_at: string; }; +type TrafficSummary = { + totalRequests: number; + blockedPercent: number; +} | null; + export default function OverviewClient({ userName, stats, + trafficSummary, recentEvents }: { userName: string; stats: StatCard[]; + trafficSummary: TrafficSummary; recentEvents: RecentEvent[]; }) { return ( @@ -90,6 +97,51 @@ export default function OverviewClient({ ))} + + {/* Traffic (24h) card */} + + + + + + + + + + {trafficSummary ? ( + <> + + {trafficSummary.totalRequests.toLocaleString()} + + + Traffic (24h) + {trafficSummary.totalRequests > 0 && ( + 0 ? "error.light" : "text.secondary", fontSize: "0.8em" }}> + · {trafficSummary.blockedPercent}% blocked + + )} + + + ) : ( + <> + + Traffic (24h) + + )} + + + + diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx new file mode 100644 index 00000000..1ad7b23f --- /dev/null +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -0,0 +1,488 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import dynamic from 'next/dynamic'; +import Link from 'next/link'; +import { + Alert, + Box, + Card, + CardContent, + CircularProgress, + FormControl, + Grid, + MenuItem, + Pagination, + Paper, + Select, + SelectChangeEvent, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import type { ApexOptions } from 'apexcharts'; + +// ── Dynamic imports (browser-only) ──────────────────────────────────────────── + +const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); + +const WorldMap = dynamic(() => import('./WorldMapInner'), { + ssr: false, + loading: () => ( + + + + ), +}); + +// ── Types (mirrored from analytics-db — can't import server-only code) ──────── + +type Interval = '24h' | '7d' | '30d'; + +interface AnalyticsSummary { + totalRequests: number; + uniqueIps: number; + blockedRequests: number; + blockedPercent: number; + bytesServed: number; + loggingDisabled: boolean; +} + +interface TimelineBucket { ts: number; total: number; blocked: number; } +interface CountryStats { countryCode: string; total: number; blocked: number; } +interface ProtoStats { proto: string; count: number; percent: number; } +interface UAStats { userAgent: string; count: number; percent: number; } + +interface BlockedEvent { + id: number; ts: number; clientIp: string; countryCode: string | null; + method: string; uri: string; status: number; host: string; +} +interface BlockedPage { events: BlockedEvent[]; total: number; page: number; pages: number; } + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function countryFlag(code: string): string { + if (!code || code.length !== 2) return '🌐'; + return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); +} + +function parseUA(ua: string): string { + if (!ua) return 'Unknown'; + if (/Googlebot/i.test(ua)) return 'Googlebot'; + if (/bingbot/i.test(ua)) return 'Bingbot'; + if (/DuckDuckBot/i.test(ua)) return 'DuckDuckBot'; + if (/curl/i.test(ua)) return 'curl'; + if (/python-requests|Python\//i.test(ua)) return 'Python'; + if (/Go-http-client/i.test(ua)) return 'Go'; + if (/wget/i.test(ua)) return 'wget'; + if (/Edg\//i.test(ua)) return 'Edge'; + if (/OPR\//i.test(ua)) return 'Opera'; + if (/SamsungBrowser/i.test(ua)) return 'Samsung Browser'; + if (/Chrome\//i.test(ua)) return 'Chrome'; + if (/Firefox\//i.test(ua)) return 'Firefox'; + if (/Safari\//i.test(ua)) return 'Safari'; + return ua.substring(0, 32); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +function formatTs(ts: number, interval: Interval): string { + const d = new Date(ts * 1000); + if (interval === '24h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (interval === '7d') return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +const DARK_CHART: ApexOptions = { + chart: { background: 'transparent', toolbar: { show: false }, animations: { enabled: false } }, + theme: { mode: 'dark' }, + grid: { borderColor: 'rgba(255,255,255,0.06)' }, + tooltip: { theme: 'dark' }, +}; + +// ── Stat card ───────────────────────────────────────────────────────────────── + +function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { + return ( + + + + {label} + + + {value} + + {sub && {sub}} + + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export default function AnalyticsClient() { + const [interval, setIntervalVal] = useState('24h'); + const [host, setHost] = useState('all'); + const [hosts, setHosts] = useState([]); + + const [summary, setSummary] = useState(null); + const [timeline, setTimeline] = useState([]); + const [countries, setCountries] = useState([]); + const [protocols, setProtocols] = useState([]); + const [userAgents, setUserAgents] = useState([]); + const [blocked, setBlocked] = useState(null); + const [loading, setLoading] = useState(true); + + // Fetch hosts once + useEffect(() => { + fetch('/api/analytics/hosts').then(r => r.json()).then(setHosts).catch(() => {}); + }, []); + + // Fetch all analytics data when interval or host changes + useEffect(() => { + setLoading(true); + const params = `?interval=${interval}&host=${encodeURIComponent(host)}`; + Promise.all([ + fetch(`/api/analytics/summary${params}`).then(r => r.json()), + fetch(`/api/analytics/timeline${params}`).then(r => r.json()), + fetch(`/api/analytics/countries${params}`).then(r => r.json()), + fetch(`/api/analytics/protocols${params}`).then(r => r.json()), + fetch(`/api/analytics/user-agents${params}`).then(r => r.json()), + fetch(`/api/analytics/blocked${params}&page=1`).then(r => r.json()), + ]).then(([s, t, c, p, u, b]) => { + setSummary(s); + setTimeline(t); + setCountries(c); + setProtocols(p); + setUserAgents(u); + setBlocked(b); + }).catch(() => {}).finally(() => setLoading(false)); + }, [interval, host]); + + const fetchBlockedPage = useCallback((page: number) => { + const params = `?interval=${interval}&host=${encodeURIComponent(host)}&page=${page}`; + fetch(`/api/analytics/blocked${params}`).then(r => r.json()).then(setBlocked).catch(() => {}); + }, [interval, host]); + + // ── Chart configs ───────────────────────────────────────────────────────── + + const timelineLabels = timeline.map(b => formatTs(b.ts, interval)); + const timelineOptions: ApexOptions = { + ...DARK_CHART, + chart: { ...DARK_CHART.chart, type: 'area', stacked: true, id: 'timeline' }, + colors: ['#3b82f6', '#ef4444'], + fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.45, opacityTo: 0.05 } }, + stroke: { curve: 'smooth', width: 2 }, + dataLabels: { enabled: false }, + xaxis: { categories: timelineLabels, labels: { rotate: 0, style: { colors: '#94a3b8', fontSize: '11px' } }, axisBorder: { show: false }, axisTicks: { show: false } }, + yaxis: { labels: { style: { colors: '#94a3b8' } } }, + legend: { labels: { colors: '#94a3b8' } }, + tooltip: { theme: 'dark', shared: true, intersect: false }, + }; + const timelineSeries = [ + { name: 'Allowed', data: timeline.map(b => b.total - b.blocked) }, + { name: 'Blocked', data: timeline.map(b => b.blocked) }, + ]; + + const donutOptions: ApexOptions = { + ...DARK_CHART, + chart: { ...DARK_CHART.chart, type: 'donut', id: 'protocols' }, + colors: ['#3b82f6', '#8b5cf6', '#06b6d4', '#f59e0b'], + labels: protocols.map(p => p.proto), + legend: { position: 'bottom', labels: { colors: '#94a3b8' } }, + dataLabels: { style: { colors: ['#fff'] } }, + plotOptions: { pie: { donut: { size: '65%' } } }, + }; + const donutSeries = protocols.map(p => p.count); + + const uaNames = userAgents.map(u => parseUA(u.userAgent)); + const barOptions: ApexOptions = { + ...DARK_CHART, + chart: { ...DARK_CHART.chart, type: 'bar', id: 'ua' }, + colors: ['#7f5bff'], + plotOptions: { bar: { horizontal: true, borderRadius: 4 } }, + dataLabels: { enabled: false }, + xaxis: { categories: uaNames, labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, + yaxis: { labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, + }; + const barSeries = [{ name: 'Requests', data: userAgents.map(u => u.count) }]; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + {/* Header */} + + + + Traffic Intelligence + + + Analytics + + + + { if (v) setIntervalVal(v); }} + > + 24h + 7d + 30d + + + + + + + + {/* Logging disabled alert */} + {summary?.loggingDisabled && ( + + Caddy access logging is not enabled — no traffic data is being collected.{' '} + Enable logging in Settings. + + )} + + {/* Loading overlay */} + {loading && ( + + + + )} + + {!loading && summary && ( + <> + {/* Stats row */} + + + + + + + + + 0 ? '#ef4444' : undefined} + /> + + + 10 ? '#f59e0b' : undefined} + /> + + + + {/* Timeline */} + + + + Requests Over Time + + {timeline.length === 0 ? ( + No data for this period + ) : ( + + )} + + + + {/* World map + Countries */} + + + + + + Traffic by Country + + + + + + + + + + Top Countries + + {countries.length === 0 ? ( + No geo data available + ) : ( + + + + Country + Requests + Blocked + + + + {countries.slice(0, 10).map(c => ( + + + + {countryFlag(c.countryCode)} + {c.countryCode} + + + + {c.total.toLocaleString()} + + + 0 ? 'error.light' : 'text.secondary'}> + {c.blocked.toLocaleString()} + + + + ))} + +
+ )} +
+
+
+
+ + {/* Protocols + User Agents */} + + + + + + HTTP Protocols + + {protocols.length === 0 ? ( + No data + ) : ( + <> + + + + {protocols.map(p => ( + + {p.proto} + {p.count.toLocaleString()} + {p.percent}% + + ))} + +
+ + )} +
+
+
+ + + + + Top User Agents + + {userAgents.length === 0 ? ( + No data + ) : ( + + )} + + + +
+ + {/* Recent Blocked Requests */} + + + + Recent Blocked Requests + + {!blocked || blocked.events.length === 0 ? ( + + No blocked requests in this period + + ) : ( + <> + + + + {['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => ( + {h} + ))} + + + + {blocked.events.map(ev => ( + + + + {new Date(ev.ts * 1000).toLocaleString()} + + + {ev.clientIp} + + + {ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'} + + + + {ev.host || '—'} + + {ev.method} + + {ev.uri} + + + {ev.status} + + + ))} + +
+ {blocked.pages > 1 && ( + + fetchBlockedPage(p)} + color="primary" + size="small" + /> + + )} + + )} +
+
+ + )} +
+ ); +} diff --git a/app/(dashboard)/analytics/WorldMapInner.tsx b/app/(dashboard)/analytics/WorldMapInner.tsx new file mode 100644 index 00000000..3a37f67a --- /dev/null +++ b/app/(dashboard)/analytics/WorldMapInner.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { geoNaturalEarth1, geoPath } from 'd3-geo'; +import { feature } from 'topojson-client'; +import type { Topology, GeometryCollection } from 'topojson-specification'; +import { Box, CircularProgress } from '@mui/material'; + +const WORLD_ATLAS = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'; + +// ISO 3166-1 alpha-2 → numeric +const A2N: Record = { + AF:'4',AL:'8',DZ:'12',AD:'20',AO:'24',AG:'28',AR:'32',AM:'51', + AU:'36',AT:'40',AZ:'31',BS:'44',BH:'48',BD:'50',BB:'52',BY:'112', + BE:'56',BZ:'84',BJ:'204',BT:'64',BO:'68',BA:'70',BW:'72',BR:'76', + BN:'96',BG:'100',BF:'854',BI:'108',CV:'132',KH:'116',CM:'120', + CA:'124',CF:'140',TD:'148',CL:'152',CN:'156',CO:'170',KM:'174', + CG:'178',CD:'180',CR:'188',CI:'384',HR:'191',CU:'192',CY:'196', + CZ:'203',DK:'208',DJ:'262',DM:'212',DO:'214',EC:'218',EG:'818', + SV:'222',GQ:'226',ER:'232',EE:'233',SZ:'748',ET:'231',FJ:'242', + FI:'246',FR:'250',GA:'266',GM:'270',GE:'268',DE:'276',GH:'288', + GR:'300',GD:'308',GT:'320',GN:'324',GW:'624',GY:'328',HT:'332', + HN:'340',HU:'348',IS:'352',IN:'356',ID:'360',IR:'364',IQ:'368', + IE:'372',IL:'376',IT:'380',JM:'388',JP:'392',JO:'400',KZ:'398', + KE:'404',KI:'296',KP:'408',KR:'410',KW:'414',KG:'417',LA:'418', + LV:'428',LB:'422',LS:'426',LR:'430',LY:'434',LI:'438',LT:'440', + LU:'442',MG:'450',MW:'454',MY:'458',MV:'462',ML:'466',MT:'470', + MH:'584',MR:'478',MU:'480',MX:'484',FM:'583',MD:'498',MC:'492', + MN:'496',ME:'499',MA:'504',MZ:'508',MM:'104',NA:'516',NR:'520', + NP:'524',NL:'528',NZ:'554',NI:'558',NE:'562',NG:'566',NO:'578', + OM:'512',PK:'586',PW:'585',PA:'591',PG:'598',PY:'600',PE:'604', + PH:'608',PL:'616',PT:'620',QA:'634',RO:'642',RU:'643',RW:'646', + KN:'659',LC:'662',VC:'670',WS:'882',SM:'674',ST:'678',SA:'682', + SN:'686',RS:'688',SC:'690',SL:'694',SG:'702',SK:'703',SI:'705', + SB:'90',SO:'706',ZA:'710',SS:'728',ES:'724',LK:'144',SD:'729', + SR:'740',SE:'752',CH:'756',SY:'760',TW:'158',TJ:'762',TZ:'834', + TH:'764',TL:'626',TG:'768',TO:'776',TT:'780',TN:'788',TR:'792', + TM:'795',TV:'798',UG:'800',UA:'804',AE:'784',GB:'826',US:'840', + UY:'858',UZ:'860',VU:'548',VE:'862',VN:'704',YE:'887',ZM:'894', + ZW:'716',PS:'275', +}; + +const N2A: Record = Object.fromEntries( + Object.entries(A2N).map(([a, n]) => [n, a]) +); + +function colorForCount(count: number, max: number): string { + if (!count) return '#22263a'; + const t = Math.sqrt(Math.min(count / Math.max(max, 1), 1)); + const r = Math.round(0x1a + (0x3b - 0x1a) * t); + const g = Math.round(0x3a + (0x82 - 0x3a) * t); + const b = Math.round(0x5c + (0xf6 - 0x5c) * t); + return `rgb(${r},${g},${b})`; +} + +export interface CountryStats { countryCode: string; total: number; blocked: number; } + +interface PathEntry { id: string; d: string; } + +export default function WorldMapInner({ data }: { data: CountryStats[] }) { + const [paths, setPaths] = useState(null); + + useEffect(() => { + fetch(WORLD_ATLAS) + .then(r => r.json()) + .then((topology: Topology) => { + const countries = feature( + topology, + topology.objects.countries as GeometryCollection + ); + const projection = geoNaturalEarth1().scale(153).translate([480, 250]); + const pathGen = geoPath(projection); + const result: PathEntry[] = []; + for (const f of countries.features) { + const d = pathGen(f); + if (d) result.push({ id: String(f.id ?? ''), d }); + } + setPaths(result); + }) + .catch(() => setPaths([])); + }, []); + + if (paths === null) { + return ( + + + + ); + } + + const countMap = new Map(data.map(d => [d.countryCode, d.total])); + const max = data.reduce((m, d) => Math.max(m, d.total), 0); + + return ( + + + {paths.map(({ id, d }) => { + const alpha2 = N2A[id] ?? null; + const count = alpha2 ? (countMap.get(alpha2) ?? 0) : 0; + return ( + + {alpha2 && count > 0 && ( + {alpha2}: {count.toLocaleString()} requests + )} + + ); + })} + + + ); +} diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx new file mode 100644 index 00000000..79b163c6 --- /dev/null +++ b/app/(dashboard)/analytics/page.tsx @@ -0,0 +1,7 @@ +import { requireUser } from '@/src/lib/auth'; +import AnalyticsClient from './AnalyticsClient'; + +export default async function AnalyticsPage() { + await requireUser(); + return ; +} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 9ed3a120..63a6dfae 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -12,6 +12,7 @@ import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import SecurityIcon from "@mui/icons-material/Security"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; import { ReactNode } from "react"; +import { getAnalyticsSummary } from "@/src/lib/analytics-db"; type StatCard = { label: string; @@ -40,23 +41,27 @@ async function loadStats(): Promise { export default async function OverviewPage() { const session = await requireAdmin(); - const stats = await loadStats(); - const recentEvents = await db - .select({ - action: auditEvents.action, - entityType: auditEvents.entityType, - summary: auditEvents.summary, - createdAt: auditEvents.createdAt - }) - .from(auditEvents) - .orderBy(desc(auditEvents.createdAt)) - .limit(8); + const [stats, trafficSummary, recentEventsRaw] = await Promise.all([ + loadStats(), + getAnalyticsSummary('24h', 'all').catch(() => null), + db + .select({ + action: auditEvents.action, + entityType: auditEvents.entityType, + summary: auditEvents.summary, + createdAt: auditEvents.createdAt + }) + .from(auditEvents) + .orderBy(desc(auditEvents.createdAt)) + .limit(8), + ]); return ( ({ + trafficSummary={trafficSummary} + recentEvents={recentEventsRaw.map((event) => ({ summary: event.summary ?? `${event.action} on ${event.entityType}`, created_at: toIso(event.createdAt)! }))} diff --git a/app/api/analytics/blocked/route.ts b/app/api/analytics/blocked/route.ts new file mode 100644 index 00000000..e75725ea --- /dev/null +++ b/app/api/analytics/blocked/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsBlocked, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const page = parseInt(searchParams.get('page') ?? '1', 10); + const data = await getAnalyticsBlocked(interval, host, page); + return NextResponse.json(data); +} diff --git a/app/api/analytics/countries/route.ts b/app/api/analytics/countries/route.ts new file mode 100644 index 00000000..9606485e --- /dev/null +++ b/app/api/analytics/countries/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsCountries, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const data = await getAnalyticsCountries(interval, host); + return NextResponse.json(data); +} diff --git a/app/api/analytics/hosts/route.ts b/app/api/analytics/hosts/route.ts new file mode 100644 index 00000000..2cb3bd1e --- /dev/null +++ b/app/api/analytics/hosts/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsHosts } from '@/src/lib/analytics-db'; + +export async function GET() { + await requireUser(); + const hosts = await getAnalyticsHosts(); + return NextResponse.json(hosts); +} diff --git a/app/api/analytics/protocols/route.ts b/app/api/analytics/protocols/route.ts new file mode 100644 index 00000000..77fcfc15 --- /dev/null +++ b/app/api/analytics/protocols/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsProtocols, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const data = await getAnalyticsProtocols(interval, host); + return NextResponse.json(data); +} diff --git a/app/api/analytics/summary/route.ts b/app/api/analytics/summary/route.ts new file mode 100644 index 00000000..13515430 --- /dev/null +++ b/app/api/analytics/summary/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsSummary, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const data = await getAnalyticsSummary(interval, host); + return NextResponse.json(data); +} diff --git a/app/api/analytics/timeline/route.ts b/app/api/analytics/timeline/route.ts new file mode 100644 index 00000000..10008d57 --- /dev/null +++ b/app/api/analytics/timeline/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsTimeline, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const data = await getAnalyticsTimeline(interval, host); + return NextResponse.json(data); +} diff --git a/app/api/analytics/user-agents/route.ts b/app/api/analytics/user-agents/route.ts new file mode 100644 index 00000000..57cd87f8 --- /dev/null +++ b/app/api/analytics/user-agents/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { getAnalyticsUserAgents, type Interval } from '@/src/lib/analytics-db'; + +export async function GET(req: NextRequest) { + await requireUser(); + const { searchParams } = req.nextUrl; + const interval = (searchParams.get('interval') ?? '24h') as Interval; + const host = searchParams.get('host') ?? 'all'; + const data = await getAnalyticsUserAgents(interval, host); + return NextResponse.json(data); +} diff --git a/docker-compose.yml b/docker-compose.yml index 90d14275..30ff4ee9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,9 +52,12 @@ services: OAUTH_TOKEN_URL: ${OAUTH_TOKEN_URL:-} OAUTH_USERINFO_URL: ${OAUTH_USERINFO_URL:-} OAUTH_ALLOW_AUTO_LINKING: ${OAUTH_ALLOW_AUTO_LINKING:-false} + group_add: + - "${CADDY_GID:-10000}" # caddy's GID — lets the web user read /logs/access.log volumes: - caddy-manager-data:/app/data - geoip-data:/usr/share/GeoIP:ro,z + - caddy-logs:/logs:ro depends_on: caddy: condition: service_healthy diff --git a/drizzle/0009_watery_bill_hollister.sql b/drizzle/0009_watery_bill_hollister.sql new file mode 100644 index 00000000..438c1e72 --- /dev/null +++ b/drizzle/0009_watery_bill_hollister.sql @@ -0,0 +1,24 @@ +-- Custom SQL migration file, put your code below! -- +CREATE TABLE `traffic_events` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `ts` integer NOT NULL, + `client_ip` text NOT NULL, + `country_code` text, + `host` text NOT NULL DEFAULT '', + `method` text NOT NULL DEFAULT '', + `uri` text NOT NULL DEFAULT '', + `status` integer NOT NULL DEFAULT 0, + `proto` text NOT NULL DEFAULT '', + `bytes_sent` integer NOT NULL DEFAULT 0, + `user_agent` text NOT NULL DEFAULT '', + `is_blocked` integer NOT NULL DEFAULT false +); +--> statement-breakpoint +CREATE INDEX `idx_traffic_events_ts` ON `traffic_events` (`ts`); +--> statement-breakpoint +CREATE INDEX `idx_traffic_events_host_ts` ON `traffic_events` (`host`, `ts`); +--> statement-breakpoint +CREATE TABLE `log_parse_state` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..39ad5af1 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1137 @@ +{ + "id": "9f96cdcc-3cae-4476-a8d6-73b16bb0417c", + "prevId": "7e98b252-3ad2-4fd7-b8b1-b6e71a546310", + "version": "6", + "dialect": "sqlite", + "tables": { + "access_list_entries": { + "name": "access_list_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "access_list_id": { + "name": "access_list_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "access_list_entries_list_idx": { + "name": "access_list_entries_list_idx", + "columns": [ + "access_list_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "access_list_entries_access_list_id_access_lists_id_fk": { + "name": "access_list_entries_access_list_id_access_lists_id_fk", + "tableFrom": "access_list_entries", + "columnsFrom": [ + "access_list_id" + ], + "tableTo": "access_lists", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "access_lists": { + "name": "access_lists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_lists_created_by_users_id_fk": { + "name": "access_lists_created_by_users_id_fk", + "tableFrom": "access_lists", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_events": { + "name": "audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_events_user_id_users_id_fk": { + "name": "audit_events_user_id_users_id_fk", + "tableFrom": "audit_events", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "certificates": { + "name": "certificates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain_names": { + "name": "domain_names", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_renew": { + "name": "auto_renew", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "certificate_pem": { + "name": "certificate_pem", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key_pem": { + "name": "private_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "certificates_created_by_users_id_fk": { + "name": "certificates_created_by_users_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instances": { + "name": "instances", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instances_base_url_unique": { + "name": "instances_base_url_unique", + "columns": [ + "base_url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_state_unique": { + "name": "oauth_state_unique", + "columns": [ + "state" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_oauth_links": { + "name": "pending_oauth_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_oauth_user_provider_unique": { + "name": "pending_oauth_user_provider_unique", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pending_oauth_links_user_id_users_id_fk": { + "name": "pending_oauth_links_user_id_users_id_fk", + "tableFrom": "pending_oauth_links", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_hosts": { + "name": "proxy_hosts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domains": { + "name": "domains", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstreams": { + "name": "upstreams", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate_id": { + "name": "certificate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_list_id": { + "name": "access_list_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssl_forced": { + "name": "ssl_forced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hsts_enabled": { + "name": "hsts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hsts_subdomains": { + "name": "hsts_subdomains", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_websocket": { + "name": "allow_websocket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "preserve_host_header": { + "name": "preserve_host_header", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skip_https_hostname_validation": { + "name": "skip_https_hostname_validation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "response_mode": { + "name": "response_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proxy'" + }, + "static_status_code": { + "name": "static_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 503 + }, + "static_response_body": { + "name": "static_response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxy_hosts_certificate_id_certificates_id_fk": { + "name": "proxy_hosts_certificate_id_certificates_id_fk", + "tableFrom": "proxy_hosts", + "columnsFrom": [ + "certificate_id" + ], + "tableTo": "certificates", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "proxy_hosts_access_list_id_access_lists_id_fk": { + "name": "proxy_hosts_access_list_id_access_lists_id_fk", + "tableFrom": "proxy_hosts", + "columnsFrom": [ + "access_list_id" + ], + "tableTo": "access_lists", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "proxy_hosts_owner_user_id_users_id_fk": { + "name": "proxy_hosts_owner_user_id_users_id_fk", + "tableFrom": "proxy_hosts", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "redirect_hosts": { + "name": "redirect_hosts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domains": { + "name": "domains", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 302 + }, + "preserve_query": { + "name": "preserve_query", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "redirect_hosts_created_by_users_id_fk": { + "name": "redirect_hosts_created_by_users_id_fk", + "tableFrom": "redirect_hosts", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_provider_subject_idx": { + "name": "users_provider_subject_idx", + "columns": [ + "provider", + "subject" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 059f3376..e33ce34e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1740960000000, "tag": "0008_unique_provider_subject", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1772129593846, + "tag": "0009_watery_bill_hollister", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index caf833bf..ecaae8b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,18 +13,25 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.6", "@types/better-sqlite3": "^7.6.13", + "apexcharts": "^5.6.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", + "d3-geo": "^3.1.1", "drizzle-orm": "^0.45.1", + "maxmind": "^5.0.5", "next": "^16.1.3", "next-auth": "^5.0.0-beta.30", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-apexcharts": "^2.0.1", + "react-dom": "^19.2.3", + "topojson-client": "^3.1.0" }, "devDependencies": { + "@types/d3-geo": "^3.1.0", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/topojson-client": "^3.1.5", "drizzle-kit": "^0.31.9", "eslint": "^9.39.2", "eslint-config-next": "^16.1.3", @@ -2571,6 +2578,16 @@ "@types/node": "*" } }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2578,6 +2595,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2641,6 +2665,27 @@ "@types/react": "*" } }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -3179,6 +3224,12 @@ "win32" ] }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3235,6 +3286,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apexcharts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.6.0.tgz", + "integrity": "sha512-BZua59yedRsaDfnxkzNrkyLCvluq2c3ZDBIz4joxSKtgr0xDQXQ5dzceMhf/TpTbAjaF+2NYIpLP3BEEIG2s/w==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3785,6 +3845,12 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3835,6 +3901,30 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5446,6 +5536,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6099,6 +6198,20 @@ "node": ">= 0.4" } }, + "node_modules/maxmind": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.5.tgz", + "integrity": "sha512-1lcH2kMjbBpCFhuHaMU32vz8CuOsKttRcWMQyXvtlklopCzN7NNHSVR/h9RYa8JPuFTGmkn2vYARm+7cIGuqDw==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.2", + "tiny-lru": "11.4.7" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6163,6 +6276,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mmdb-lib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6803,6 +6926,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-apexcharts": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.0.1.tgz", + "integrity": "sha512-AN9u5C0kDAkQyMyJP6yXORexiuiaTa0GaxR5dOaF075vWLTTbQxxtYF/kXyvUo4jjtBEIe4lLoCZTo/kB3bRoA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "apexcharts": ">=5.6.0", + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -7598,6 +7734,15 @@ "node": ">=6" } }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7659,6 +7804,20 @@ "node": ">=8.0" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", diff --git a/package.json b/package.json index 04a3befe..2a20540c 100644 --- a/package.json +++ b/package.json @@ -18,19 +18,26 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.6", "@types/better-sqlite3": "^7.6.13", + "apexcharts": "^5.6.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", + "d3-geo": "^3.1.1", "drizzle-orm": "^0.45.1", + "maxmind": "^5.0.5", "next": "^16.1.3", "next-auth": "^5.0.0-beta.30", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-apexcharts": "^2.0.1", + "react-dom": "^19.2.3", + "topojson-client": "^3.1.0" }, "devDependencies": { - "drizzle-kit": "^0.31.9", + "@types/d3-geo": "^3.1.0", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/topojson-client": "^3.1.5", + "drizzle-kit": "^0.31.9", "eslint": "^9.39.2", "eslint-config-next": "^16.1.3", "typescript": "^5.9.3" diff --git a/src/instrumentation.ts b/src/instrumentation.ts index e86b2e09..9068767c 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -48,6 +48,26 @@ export async function register() { // Don't throw - monitoring is a nice-to-have feature } + // Start log parser for analytics + const { initLogParser, parseNewLogEntries, stopLogParser } = await import("./lib/log-parser"); + try { + await initLogParser(); + const logParserInterval = setInterval(async () => { + try { + await parseNewLogEntries(); + } catch (err) { + console.error("Log parser interval error:", err); + } + }, 30_000); + process.on("SIGTERM", () => { + stopLogParser(); + clearInterval(logParserInterval); + }); + console.log("Log parser started"); + } catch (error) { + console.error("Failed to start log parser:", error); + } + // Start periodic instance sync if configured (master mode only) const { getInstanceMode, getSyncIntervalMs, syncInstances } = await import("./lib/instance-sync"); try { diff --git a/src/lib/analytics-db.ts b/src/lib/analytics-db.ts new file mode 100644 index 00000000..48d8a181 --- /dev/null +++ b/src/lib/analytics-db.ts @@ -0,0 +1,244 @@ +import { sql, and, gte, eq } from 'drizzle-orm'; +import db from './db'; +import { trafficEvents } from './db/schema'; +import { existsSync } from 'node:fs'; + +export type Interval = '24h' | '7d' | '30d'; + +const LOG_FILE = '/logs/access.log'; + +function getIntervalStart(interval: Interval): number { + const seconds = interval === '24h' ? 86400 : interval === '7d' ? 7 * 86400 : 30 * 86400; + return Math.floor(Date.now() / 1000) - seconds; +} + +function buildWhere(interval: Interval, host: string) { + const since = getIntervalStart(interval); + const conditions = [gte(trafficEvents.ts, since)]; + if (host !== 'all' && host !== '') conditions.push(eq(trafficEvents.host, host)); + return and(...conditions); +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +export interface AnalyticsSummary { + totalRequests: number; + uniqueIps: number; + blockedRequests: number; + blockedPercent: number; + bytesServed: number; + loggingDisabled: boolean; +} + +export async function getAnalyticsSummary(interval: Interval, host: string): Promise { + const loggingDisabled = !existsSync(LOG_FILE); + const where = buildWhere(interval, host); + + const row = db + .select({ + total: sql`count(*)`, + uniqueIps: sql`count(distinct ${trafficEvents.clientIp})`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + bytes: sql`sum(${trafficEvents.bytesSent})`, + }) + .from(trafficEvents) + .where(where) + .get(); + + const total = row?.total ?? 0; + const blocked = row?.blocked ?? 0; + + return { + totalRequests: total, + uniqueIps: row?.uniqueIps ?? 0, + blockedRequests: blocked, + blockedPercent: total > 0 ? Math.round((blocked / total) * 1000) / 10 : 0, + bytesServed: row?.bytes ?? 0, + loggingDisabled, + }; +} + +// ── Timeline ───────────────────────────────────────────────────────────────── + +export interface TimelineBucket { + ts: number; + total: number; + blocked: number; +} + +export async function getAnalyticsTimeline(interval: Interval, host: string): Promise { + const bucketSize = interval === '24h' ? 3600 : interval === '7d' ? 21600 : 86400; + const where = buildWhere(interval, host); + + const rows = db + .select({ + bucket: sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`, + total: sql`count(*)`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`) + .orderBy(sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`) + .all(); + + return rows.map((r) => ({ + ts: r.bucket * bucketSize, + total: r.total, + blocked: r.blocked ?? 0, + })); +} + +// ── Countries ──────────────────────────────────────────────────────────────── + +export interface CountryStats { + countryCode: string; + total: number; + blocked: number; +} + +export async function getAnalyticsCountries(interval: Interval, host: string): Promise { + const where = buildWhere(interval, host); + + const rows = db + .select({ + countryCode: trafficEvents.countryCode, + total: sql`count(*)`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(trafficEvents.countryCode) + .orderBy(sql`count(*) desc`) + .all(); + + return rows.map((r) => ({ + countryCode: r.countryCode ?? 'XX', + total: r.total, + blocked: r.blocked ?? 0, + })); +} + +// ── Protocols ──────────────────────────────────────────────────────────────── + +export interface ProtoStats { + proto: string; + count: number; + percent: number; +} + +export async function getAnalyticsProtocols(interval: Interval, host: string): Promise { + const where = buildWhere(interval, host); + + const rows = db + .select({ + proto: trafficEvents.proto, + count: sql`count(*)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(trafficEvents.proto) + .orderBy(sql`count(*) desc`) + .all(); + + const total = rows.reduce((s, r) => s + r.count, 0); + + return rows.map((r) => ({ + proto: r.proto || 'Unknown', + count: r.count, + percent: total > 0 ? Math.round((r.count / total) * 1000) / 10 : 0, + })); +} + +// ── User Agents ────────────────────────────────────────────────────────────── + +export interface UAStats { + userAgent: string; + count: number; + percent: number; +} + +export async function getAnalyticsUserAgents(interval: Interval, host: string): Promise { + const where = buildWhere(interval, host); + + const rows = db + .select({ + userAgent: trafficEvents.userAgent, + count: sql`count(*)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(trafficEvents.userAgent) + .orderBy(sql`count(*) desc`) + .limit(10) + .all(); + + const total = rows.reduce((s, r) => s + r.count, 0); + + return rows.map((r) => ({ + userAgent: r.userAgent || 'Unknown', + count: r.count, + percent: total > 0 ? Math.round((r.count / total) * 1000) / 10 : 0, + })); +} + +// ── Blocked events ─────────────────────────────────────────────────────────── + +export interface BlockedEvent { + id: number; + ts: number; + clientIp: string; + countryCode: string | null; + method: string; + uri: string; + status: number; + host: string; +} + +export interface BlockedPage { + events: BlockedEvent[]; + total: number; + page: number; + pages: number; +} + +export async function getAnalyticsBlocked(interval: Interval, host: string, page: number): Promise { + const pageSize = 10; + const where = and(buildWhere(interval, host), eq(trafficEvents.isBlocked, true)); + + const totalRow = db.select({ total: sql`count(*)` }).from(trafficEvents).where(where).get(); + const total = totalRow?.total ?? 0; + const pages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(1, page), pages); + + const rows = db + .select({ + id: trafficEvents.id, + ts: trafficEvents.ts, + clientIp: trafficEvents.clientIp, + countryCode: trafficEvents.countryCode, + method: trafficEvents.method, + uri: trafficEvents.uri, + status: trafficEvents.status, + host: trafficEvents.host, + }) + .from(trafficEvents) + .where(where) + .orderBy(sql`${trafficEvents.ts} desc`) + .limit(pageSize) + .offset((safePage - 1) * pageSize) + .all(); + + return { events: rows, total, page: safePage, pages }; +} + +// ── Hosts ──────────────────────────────────────────────────────────────────── + +export async function getAnalyticsHosts(): Promise { + const rows = db + .selectDistinct({ host: trafficEvents.host }) + .from(trafficEvents) + .orderBy(trafficEvents.host) + .all(); + return rows.map((r) => r.host).filter(Boolean); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index edd26687..54495902 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -185,3 +185,30 @@ export const linkingTokens = sqliteTable("linking_tokens", { createdAt: text("created_at").notNull(), expiresAt: text("expires_at").notNull() }); + +export const trafficEvents = sqliteTable( + 'traffic_events', + { + id: integer('id').primaryKey({ autoIncrement: true }), + ts: integer('ts').notNull(), + clientIp: text('client_ip').notNull(), + countryCode: text('country_code'), + host: text('host').notNull().default(''), + method: text('method').notNull().default(''), + uri: text('uri').notNull().default(''), + status: integer('status').notNull().default(0), + proto: text('proto').notNull().default(''), + bytesSent: integer('bytes_sent').notNull().default(0), + userAgent: text('user_agent').notNull().default(''), + isBlocked: integer('is_blocked', { mode: 'boolean' }).notNull().default(false), + }, + (table) => ({ + tsIdx: index('idx_traffic_events_ts').on(table.ts), + hostTsIdx: index('idx_traffic_events_host_ts').on(table.host, table.ts), + }) +); + +export const logParseState = sqliteTable('log_parse_state', { + key: text('key').primaryKey(), + value: text('value').notNull(), +}); diff --git a/src/lib/log-parser.ts b/src/lib/log-parser.ts new file mode 100644 index 00000000..58538f38 --- /dev/null +++ b/src/lib/log-parser.ts @@ -0,0 +1,187 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import maxmind, { CountryResponse } from 'maxmind'; +import db from './db'; +import { trafficEvents, logParseState } from './db/schema'; +import { eq } from 'drizzle-orm'; + +const LOG_FILE = '/logs/access.log'; +const GEOIP_DB = '/usr/share/GeoIP/GeoLite2-Country.mmdb'; +const BATCH_SIZE = 500; +const RETENTION_DAYS = 90; + +// GeoIP reader — null if mmdb not available +let geoReader: Awaited>> | null = null; +const geoCache = new Map(); + +let stopped = false; + +// ── state helpers ──────────────────────────────────────────────────────────── + +function getState(key: string): string | null { + const row = db.select({ value: logParseState.value }).from(logParseState).where(eq(logParseState.key, key)).get(); + return row?.value ?? null; +} + +function setState(key: string, value: string): void { + db.insert(logParseState).values({ key, value }).onConflictDoUpdate({ target: logParseState.key, set: { value } }).run(); +} + +// ── GeoIP ──────────────────────────────────────────────────────────────────── + +async function initGeoIP(): Promise { + if (!existsSync(GEOIP_DB)) { + console.log('[log-parser] GeoIP database not found, country codes will be null'); + return; + } + try { + geoReader = await maxmind.open(GEOIP_DB); + console.log('[log-parser] GeoIP database loaded'); + } catch (err) { + console.warn('[log-parser] Failed to load GeoIP database:', err); + } +} + +function lookupCountry(ip: string): string | null { + if (!geoReader) return null; + if (geoCache.has(ip)) return geoCache.get(ip)!; + if (geoCache.size > 10_000) geoCache.clear(); + try { + const result = geoReader.get(ip); + const code = result?.country?.iso_code ?? null; + geoCache.set(ip, code); + return code; + } catch { + geoCache.set(ip, null); + return null; + } +} + +// ── log parsing ────────────────────────────────────────────────────────────── + +interface CaddyLogEntry { + ts?: number; + msg?: string; + status?: number; + size?: number; + request?: { + client_ip?: string; + remote_ip?: string; + host?: string; + method?: string; + uri?: string; + proto?: string; + headers?: Record; + }; +} + +function parseLine(line: string): typeof trafficEvents.$inferInsert | null { + let entry: CaddyLogEntry; + try { + entry = JSON.parse(line); + } catch { + return null; + } + + // Only process "handled request" log entries + if (entry.msg !== 'handled request') return null; + + const req = entry.request ?? {}; + const clientIp = req.client_ip || req.remote_ip || ''; + const status = entry.status ?? 0; + + return { + ts: Math.floor(entry.ts ?? Date.now() / 1000), + clientIp, + countryCode: clientIp ? lookupCountry(clientIp) : null, + host: req.host ?? '', + method: req.method ?? '', + uri: req.uri ?? '', + status, + proto: req.proto ?? '', + bytesSent: entry.size ?? 0, + userAgent: req.headers?.['User-Agent']?.[0] ?? '', + isBlocked: status === 403, + }; +} + +async function readLines(startOffset: number): Promise<{ rows: typeof trafficEvents.$inferInsert[]; newOffset: number }> { + return new Promise((resolve, reject) => { + const rows: typeof trafficEvents.$inferInsert[] = []; + let bytesRead = 0; + + const stream = createReadStream(LOG_FILE, { start: startOffset, encoding: 'utf8' }); + stream.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') resolve({ rows: [], newOffset: startOffset }); + else reject(err); + }); + + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + rl.on('line', (line) => { + bytesRead += Buffer.byteLength(line, 'utf8') + 1; // +1 for newline + const row = parseLine(line.trim()); + if (row) rows.push(row); + }); + rl.on('close', () => resolve({ rows, newOffset: startOffset + bytesRead })); + rl.on('error', reject); + }); +} + +function insertBatch(rows: typeof trafficEvents.$inferInsert[]): void { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + db.insert(trafficEvents).values(rows.slice(i, i + BATCH_SIZE)).run(); + } +} + +function purgeOldEntries(): void { + const cutoff = Math.floor(Date.now() / 1000) - RETENTION_DAYS * 86400; + db.delete(trafficEvents).where(eq(trafficEvents.ts, cutoff)).run(); + // Use raw sql for < comparison + db.run(`DELETE FROM traffic_events WHERE ts < ${cutoff}`); +} + +// ── public API ─────────────────────────────────────────────────────────────── + +export async function initLogParser(): Promise { + await initGeoIP(); + console.log('[log-parser] initialized'); +} + +export async function parseNewLogEntries(): Promise { + if (stopped) return; + if (!existsSync(LOG_FILE)) return; + + try { + const storedOffset = parseInt(getState('access_log_offset') ?? '0', 10); + const storedSize = parseInt(getState('access_log_size') ?? '0', 10); + + let currentSize: number; + try { + currentSize = statSync(LOG_FILE).size; + } catch { + return; + } + + // Detect log rotation: file shrank + const startOffset = currentSize < storedSize ? 0 : storedOffset; + + const { rows, newOffset } = await readLines(startOffset); + + if (rows.length > 0) { + insertBatch(rows); + console.log(`[log-parser] inserted ${rows.length} traffic events`); + } + + setState('access_log_offset', String(newOffset)); + setState('access_log_size', String(currentSize)); + + // Purge old entries once per run (cheap since it's indexed) + purgeOldEntries(); + } catch (err) { + console.error('[log-parser] error during parse:', err); + } +} + +export function stopLogParser(): void { + stopped = true; +}