feat: add custom date range picker, fix country click highlight on map

- Analytics default interval changed to 1h
- Add 'Custom' toggle option with datetime-local pickers (pre-filled to last 24h)
- Refactor analytics-db: buildWhere now takes from/to unix timestamps instead of Interval
- Export INTERVAL_SECONDS from analytics-db for route reuse
- All 6 API routes accept from/to params (fallback to interval if absent)
- Timeline bucket size computed from duration rather than hardcoded per interval
- Fix map country click highlight: bake isSelected into GeoJSON features (data-driven)
  instead of relying on Layer filter prop updates (unreliable in react-map-gl v8)
- Split highlight into countries-selected (data-driven) and countries-hover (filter-driven)
- Show tooltip at country centroid when selected via table, hover takes precedence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-27 10:38:02 +01:00
parent 608fb9c6fe
commit 9e2007eb0c
10 changed files with 311 additions and 100 deletions
+105 -16
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import {
@@ -44,6 +44,17 @@ const WorldMap = dynamic(() => import('./WorldMapInner'), {
// ── Types (mirrored from analytics-db — can't import server-only code) ────────
type Interval = '1h' | '12h' | '24h' | '7d' | '30d';
type DisplayInterval = Interval | 'custom';
const INTERVAL_SECONDS_CLIENT: Record<Interval, number> = {
'1h': 3600, '12h': 43200, '24h': 86400, '7d': 7 * 86400, '30d': 30 * 86400,
};
/** Format a datetime-local string for <input type="datetime-local"> (local time, no seconds) */
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
interface AnalyticsSummary {
totalRequests: number;
@@ -97,12 +108,10 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatTs(ts: number, interval: Interval): string {
function formatTs(ts: number, rangeSeconds: number): string {
const d = new Date(ts * 1000);
if (interval === '1h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (interval === '12h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
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' });
if (rangeSeconds <= 86400) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (rangeSeconds <= 7 * 86400) return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
@@ -134,10 +143,14 @@ function StatCard({ label, value, sub, color }: { label: string; value: string;
// ── Main component ────────────────────────────────────────────────────────────
export default function AnalyticsClient() {
const [interval, setIntervalVal] = useState<Interval>('24h');
const [interval, setIntervalVal] = useState<DisplayInterval>('1h');
const [host, setHost] = useState<string>('all');
const [hosts, setHosts] = useState<string[]>([]);
// Custom range (datetime-local strings: "YYYY-MM-DDTHH:MM")
const [customFrom, setCustomFrom] = useState<string>('');
const [customTo, setCustomTo] = useState<string>('');
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [timeline, setTimeline] = useState<TimelineBucket[]>([]);
const [countries, setCountries] = useState<CountryStats[]>([]);
@@ -147,15 +160,42 @@ export default function AnalyticsClient() {
const [loading, setLoading] = useState(true);
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
/** How many seconds the current selection spans — used for chart axis labels */
const rangeSeconds = useMemo(() => {
if (interval === 'custom' && customFrom && customTo) {
const diff = Math.floor(new Date(customTo).getTime() / 1000) - Math.floor(new Date(customFrom).getTime() / 1000);
return diff > 0 ? diff : 3600;
}
return INTERVAL_SECONDS_CLIENT[interval as Interval] ?? 3600;
}, [interval, customFrom, customTo]);
/** Build the query string for all analytics endpoints */
const buildParams = useCallback((extra = '') => {
const h = `host=${encodeURIComponent(host)}`;
if (interval === 'custom' && customFrom && customTo) {
const from = Math.floor(new Date(customFrom).getTime() / 1000);
const to = Math.floor(new Date(customTo).getTime() / 1000);
return `?from=${from}&to=${to}&${h}${extra}`;
}
return `?interval=${interval}&${h}${extra}`;
}, [interval, host, customFrom, customTo]);
// Fetch hosts once
useEffect(() => {
fetch('/api/analytics/hosts').then(r => r.json()).then(setHosts).catch(() => {});
}, []);
// Fetch all analytics data when interval or host changes
// Fetch all analytics data when range/host changes
// For custom: only fetch when both dates are set and from < to
useEffect(() => {
if (interval === 'custom') {
if (!customFrom || !customTo) return;
const from = new Date(customFrom).getTime();
const to = new Date(customTo).getTime();
if (from >= to) return;
}
setLoading(true);
const params = `?interval=${interval}&host=${encodeURIComponent(host)}`;
const params = buildParams();
Promise.all([
fetch(`/api/analytics/summary${params}`).then(r => r.json()),
fetch(`/api/analytics/timeline${params}`).then(r => r.json()),
@@ -171,16 +211,15 @@ export default function AnalyticsClient() {
setUserAgents(u);
setBlocked(b);
}).catch(() => {}).finally(() => setLoading(false));
}, [interval, host]);
}, [buildParams, interval, customFrom, customTo]);
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]);
fetch(`/api/analytics/blocked${buildParams(`&page=${page}`)}`).then(r => r.json()).then(setBlocked).catch(() => {});
}, [buildParams]);
// ── Chart configs ─────────────────────────────────────────────────────────
const timelineLabels = timeline.map(b => formatTs(b.ts, interval));
const timelineLabels = timeline.map(b => formatTs(b.ts, rangeSeconds));
const timelineOptions: ApexOptions = {
...DARK_CHART,
chart: { ...DARK_CHART.chart, type: 'area', stacked: true, id: 'timeline' },
@@ -235,19 +274,69 @@ export default function AnalyticsClient() {
Analytics
</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
<ToggleButtonGroup
value={interval}
exclusive
size="small"
onChange={(_e, v) => { if (v) setIntervalVal(v); }}
onChange={(_e, v) => {
if (!v) return;
if (v === 'custom' && !customFrom) {
// Pre-fill custom range with last 24h
const now = new Date();
const ago = new Date(now.getTime() - 86400 * 1000);
setCustomFrom(toDatetimeLocal(ago));
setCustomTo(toDatetimeLocal(now));
}
setIntervalVal(v);
}}
>
<ToggleButton value="1h">1h</ToggleButton>
<ToggleButton value="12h">12h</ToggleButton>
<ToggleButton value="24h">24h</ToggleButton>
<ToggleButton value="7d">7d</ToggleButton>
<ToggleButton value="30d">30d</ToggleButton>
<ToggleButton value="custom">Custom</ToggleButton>
</ToggleButtonGroup>
{interval === 'custom' && (
<Stack direction="row" spacing={1} alignItems="center">
<input
type="datetime-local"
value={customFrom}
max={customTo || undefined}
onChange={e => setCustomFrom(e.target.value)}
style={{
background: 'rgba(30,41,59,0.8)',
border: '1px solid rgba(148,163,184,0.2)',
borderRadius: 6,
color: '#f1f5f9',
padding: '5px 8px',
fontSize: 13,
colorScheme: 'dark',
outline: 'none',
}}
/>
<Typography variant="caption" color="text.disabled"></Typography>
<input
type="datetime-local"
value={customTo}
min={customFrom || undefined}
onChange={e => setCustomTo(e.target.value)}
style={{
background: 'rgba(30,41,59,0.8)',
border: '1px solid rgba(148,163,184,0.2)',
borderRadius: 6,
color: '#f1f5f9',
padding: '5px 8px',
fontSize: 13,
colorScheme: 'dark',
outline: 'none',
}}
/>
</Stack>
)}
<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={host}
+90 -42
View File
@@ -140,8 +140,8 @@ const FILL_LAYER: Omit<FillLayerSpecification, 'source'> = {
},
};
const HIGHLIGHT_LAYER: Omit<FillLayerSpecification, 'source'> = {
id: 'countries-highlight',
const SELECTED_LAYER: Omit<FillLayerSpecification, 'source'> = {
id: 'countries-selected',
type: 'fill',
paint: {
'fill-color': '#7dd3fc',
@@ -149,6 +149,15 @@ const HIGHLIGHT_LAYER: Omit<FillLayerSpecification, 'source'> = {
},
};
const HOVER_LAYER: Omit<FillLayerSpecification, 'source'> = {
id: 'countries-hover',
type: 'fill',
paint: {
'fill-color': '#7dd3fc',
'fill-opacity': 0.30,
},
};
const OUTLINE_LAYER: Omit<LineLayerSpecification, 'source'> = {
id: 'countries-outline',
type: 'line',
@@ -195,10 +204,11 @@ export default function WorldMapInner({ data, selectedCountry }: { data: Country
const alpha2 = N2A[String(Number(f.id ?? 0))] ?? null;
const total = alpha2 ? (countMap.get(alpha2) ?? 0) : 0;
const blocked = alpha2 ? (blockedMap.get(alpha2) ?? 0) : 0;
return { ...f, properties: { ...f.properties, alpha2, total, blocked, norm: total / safeMax } };
const isSelected = alpha2 !== null && alpha2 === selectedCountry;
return { ...f, properties: { ...f.properties, alpha2, total, blocked, norm: total / safeMax, isSelected } };
}),
};
}, [baseGeojson, countMap, blockedMap, max]);
}, [baseGeojson, countMap, blockedMap, max, selectedCountry]);
const onHover = useCallback((event: MapLayerMouseEvent) => {
const f = event.features?.[0];
@@ -213,10 +223,41 @@ export default function WorldMapInner({ data, selectedCountry }: { data: Country
});
}, []);
const highlightFilter = useMemo<ExpressionSpecification>(() => {
const target = hoverInfo?.alpha2 ?? selectedCountry ?? null;
return target ? ['==', ['get', 'alpha2'], target] : ['boolean', false];
}, [hoverInfo?.alpha2, selectedCountry]);
// Hover filter: only tracks mouse position, changes on every mousemove
const hoverFilter = useMemo<ExpressionSpecification>(() => {
const a2 = hoverInfo?.alpha2 ?? null;
return a2 ? ['==', ['get', 'alpha2'], a2] : ['boolean', false];
}, [hoverInfo?.alpha2]);
// Centroid popup for the table-selected country (shown when not hovering)
const selectedInfo = useMemo(() => {
if (!selectedCountry || !geojson) return null;
const feat = geojson.features.find(f => f.properties?.alpha2 === selectedCountry);
if (!feat?.geometry) return null;
const positions: GeoJSON.Position[] = [];
const collect = (geom: GeoJSON.Geometry) => {
if (geom.type === 'Polygon') geom.coordinates.forEach(r => positions.push(...r));
else if (geom.type === 'MultiPolygon') geom.coordinates.forEach(p => p.forEach(r => positions.push(...r)));
};
collect(feat.geometry);
if (positions.length === 0) return null;
const lngs = positions.map(c => c[0]);
const lats = positions.map(c => c[1]);
// Clamp longitude to [-180, 180] for the popup anchor
const rawLng = (Math.min(...lngs) + Math.max(...lngs)) / 2;
const longitude = ((rawLng + 180) % 360 + 360) % 360 - 180;
const latitude = Math.max(-85, Math.min(85, (Math.min(...lats) + Math.max(...lats)) / 2));
return {
longitude,
latitude,
alpha2: selectedCountry,
total: (feat.properties?.total as number) ?? 0,
blocked: (feat.properties?.blocked as number) ?? 0,
};
}, [selectedCountry, geojson]);
if (!geojson) {
return (
@@ -267,45 +308,52 @@ export default function WorldMapInner({ data, selectedCountry }: { data: Country
>
<Source id="countries" type="geojson" data={geojson}>
<Layer {...FILL_LAYER} source="countries" />
<Layer {...HIGHLIGHT_LAYER} source="countries" filter={highlightFilter} />
{/* Selected: data-driven via isSelected property baked into geojson — reliable on click */}
<Layer {...SELECTED_LAYER} source="countries" filter={['==', ['get', 'isSelected'], true]} />
{/* Hover: filter-driven, changes on mousemove */}
<Layer {...HOVER_LAYER} source="countries" filter={hoverFilter} />
<Layer {...OUTLINE_LAYER} source="countries" />
</Source>
{hoverInfo && (
<Popup
longitude={hoverInfo.longitude}
latitude={hoverInfo.latitude}
offset={[0, -6] as [number, number]}
closeButton={false}
closeOnClick={false}
anchor="bottom"
className="wm-popup"
>
<div style={{ color: '#f1f5f9', fontFamily: 'inherit', fontSize: 13 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 7, fontWeight: 600, fontSize: 14 }}>
<span style={{ fontSize: 20, lineHeight: 1 }}>{hoverInfo.alpha2 ? flag(hoverInfo.alpha2) : '🌐'}</span>
<span>{hoverInfo.alpha2 ? (NAMES[hoverInfo.alpha2] ?? hoverInfo.alpha2) : 'Territory'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20 }}>
<span style={{ color: '#94a3b8' }}>Requests</span>
<span style={{ color: '#60a5fa', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{hoverInfo.total.toLocaleString()}
</span>
</div>
{hoverInfo.blocked > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, marginTop: 3 }}>
<span style={{ color: '#94a3b8' }}>Blocked</span>
<span style={{ color: '#f87171', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{hoverInfo.blocked.toLocaleString()}
{/* Hover popup (takes precedence) or selected-country popup */}
{(hoverInfo ?? selectedInfo) && (() => {
const info = hoverInfo ?? selectedInfo!;
return (
<Popup
longitude={info.longitude}
latitude={info.latitude}
offset={[0, -6] as [number, number]}
closeButton={false}
closeOnClick={false}
anchor="bottom"
className="wm-popup"
>
<div style={{ color: '#f1f5f9', fontFamily: 'inherit', fontSize: 13 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 7, fontWeight: 600, fontSize: 14 }}>
<span style={{ fontSize: 20, lineHeight: 1 }}>{info.alpha2 ? flag(info.alpha2) : '🌐'}</span>
<span>{info.alpha2 ? (NAMES[info.alpha2] ?? info.alpha2) : 'Territory'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20 }}>
<span style={{ color: '#94a3b8' }}>Requests</span>
<span style={{ color: '#60a5fa', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{info.total.toLocaleString()}
</span>
</div>
)}
{hoverInfo.total === 0 && (
<div style={{ color: '#475569', marginTop: 3, fontSize: 12 }}>No traffic recorded</div>
)}
</div>
</Popup>
)}
{info.blocked > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, marginTop: 3 }}>
<span style={{ color: '#94a3b8' }}>Blocked</span>
<span style={{ color: '#f87171', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{info.blocked.toLocaleString()}
</span>
</div>
)}
{info.total === 0 && (
<div style={{ color: '#475569', marginTop: 3, fontSize: 12 }}>No traffic recorded</div>
)}
</div>
</Popup>
);
})()}
</MapGL>
</Box>
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function OverviewPage() {
const session = await requireAdmin();
const [stats, trafficSummary, recentEventsRaw] = await Promise.all([
loadStats(),
getAnalyticsSummary('24h', 'all').catch(() => null),
getAnalyticsSummary(Math.floor(Date.now() / 1000) - 86400, Math.floor(Date.now() / 1000), 'all').catch(() => null),
db
.select({
action: auditEvents.action,
+15 -3
View File
@@ -1,13 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsBlocked, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsBlocked, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsBlocked(from, to, host, page);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+15 -3
View File
@@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsCountries, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsCountries, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsCountries(from, to, host);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+15 -3
View File
@@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsProtocols, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsProtocols, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsProtocols(from, to, host);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+15 -3
View File
@@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsSummary, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsSummary, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsSummary(from, to, host);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+15 -3
View File
@@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsTimeline, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsTimeline, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsTimeline(from, to, host);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+15 -3
View File
@@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/src/lib/auth';
import { getAnalyticsUserAgents, type Interval } from '@/src/lib/analytics-db';
import { getAnalyticsUserAgents, INTERVAL_SECONDS } 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);
const { from, to } = resolveRange(searchParams);
const data = await getAnalyticsUserAgents(from, to, host);
return NextResponse.json(data);
}
function resolveRange(params: URLSearchParams): { from: number; to: number } {
const fromParam = params.get('from');
const toParam = params.get('to');
if (fromParam && toParam) {
return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) };
}
const interval = params.get('interval') ?? '1h';
const to = Math.floor(Date.now() / 1000);
const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']);
return { from, to };
}
+25 -23
View File
@@ -1,4 +1,4 @@
import { sql, and, gte, eq } from 'drizzle-orm';
import { sql, and, gte, lte, eq } from 'drizzle-orm';
import db from './db';
import { trafficEvents } from './db/schema';
import { existsSync } from 'node:fs';
@@ -7,7 +7,7 @@ export type Interval = '1h' | '12h' | '24h' | '7d' | '30d';
const LOG_FILE = '/logs/access.log';
const INTERVAL_SECONDS: Record<Interval, number> = {
export const INTERVAL_SECONDS: Record<Interval, number> = {
'1h': 3600,
'12h': 43200,
'24h': 86400,
@@ -15,13 +15,8 @@ const INTERVAL_SECONDS: Record<Interval, number> = {
'30d': 30 * 86400,
};
function getIntervalStart(interval: Interval): number {
return Math.floor(Date.now() / 1000) - INTERVAL_SECONDS[interval];
}
function buildWhere(interval: Interval, host: string) {
const since = getIntervalStart(interval);
const conditions = [gte(trafficEvents.ts, since)];
function buildWhere(from: number, to: number, host: string) {
const conditions = [gte(trafficEvents.ts, from), lte(trafficEvents.ts, to)];
if (host !== 'all' && host !== '') conditions.push(eq(trafficEvents.host, host));
return and(...conditions);
}
@@ -37,9 +32,9 @@ export interface AnalyticsSummary {
loggingDisabled: boolean;
}
export async function getAnalyticsSummary(interval: Interval, host: string): Promise<AnalyticsSummary> {
export async function getAnalyticsSummary(from: number, to: number, host: string): Promise<AnalyticsSummary> {
const loggingDisabled = !existsSync(LOG_FILE);
const where = buildWhere(interval, host);
const where = buildWhere(from, to, host);
const row = db
.select({
@@ -73,10 +68,17 @@ export interface TimelineBucket {
blocked: number;
}
export async function getAnalyticsTimeline(interval: Interval, host: string): Promise<TimelineBucket[]> {
const BUCKET: Record<Interval, number> = { '1h': 300, '12h': 1800, '24h': 3600, '7d': 21600, '30d': 86400 };
const bucketSize = BUCKET[interval];
const where = buildWhere(interval, host);
function bucketSizeForDuration(seconds: number): number {
if (seconds <= 3600) return 300;
if (seconds <= 43200) return 1800;
if (seconds <= 86400) return 3600;
if (seconds <= 7 * 86400) return 21600;
return 86400;
}
export async function getAnalyticsTimeline(from: number, to: number, host: string): Promise<TimelineBucket[]> {
const bucketSize = bucketSizeForDuration(to - from);
const where = buildWhere(from, to, host);
const rows = db
.select({
@@ -105,8 +107,8 @@ export interface CountryStats {
blocked: number;
}
export async function getAnalyticsCountries(interval: Interval, host: string): Promise<CountryStats[]> {
const where = buildWhere(interval, host);
export async function getAnalyticsCountries(from: number, to: number, host: string): Promise<CountryStats[]> {
const where = buildWhere(from, to, host);
const rows = db
.select({
@@ -135,8 +137,8 @@ export interface ProtoStats {
percent: number;
}
export async function getAnalyticsProtocols(interval: Interval, host: string): Promise<ProtoStats[]> {
const where = buildWhere(interval, host);
export async function getAnalyticsProtocols(from: number, to: number, host: string): Promise<ProtoStats[]> {
const where = buildWhere(from, to, host);
const rows = db
.select({
@@ -166,8 +168,8 @@ export interface UAStats {
percent: number;
}
export async function getAnalyticsUserAgents(interval: Interval, host: string): Promise<UAStats[]> {
const where = buildWhere(interval, host);
export async function getAnalyticsUserAgents(from: number, to: number, host: string): Promise<UAStats[]> {
const where = buildWhere(from, to, host);
const rows = db
.select({
@@ -210,9 +212,9 @@ export interface BlockedPage {
pages: number;
}
export async function getAnalyticsBlocked(interval: Interval, host: string, page: number): Promise<BlockedPage> {
export async function getAnalyticsBlocked(from: number, to: number, host: string, page: number): Promise<BlockedPage> {
const pageSize = 10;
const where = and(buildWhere(interval, host), eq(trafficEvents.isBlocked, true));
const where = and(buildWhere(from, to, host), eq(trafficEvents.isBlocked, true));
const totalRow = db.select({ total: sql<number>`count(*)` }).from(trafficEvents).where(where).get();
const total = totalRow?.total ?? 0;