diff --git a/.gitignore b/.gitignore index c7180bfe..f62bdb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tsconfig.tsbuildinfo CLAUDE.local.md /caddy-proxy-manager.wiki docs/plans +/.playwright-mcp diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index 7cde8355..c4fc9321 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -5,27 +5,31 @@ import dynamic from 'next/dynamic'; import Link from 'next/link'; import { Alert, + Autocomplete, Box, Card, CardContent, + Checkbox, + Chip, CircularProgress, - FormControl, Grid, - MenuItem, + ListItemText, Pagination, Paper, - Select, - SelectChangeEvent, Stack, Table, TableBody, TableCell, TableHead, TableRow, + TextField, ToggleButton, ToggleButtonGroup, 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'; // ── Dynamic imports (browser-only) ──────────────────────────────────────────── @@ -50,11 +54,6 @@ const INTERVAL_SECONDS_CLIENT: Record = { '1h': 3600, '12h': 43200, '24h': 86400, '7d': 7 * 86400, '30d': 30 * 86400, }; -/** Format a datetime-local string for (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; @@ -144,12 +143,12 @@ function StatCard({ label, value, sub, color }: { label: string; value: string; export default function AnalyticsClient() { const [interval, setIntervalVal] = useState('1h'); - const [host, setHost] = useState('all'); - const [hosts, setHosts] = useState([]); + const [selectedHosts, setSelectedHosts] = useState([]); + const [allHosts, setAllHosts] = useState([]); - // Custom range (datetime-local strings: "YYYY-MM-DDTHH:MM") - const [customFrom, setCustomFrom] = useState(''); - const [customTo, setCustomTo] = useState(''); + // Custom range as Dayjs objects + const [customFrom, setCustomFrom] = useState(null); + const [customTo, setCustomTo] = useState(null); const [summary, setSummary] = useState(null); const [timeline, setTimeline] = useState([]); @@ -163,7 +162,7 @@ export default function AnalyticsClient() { /** 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); + const diff = customTo.unix() - customFrom.unix(); return diff > 0 ? diff : 3600; } return INTERVAL_SECONDS_CLIENT[interval as Interval] ?? 3600; @@ -171,28 +170,25 @@ export default function AnalyticsClient() { /** Build the query string for all analytics endpoints */ const buildParams = useCallback((extra = '') => { - const h = `host=${encodeURIComponent(host)}`; + const h = selectedHosts.length > 0 + ? `hosts=${selectedHosts.map(encodeURIComponent).join(',')}` + : ''; + const sep = h ? `&${h}` : ''; 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 `?from=${customFrom.unix()}&to=${customTo.unix()}${sep}${extra}`; } - return `?interval=${interval}&${h}${extra}`; - }, [interval, host, customFrom, customTo]); + return `?interval=${interval}${sep}${extra}`; + }, [interval, selectedHosts, customFrom, customTo]); - // Fetch hosts once + // Fetch all configured+active hosts once useEffect(() => { - fetch('/api/analytics/hosts').then(r => r.json()).then(setHosts).catch(() => {}); + fetch('/api/analytics/hosts').then(r => r.json()).then(setAllHosts).catch(() => {}); }, []); - // Fetch all analytics data when range/host changes - // For custom: only fetch when both dates are set and from < to + // Fetch all analytics data when range/host selection changes useEffect(() => { if (interval === 'custom') { - if (!customFrom || !customTo) return; - const from = new Date(customFrom).getTime(); - const to = new Date(customTo).getTime(); - if (from >= to) return; + if (!customFrom || !customTo || customFrom.unix() >= customTo.unix()) return; } setLoading(true); const params = buildParams(); @@ -274,80 +270,96 @@ export default function AnalyticsClient() { Analytics - - { - 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); - }} - > - 1h - 12h - 24h - 7d - 30d - Custom - - - {interval === 'custom' && ( - - 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', - }} - /> - - 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', - }} - /> - - )} - - - - - + 1h + 12h + 24h + 7d + 30d + Custom + + + {interval === 'custom' && ( + + + + + + )} + + setSelectedHosts(v)} + disableCloseOnSelect + limitTags={2} + sx={{ minWidth: 220, maxWidth: 380 }} + renderOption={(props, option, { selected }) => ( +
  • + + +
  • + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + {/* Logging disabled alert */} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 21af78ae..e5b3bee6 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -43,7 +43,7 @@ export default async function OverviewPage() { const session = await requireAdmin(); const [stats, trafficSummary, recentEventsRaw] = await Promise.all([ loadStats(), - getAnalyticsSummary(Math.floor(Date.now() / 1000) - 86400, Math.floor(Date.now() / 1000), 'all').catch(() => null), + getAnalyticsSummary(Math.floor(Date.now() / 1000) - 86400, Math.floor(Date.now() / 1000), []).catch(() => null), db .select({ action: auditEvents.action, diff --git a/app/api/analytics/blocked/route.ts b/app/api/analytics/blocked/route.ts index c49a13c7..c88b801d 100644 --- a/app/api/analytics/blocked/route.ts +++ b/app/api/analytics/blocked/route.ts @@ -5,10 +5,11 @@ import { getAnalyticsBlocked, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const page = parseInt(searchParams.get('page') ?? '1', 10); const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsBlocked(from, to, host, page); + const data = await getAnalyticsBlocked(from, to, hosts, page); return NextResponse.json(data); } diff --git a/app/api/analytics/countries/route.ts b/app/api/analytics/countries/route.ts index 2a4e9a5c..da6dea5e 100644 --- a/app/api/analytics/countries/route.ts +++ b/app/api/analytics/countries/route.ts @@ -5,9 +5,10 @@ import { getAnalyticsCountries, INTERVAL_SECONDS } from '@/src/lib/analytics-db' export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsCountries(from, to, host); + const data = await getAnalyticsCountries(from, to, hosts); return NextResponse.json(data); } diff --git a/app/api/analytics/protocols/route.ts b/app/api/analytics/protocols/route.ts index b1896e06..ee48a05c 100644 --- a/app/api/analytics/protocols/route.ts +++ b/app/api/analytics/protocols/route.ts @@ -5,9 +5,10 @@ import { getAnalyticsProtocols, INTERVAL_SECONDS } from '@/src/lib/analytics-db' export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsProtocols(from, to, host); + const data = await getAnalyticsProtocols(from, to, hosts); return NextResponse.json(data); } diff --git a/app/api/analytics/summary/route.ts b/app/api/analytics/summary/route.ts index 60a0a7ef..0b66bf32 100644 --- a/app/api/analytics/summary/route.ts +++ b/app/api/analytics/summary/route.ts @@ -5,9 +5,10 @@ import { getAnalyticsSummary, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsSummary(from, to, host); + const data = await getAnalyticsSummary(from, to, hosts); return NextResponse.json(data); } diff --git a/app/api/analytics/timeline/route.ts b/app/api/analytics/timeline/route.ts index 7d051808..56b1a79a 100644 --- a/app/api/analytics/timeline/route.ts +++ b/app/api/analytics/timeline/route.ts @@ -5,9 +5,10 @@ import { getAnalyticsTimeline, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsTimeline(from, to, host); + const data = await getAnalyticsTimeline(from, to, hosts); return NextResponse.json(data); } diff --git a/app/api/analytics/user-agents/route.ts b/app/api/analytics/user-agents/route.ts index 05bc816c..9470e907 100644 --- a/app/api/analytics/user-agents/route.ts +++ b/app/api/analytics/user-agents/route.ts @@ -5,9 +5,10 @@ import { getAnalyticsUserAgents, INTERVAL_SECONDS } from '@/src/lib/analytics-db export async function GET(req: NextRequest) { await requireUser(); const { searchParams } = req.nextUrl; - const host = searchParams.get('host') ?? 'all'; + const hostsParam = searchParams.get('hosts') ?? ''; + const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; const { from, to } = resolveRange(searchParams); - const data = await getAnalyticsUserAgents(from, to, host); + const data = await getAnalyticsUserAgents(from, to, hosts); return NextResponse.json(data); } diff --git a/package-lock.json b/package-lock.json index a773471c..c9ced7ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.6", + "@mui/x-date-pickers": "^8.27.2", "@types/better-sqlite3": "^7.6.13", "apexcharts": "^5.6.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "d3-geo": "^3.1.1", + "dayjs": "^1.11.19", "drizzle-orm": "^0.45.1", "maplibre-gl": "^5.19.0", "maxmind": "^5.0.5", @@ -2430,6 +2432,94 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "8.27.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.27.2.tgz", + "integrity": "sha512-06LFkHFRXJ2O9DMXtWAA3kY0jpbL7XH8iqa8L5cBlN+8bRx/UVLKlZYlhGv06C88jF9kuZWY1bUgrv/EoY/2Ww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.26.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", + "integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4203,6 +4293,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7432,6 +7528,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8653,6 +8755,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 5e524c42..25a194a1 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,13 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.6", + "@mui/x-date-pickers": "^8.27.2", "@types/better-sqlite3": "^7.6.13", "apexcharts": "^5.6.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "d3-geo": "^3.1.1", + "dayjs": "^1.11.19", "drizzle-orm": "^0.45.1", "maplibre-gl": "^5.19.0", "maxmind": "^5.0.5", diff --git a/src/lib/analytics-db.ts b/src/lib/analytics-db.ts index 30ad06de..54e54c38 100644 --- a/src/lib/analytics-db.ts +++ b/src/lib/analytics-db.ts @@ -1,6 +1,6 @@ -import { sql, and, gte, lte, eq } from 'drizzle-orm'; +import { sql, and, gte, lte, eq, inArray } from 'drizzle-orm'; import db from './db'; -import { trafficEvents } from './db/schema'; +import { trafficEvents, proxyHosts } from './db/schema'; import { existsSync } from 'node:fs'; export type Interval = '1h' | '12h' | '24h' | '7d' | '30d'; @@ -15,9 +15,13 @@ export const INTERVAL_SECONDS: Record = { '30d': 30 * 86400, }; -function buildWhere(from: number, to: number, host: string) { +function buildWhere(from: number, to: number, hosts: string[]) { const conditions = [gte(trafficEvents.ts, from), lte(trafficEvents.ts, to)]; - if (host !== 'all' && host !== '') conditions.push(eq(trafficEvents.host, host)); + if (hosts.length === 1) { + conditions.push(eq(trafficEvents.host, hosts[0])); + } else if (hosts.length > 1) { + conditions.push(inArray(trafficEvents.host, hosts)); + } return and(...conditions); } @@ -32,9 +36,9 @@ export interface AnalyticsSummary { loggingDisabled: boolean; } -export async function getAnalyticsSummary(from: number, to: number, host: string): Promise { +export async function getAnalyticsSummary(from: number, to: number, hosts: string[]): Promise { const loggingDisabled = !existsSync(LOG_FILE); - const where = buildWhere(from, to, host); + const where = buildWhere(from, to, hosts); const row = db .select({ @@ -76,9 +80,9 @@ function bucketSizeForDuration(seconds: number): number { return 86400; } -export async function getAnalyticsTimeline(from: number, to: number, host: string): Promise { +export async function getAnalyticsTimeline(from: number, to: number, hosts: string[]): Promise { const bucketSize = bucketSizeForDuration(to - from); - const where = buildWhere(from, to, host); + const where = buildWhere(from, to, hosts); const rows = db .select({ @@ -107,8 +111,8 @@ export interface CountryStats { blocked: number; } -export async function getAnalyticsCountries(from: number, to: number, host: string): Promise { - const where = buildWhere(from, to, host); +export async function getAnalyticsCountries(from: number, to: number, hosts: string[]): Promise { + const where = buildWhere(from, to, hosts); const rows = db .select({ @@ -137,8 +141,8 @@ export interface ProtoStats { percent: number; } -export async function getAnalyticsProtocols(from: number, to: number, host: string): Promise { - const where = buildWhere(from, to, host); +export async function getAnalyticsProtocols(from: number, to: number, hosts: string[]): Promise { + const where = buildWhere(from, to, hosts); const rows = db .select({ @@ -168,8 +172,8 @@ export interface UAStats { percent: number; } -export async function getAnalyticsUserAgents(from: number, to: number, host: string): Promise { - const where = buildWhere(from, to, host); +export async function getAnalyticsUserAgents(from: number, to: number, hosts: string[]): Promise { + const where = buildWhere(from, to, hosts); const rows = db .select({ @@ -212,9 +216,9 @@ export interface BlockedPage { pages: number; } -export async function getAnalyticsBlocked(from: number, to: number, host: string, page: number): Promise { +export async function getAnalyticsBlocked(from: number, to: number, hosts: string[], page: number): Promise { const pageSize = 10; - const where = and(buildWhere(from, to, host), eq(trafficEvents.isBlocked, true)); + const where = and(buildWhere(from, to, hosts), eq(trafficEvents.isBlocked, true)); const totalRow = db.select({ total: sql`count(*)` }).from(trafficEvents).where(where).get(); const total = totalRow?.total ?? 0; @@ -245,10 +249,23 @@ export async function getAnalyticsBlocked(from: number, to: number, host: string // ── Hosts ──────────────────────────────────────────────────────────────────── export async function getAnalyticsHosts(): Promise { - const rows = db - .selectDistinct({ host: trafficEvents.host }) - .from(trafficEvents) - .orderBy(trafficEvents.host) - .all(); - return rows.map((r) => r.host).filter(Boolean); + const hostSet = new Set(); + + // Hosts that appear in traffic events + const trafficRows = db.selectDistinct({ host: trafficEvents.host }).from(trafficEvents).all(); + for (const r of trafficRows) if (r.host) hostSet.add(r.host); + + // All domains configured on proxy hosts (even those with no traffic yet) + const proxyRows = db.select({ domains: proxyHosts.domains }).from(proxyHosts).all(); + for (const r of proxyRows) { + try { + const domains = JSON.parse(r.domains) as string[]; + for (const d of domains) { + const trimmed = d?.trim().toLowerCase(); + if (trimmed) hostSet.add(trimmed); + } + } catch { /* ignore malformed rows */ } + } + + return Array.from(hostSet).sort(); }