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
-
+
)}
-
+
);
}