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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user