diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index 1e98dbdf..e03a315f 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -3,37 +3,27 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; +import dayjs, { type Dayjs } from 'dayjs'; +import { toast } from 'sonner'; +import { ChevronLeft, ChevronRight, Check, ChevronsUpDown, X } from 'lucide-react'; +import type { ApexOptions } from 'apexcharts'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { - Alert, - Autocomplete, - Box, - Button, - Card, - CardContent, - Checkbox, - Chip, - CircularProgress, - Divider, - Grid, - ListItemText, - Pagination, - Paper, - Stack, Table, TableBody, TableCell, TableHead, + TableHeader, TableRow, - TextField, - ToggleButton, - ToggleButtonGroup, - Tooltip, - Typography, -} from '@mui/material'; -import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import dayjs, { type Dayjs } from 'dayjs'; -import type { ApexOptions } from 'apexcharts'; +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; // ── Dynamic imports (browser-only) ──────────────────────────────────────────── @@ -42,9 +32,9 @@ const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }) const WorldMap = dynamic(() => import('./WorldMapInner'), { ssr: false, loading: () => ( - - - +
+ +
), }) as React.ComponentType<{ data: import('./WorldMapInner').CountryStats[]; selectedCountry?: string | null }>; @@ -127,21 +117,184 @@ const DARK_CHART: ApexOptions = { tooltip: { theme: 'dark' }, }; +// ── Local DateTimePicker ─────────────────────────────────────────────────────── + +function DateTimePicker({ + value, + onChange, + placeholder, +}: { + value: Dayjs | null; + onChange: (v: Dayjs | null) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [timeStr, setTimeStr] = useState(value ? value.format('HH:mm') : '00:00'); + + // Keep timeStr in sync when value changes externally + useEffect(() => { + if (value) setTimeStr(value.format('HH:mm')); + }, [value]); + + const selectedDate = value ? value.toDate() : undefined; + + function handleDaySelect(day: Date | undefined) { + if (!day) return; + const [hh, mm] = timeStr.split(':').map(Number); + const next = dayjs(day).hour(hh || 0).minute(mm || 0).second(0); + onChange(next); + } + + function handleTimeChange(e: React.ChangeEvent) { + setTimeStr(e.target.value); + if (value) { + const [hh, mm] = e.target.value.split(':').map(Number); + onChange(value.hour(hh || 0).minute(mm || 0).second(0)); + } + } + + const label = value ? value.format('DD/MM/YYYY HH:mm') : (placeholder ?? 'Pick date & time'); + + return ( + + + + + + +
+ Time: + +
+
+
+ ); +} + // ── Stat card ───────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { return ( - - - - {label} - - - {value} - - {sub && {sub}} - - +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +// ── Hosts multi-select combobox ─────────────────────────────────────────────── + +function HostsCombobox({ + allHosts, + selectedHosts, + onChange, +}: { + allHosts: string[]; + selectedHosts: string[]; + onChange: (v: string[]) => void; +}) { + const [open, setOpen] = useState(false); + + function toggle(host: string) { + if (selectedHosts.includes(host)) { + onChange(selectedHosts.filter(h => h !== host)); + } else { + onChange([...selectedHosts, host]); + } + } + + const label = + selectedHosts.length === 0 + ? 'All hosts' + : selectedHosts.length <= 2 + ? selectedHosts.join(', ') + : `${selectedHosts.length} hosts`; + + return ( + + + + + + + +
+ + · + +
+ + No hosts found. + + {allHosts.map(host => ( + toggle(host)} className="text-xs"> + + {host} + + ))} + + +
+ {selectedHosts.length > 0 && ( +
+ {selectedHosts.length <= 2 + ? selectedHosts.map(h => ( + + {h} + + + )) + : ( + + {selectedHosts.length} hosts + + + ) + } +
+ )} +
+
); } @@ -215,7 +368,9 @@ export default function AnalyticsClient() { setUserAgents(u); setBlocked(b); setWafStats(w); - }).catch(() => {}).finally(() => setLoading(false)); + }).catch(() => { + toast.error('Failed to load analytics data'); + }).finally(() => setLoading(false)); }, [buildParams, interval, customFrom, customTo]); const fetchBlockedPage = useCallback((page: number) => { @@ -279,467 +434,322 @@ export default function AnalyticsClient() { const wafByCountry = new Map((wafStats?.byCountry ?? []).map(r => [r.countryCode, r.count])); + const INTERVALS: DisplayInterval[] = ['1h', '12h', '24h', '7d', '30d', 'custom']; + // ── Render ──────────────────────────────────────────────────────────────── return ( - +
{/* Header */} - - - - Traffic Intelligence - - - Analytics - - - - - { - if (!v) return; - if (v === 'custom' && !customFrom) { - setCustomFrom(dayjs().subtract(24, 'hour')); - setCustomTo(dayjs()); - } - setIntervalVal(v); - }} - > - 1h - 12h - 24h - 7d - 30d - Custom - +
+
+

Traffic Intelligence

+

Analytics

+
+
+ {/* Interval toggle group */} +
+ {INTERVALS.map(iv => ( + + ))} +
- {interval === 'custom' && ( - - - - - - )} + {interval === 'custom' && ( +
+ + + +
+ )} - setSelectedHosts(v)} - disableCloseOnSelect - limitTags={2} - sx={{ width: { xs: '100%', sm: 260 }, flexShrink: 0 }} - ListboxProps={{ - // Prevent scroll from the dropdown list leaking to the page - style: { overscrollBehavior: 'contain' }, - }} - PaperComponent={({ children, ...paperProps }) => ( - - {/* Select all / none — onMouseDown preventDefault keeps the popup open */} - e.preventDefault()} - sx={{ display: 'flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.5 }} - > - - · - - - - {children} - - )} - renderOption={(props, option, { selected }) => ( -
  • - - -
  • - )} - renderTags={(value, getTagProps) => { - if (value.length <= 2) { - return value.map((option, index) => ( - - )); - } - // Collapse to a single count chip so the input never grows tall - return [ - setSelectedHosts([])} - />, - ]; - }} - renderInput={(params) => ( - - )} - /> - - - + +
    +
    {/* Logging disabled alert */} {summary?.loggingDisabled && ( - +
    Caddy access logging is not enabled — no traffic data is being collected.{' '} - Enable logging in Settings. - + Enable logging in Settings. +
    )} {/* Loading overlay */} {loading && ( - - - +
    + +
    )} {!loading && summary && ( <> {/* Stats row */} - - - - - - - - - 0 ? `${wafStats!.total.toLocaleString()} from WAF` : undefined} - color={summary.blockedRequests > 0 ? '#ef4444' : undefined} - /> - - - 10 ? '#f59e0b' : undefined} - /> - - - 0 ? `${wafStats.topRules.length} rules triggered` : 'No WAF events'} - color={(wafStats?.total ?? 0) > 0 ? '#f59e0b' : undefined} - /> - - +
    + + + 0 ? `${wafStats!.total.toLocaleString()} from WAF` : undefined} + color={summary.blockedRequests > 0 ? '#ef4444' : undefined} + /> + 10 ? '#f59e0b' : undefined} + /> + 0 ? `${wafStats.topRules.length} rules triggered` : 'No WAF events'} + color={(wafStats?.total ?? 0) > 0 ? '#f59e0b' : undefined} + /> +
    {/* Timeline */} - - - - Requests Over Time - - {timeline.length === 0 ? ( - No data for this period - ) : ( - - - - )} - - +
    +

    Requests Over Time

    + {timeline.length === 0 ? ( +
    No data for this period
    + ) : ( +
    + +
    + )} +
    {/* World map + Countries */} - - - - - - Traffic by Country - - - - - - - - - - - - Top Countries - - {countries.length === 0 ? ( - No geo data available - ) : ( - - - - Country - Requests - WAF - Blocked +
    +
    +

    Traffic by Country

    +
    + +
    +
    +
    +

    Top Countries

    + {countries.length === 0 ? ( +
    No geo data available
    + ) : ( +
    + + + Country + Requests + WAF + Blocked + + + + {countries.slice(0, 10).map(c => { + const wafCount = wafByCountry.get(c.countryCode) ?? 0; + return ( + setSelectedCountry(s => s === c.countryCode ? null : c.countryCode)} + className={cn( + 'cursor-pointer', + selectedCountry === c.countryCode ? 'bg-sky-300/[0.08]' : 'hover:bg-sky-300/[0.05]', + )} + > + +
    + {countryFlag(c.countryCode)} + {c.countryCode} +
    +
    + {c.total.toLocaleString()} + 0 ? 'text-yellow-400' : 'text-muted-foreground')}> + {wafCount > 0 ? wafCount.toLocaleString() : '—'} + + 0 ? 'text-red-400' : 'text-muted-foreground')}> + {c.blocked.toLocaleString()} +
    - - - {countries.slice(0, 10).map(c => ( - setSelectedCountry(s => s === c.countryCode ? null : c.countryCode)} - sx={{ - cursor: 'pointer', - '& td': { borderColor: 'rgba(255,255,255,0.04)' }, - bgcolor: selectedCountry === c.countryCode ? 'rgba(125,211,252,0.08)' : 'transparent', - '&:hover': { bgcolor: 'rgba(125,211,252,0.05)' }, - }} - > - - - {countryFlag(c.countryCode)} - {c.countryCode} - - - - {c.total.toLocaleString()} - - - {(() => { const wafCount = wafByCountry.get(c.countryCode) ?? 0; return ( - 0 ? 'warning.light' : 'text.disabled'}> - {wafCount > 0 ? wafCount.toLocaleString() : '—'} - - ); })()} - - - 0 ? 'error.light' : 'text.secondary'}> - {c.blocked.toLocaleString()} - - - - ))} - -
    - )} -
    -
    -
    -
    + ); + })} + + + )} +
    + {/* Protocols + User Agents */} - - - - - - HTTP Protocols - - {protocols.length === 0 ? ( - No data - ) : ( - <> - - - - - - {protocols.map(p => ( - - {p.proto} - {p.count.toLocaleString()} - {p.percent}% - - ))} - -
    - - )} -
    -
    -
    - - - - - Top User Agents - - {userAgents.length === 0 ? ( - No data - ) : ( - - - - )} - - - -
    - - {/* Recent Blocked Requests */} - - - - Recent Blocked Requests - - {!blocked || blocked.events.length === 0 ? ( - - No blocked requests in this period - +
    +
    +

    HTTP Protocols

    + {protocols.length === 0 ? ( +
    No data
    ) : ( <> - - - - {['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => ( - {h} - ))} - - +
    + +
    +
    - {blocked.events.map(ev => ( - - - - {new Date(ev.ts * 1000).toLocaleString()} - - - {ev.clientIp} - - - {ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'} - - - - {ev.host || '—'} - - {ev.method} - - {ev.uri} - - - {ev.status} - + {protocols.map(p => ( + + {p.proto} + {p.count.toLocaleString()} + {p.percent}% ))}
    - {blocked.pages > 1 && ( - - fetchBlockedPage(p)} - color="primary" - size="small" - /> - - )} )} - - +
    +
    +

    Top User Agents

    + {userAgents.length === 0 ? ( +
    No data
    + ) : ( +
    + +
    + )} +
    +
    + + {/* Recent Blocked Requests */} +
    +

    Recent Blocked Requests

    + {!blocked || blocked.events.length === 0 ? ( +
    + No blocked requests in this period +
    + ) : ( + <> +
    + + + + {['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => ( + {h} + ))} + + + + {blocked.events.map(ev => ( + + + {new Date(ev.ts * 1000).toLocaleString()} + + {ev.clientIp} + + {ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'} + + + {ev.host || '—'} + + {ev.method} + + {ev.uri} + + {ev.status} + + ))} + +
    +
    + {blocked.pages > 1 && ( +
    + + + Page {blocked.page} of {blocked.pages} + + +
    + )} + + )} +
    + {/* WAF Top Rules */} {wafStats && wafStats.total > 0 && ( - - - - Top WAF Rules Triggered - - - - - - - - {['Rule', 'Description', 'Hits', 'Triggered by'].map(h => ( - {h} - ))} - - - - {wafStats.topRules.map(rule => ( - - - #{rule.ruleId} - - - {rule.message ? ( - - {rule.message} - - ) : ( - - )} - - - {rule.count.toLocaleString()} - - - - {rule.hosts.map(h => ( - - ))} - - - +
    +

    Top WAF Rules Triggered

    +
    + +
    +
    + + + {['Rule', 'Description', 'Hits', 'Triggered by'].map(h => ( + {h} ))} - -
    -
    -
    + + + + {wafStats.topRules.map(rule => ( + + #{rule.ruleId} + + {rule.message ? ( + + +

    {rule.message}

    +
    + {rule.message} +
    + ) : ( + + )} +
    + {rule.count.toLocaleString()} + +
    + {rule.hosts.map(h => ( + {h.host} ×{h.count} + ))} +
    +
    +
    + ))} +
    + + )} )} -
    + ); } diff --git a/app/(dashboard)/analytics/WorldMapInner.tsx b/app/(dashboard)/analytics/WorldMapInner.tsx index 46ea7907..c82fa31f 100644 --- a/app/(dashboard)/analytics/WorldMapInner.tsx +++ b/app/(dashboard)/analytics/WorldMapInner.tsx @@ -5,7 +5,7 @@ import MapGL, { Layer, Popup, Source, type MapLayerMouseEvent } from 'react-map- import { feature } from 'topojson-client'; import type { Topology, GeometryCollection } from 'topojson-specification'; import type { ExpressionSpecification, FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl'; -import { Box, CircularProgress, Typography } from '@mui/material'; +import { Skeleton } from '@/components/ui/skeleton'; import 'maplibre-gl/dist/maplibre-gl.css'; const A2N: Record = { @@ -261,14 +261,14 @@ export default function WorldMapInner({ data, selectedCountry }: { data: Country if (!geojson) { return ( - - - +
    + +
    ); } return ( - +
    {/* Override MapLibre popup chrome to match dark theme */} - +
    - +
    {max > 0 && ( - - Low - - High - +
    +

    Low

    +
    +

    High

    +
    )} - +
    ); }