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:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)!
|
||||
}))}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+160
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user