feat: add analytics dashboard with traffic monitoring

- Parse Caddy access logs every 30s into traffic_events SQLite table
- GeoIP country lookup via maxmind (GeoLite2-Country.mmdb)
- 90-day retention with automatic purge
- Analytics page with interval (24h/7d/30d) and per-host filtering:
  - Stats cards: total requests, unique IPs, blocked count, block rate
  - Requests-over-time area chart (ApexCharts)
  - SVG world choropleth map (d3-geo + topojson-client, React 19 compatible)
  - Top countries table with flag emojis
  - HTTP protocol donut chart
  - Top user agents horizontal bar chart
  - Recent blocked requests table with pagination
- Traffic (24h) summary card on Overview page linking to analytics
- 7 authenticated API routes under /api/analytics/
- Share caddy-logs volume with web container (read-only)
- group_add caddy GID to web container for log file read access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-26 20:43:23 +01:00
parent 674e06e3c9
commit 8be69d2774
23 changed files with 2584 additions and 16 deletions
@@ -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 },
+52
View File
@@ -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({
</Card>
</Grid>
))}
{/* Traffic (24h) card */}
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card elevation={0} sx={{ height: "100%", border: "1px solid rgba(148, 163, 184, 0.14)" }}>
<CardActionArea
component={Link}
href="/analytics"
sx={{
height: "100%",
p: 0,
"&:hover": {
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.16), rgba(34, 211, 238, 0.08))"
}
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box sx={{ color: "rgba(127, 91, 255, 0.8)", display: "flex", alignItems: "center" }}>
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 9.2h3V19H5zM10.6 5h2.8v14h-2.8zm5.6 8H19v6h-2.8z"/>
</svg>
</Box>
{trafficSummary ? (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}>
{trafficSummary.totalRequests.toLocaleString()}
</Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>
Traffic (24h)
{trafficSummary.totalRequests > 0 && (
<Box component="span" sx={{ ml: 1, color: trafficSummary.blockedPercent > 0 ? "error.light" : "text.secondary", fontSize: "0.8em" }}>
· {trafficSummary.blockedPercent}% blocked
</Box>
)}
</Typography>
</>
) : (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}></Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>Traffic (24h)</Typography>
</>
)}
</CardContent>
</CardActionArea>
</Card>
</Grid>
</Grid>
<Stack spacing={2}>
@@ -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: () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 240 }}>
<CircularProgress size={24} />
</Box>
),
});
// ── 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 (
<Card elevation={0} sx={{ height: '100%', border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}>
{label}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.03em', mt: 0.5, color: color ?? 'text.primary' }}>
{value}
</Typography>
{sub && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{sub}</Typography>}
</CardContent>
</Card>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function AnalyticsClient() {
const [interval, setIntervalVal] = useState<Interval>('24h');
const [host, setHost] = useState<string>('all');
const [hosts, setHosts] = useState<string[]>([]);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [timeline, setTimeline] = useState<TimelineBucket[]>([]);
const [countries, setCountries] = useState<CountryStats[]>([]);
const [protocols, setProtocols] = useState<ProtoStats[]>([]);
const [userAgents, setUserAgents] = useState<UAStats[]>([]);
const [blocked, setBlocked] = useState<BlockedPage | null>(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 (
<Stack spacing={4}>
{/* Header */}
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} justifyContent="space-between" spacing={2}>
<Box>
<Typography variant="overline" sx={{ color: 'rgba(148,163,184,0.6)', letterSpacing: 4 }}>
Traffic Intelligence
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
Analytics
</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center">
<ToggleButtonGroup
value={interval}
exclusive
size="small"
onChange={(_e, v) => { if (v) setIntervalVal(v); }}
>
<ToggleButton value="24h">24h</ToggleButton>
<ToggleButton value="7d">7d</ToggleButton>
<ToggleButton value="30d">30d</ToggleButton>
</ToggleButtonGroup>
<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={host}
onChange={(e: SelectChangeEvent) => setHost(e.target.value)}
displayEmpty
>
<MenuItem value="all">All hosts</MenuItem>
{hosts.map(h => <MenuItem key={h} value={h}>{h}</MenuItem>)}
</Select>
</FormControl>
</Stack>
</Stack>
{/* Logging disabled alert */}
{summary?.loggingDisabled && (
<Alert severity="warning">
Caddy access logging is not enabled no traffic data is being collected.{' '}
<Link href="/settings" style={{ color: 'inherit' }}>Enable logging in Settings</Link>.
</Alert>
)}
{/* Loading overlay */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
)}
{!loading && summary && (
<>
{/* Stats row */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard label="Total Requests" value={summary.totalRequests.toLocaleString()} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard label="Unique IPs" value={summary.uniqueIps.toLocaleString()} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
label="Blocked Requests"
value={summary.blockedRequests.toLocaleString()}
color={summary.blockedRequests > 0 ? '#ef4444' : undefined}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
label="Block Rate"
value={`${summary.blockedPercent}%`}
sub={`${formatBytes(summary.bytesServed)} served`}
color={summary.blockedPercent > 10 ? '#f59e0b' : undefined}
/>
</Grid>
</Grid>
{/* Timeline */}
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Requests Over Time
</Typography>
{timeline.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data for this period</Box>
) : (
<ReactApexChart
type="area"
series={timelineSeries}
options={timelineOptions}
height={220}
/>
)}
</CardContent>
</Card>
{/* World map + Countries */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 7 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
Traffic by Country
</Typography>
<WorldMap data={countries} />
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 5 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent sx={{ p: '16px !important' }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1.5 }}>
Top Countries
</Typography>
{countries.length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center', color: 'text.secondary' }}>No geo data available</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Country</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Requests</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Blocked</TableCell>
</TableRow>
</TableHead>
<TableBody>
{countries.slice(0, 10).map(c => (
<TableRow key={c.countryCode} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<span>{countryFlag(c.countryCode)}</span>
<Typography variant="body2">{c.countryCode}</Typography>
</Stack>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{c.total.toLocaleString()}</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" color={c.blocked > 0 ? 'error.light' : 'text.secondary'}>
{c.blocked.toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Protocols + User Agents */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 5 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
HTTP Protocols
</Typography>
{protocols.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data</Box>
) : (
<>
<ReactApexChart type="donut" series={donutSeries} options={donutOptions} height={220} />
<Table size="small" sx={{ mt: 1 }}>
<TableBody>
{protocols.map(p => (
<TableRow key={p.proto} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell><Typography variant="body2">{p.proto}</Typography></TableCell>
<TableCell align="right"><Typography variant="body2">{p.count.toLocaleString()}</Typography></TableCell>
<TableCell align="right"><Typography variant="body2" color="text.secondary">{p.percent}%</Typography></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 7 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Top User Agents
</Typography>
{userAgents.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data</Box>
) : (
<ReactApexChart type="bar" series={barSeries} options={barOptions} height={260} />
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Recent Blocked Requests */}
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Recent Blocked Requests
</Typography>
{!blocked || blocked.events.length === 0 ? (
<Paper elevation={0} sx={{ py: 5, textAlign: 'center', color: 'text.secondary', bgcolor: 'rgba(12,18,30,0.5)' }}>
No blocked requests in this period
</Paper>
) : (
<>
<Table size="small">
<TableHead>
<TableRow>
{['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => (
<TableCell key={h} sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{blocked.events.map(ev => (
<TableRow key={ev.id} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary">
{new Date(ev.ts * 1000).toLocaleString()}
</Typography>
</TableCell>
<TableCell><Typography variant="body2" fontFamily="monospace">{ev.clientIp}</Typography></TableCell>
<TableCell>
<Typography variant="body2">
{ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'}
</Typography>
</TableCell>
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2">{ev.host || '—'}</Typography>
</TableCell>
<TableCell><Typography variant="body2" fontFamily="monospace">{ev.method}</Typography></TableCell>
<TableCell sx={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" fontFamily="monospace" title={ev.uri}>{ev.uri}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="error.light" fontFamily="monospace">{ev.status}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{blocked.pages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={blocked.pages}
page={blocked.page}
onChange={(_e, p) => fetchBlockedPage(p)}
color="primary"
size="small"
/>
</Box>
)}
</>
)}
</CardContent>
</Card>
</>
)}
</Stack>
);
}
+117
View File
@@ -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<string, string> = {
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<string, string> = 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<PathEntry[] | null>(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 (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 240 }}>
<CircularProgress size={24} />
</Box>
);
}
const countMap = new Map(data.map(d => [d.countryCode, d.total]));
const max = data.reduce((m, d) => Math.max(m, d.total), 0);
return (
<Box sx={{ width: '100%', lineHeight: 0 }}>
<svg viewBox="0 0 960 500" style={{ width: '100%', height: 'auto' }}>
{paths.map(({ id, d }) => {
const alpha2 = N2A[id] ?? null;
const count = alpha2 ? (countMap.get(alpha2) ?? 0) : 0;
return (
<path
key={id}
d={d}
fill={colorForCount(count, max)}
stroke="#0f172a"
strokeWidth={0.5}
>
{alpha2 && count > 0 && (
<title>{alpha2}: {count.toLocaleString()} requests</title>
)}
</path>
);
})}
</svg>
</Box>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireUser } from '@/src/lib/auth';
import AnalyticsClient from './AnalyticsClient';
export default async function AnalyticsPage() {
await requireUser();
return <AnalyticsClient />;
}
+17 -12
View File
@@ -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<StatCard[]> {
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 (
<OverviewClient
userName={session.user.name ?? session.user.email ?? "Admin"}
stats={stats}
recentEvents={recentEvents.map((event) => ({
trafficSummary={trafficSummary}
recentEvents={recentEventsRaw.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entityType}`,
created_at: toIso(event.createdAt)!
}))}
+13
View File
@@ -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);
}
+12
View File
@@ -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);
}
+9
View File
@@ -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);
}
+12
View File
@@ -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);
}
+12
View File
@@ -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);
}
+12
View File
@@ -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);
}
+12
View File
@@ -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);
}
+3
View File
@@ -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
+24
View File
@@ -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
);
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -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
}
]
}
}
+160 -1
View File
@@ -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",
+9 -2
View File
@@ -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"
+20
View File
@@ -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 {
+244
View File
@@ -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<AnalyticsSummary> {
const loggingDisabled = !existsSync(LOG_FILE);
const where = buildWhere(interval, host);
const row = db
.select({
total: sql<number>`count(*)`,
uniqueIps: sql<number>`count(distinct ${trafficEvents.clientIp})`,
blocked: sql<number>`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`,
bytes: sql<number>`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<TimelineBucket[]> {
const bucketSize = interval === '24h' ? 3600 : interval === '7d' ? 21600 : 86400;
const where = buildWhere(interval, host);
const rows = db
.select({
bucket: sql<number>`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`,
total: sql<number>`count(*)`,
blocked: sql<number>`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<CountryStats[]> {
const where = buildWhere(interval, host);
const rows = db
.select({
countryCode: trafficEvents.countryCode,
total: sql<number>`count(*)`,
blocked: sql<number>`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<ProtoStats[]> {
const where = buildWhere(interval, host);
const rows = db
.select({
proto: trafficEvents.proto,
count: sql<number>`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<UAStats[]> {
const where = buildWhere(interval, host);
const rows = db
.select({
userAgent: trafficEvents.userAgent,
count: sql<number>`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<BlockedPage> {
const pageSize = 10;
const where = and(buildWhere(interval, host), eq(trafficEvents.isBlocked, true));
const totalRow = db.select({ total: sql<number>`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<string[]> {
const rows = db
.selectDistinct({ host: trafficEvents.host })
.from(trafficEvents)
.orderBy(trafficEvents.host)
.all();
return rows.map((r) => r.host).filter(Boolean);
}
+27
View File
@@ -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(),
});
+187
View File
@@ -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<ReturnType<typeof maxmind.open<CountryResponse>>> | null = null;
const geoCache = new Map<string, string | null>();
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<void> {
if (!existsSync(GEOIP_DB)) {
console.log('[log-parser] GeoIP database not found, country codes will be null');
return;
}
try {
geoReader = await maxmind.open<CountryResponse>(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<string, string[]>;
};
}
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<void> {
await initGeoIP();
console.log('[log-parser] initialized');
}
export async function parseNewLogEntries(): Promise<void> {
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;
}