feat: MUI date-time pickers, multiselect hosts with search, fix host list
- Replace native datetime-local inputs with @mui/x-date-pickers DateTimePicker (proper dark-themed calendar popover with time picker, DD/MM/YYYY HH:mm format, min/max constraints between pickers, 24h clock) - Replace single-host Select with Autocomplete (multiple, disableCloseOnSelect): checkbox per option, chip display with limitTags=2, built-in search/filter - getAnalyticsHosts() now unions traffic event hosts WITH all configured proxy host domains (parsed from proxyHosts.domains JSON), so every proxy appears in the list - analytics-db: buildWhere accepts hosts: string[] (empty = all); uses inArray for multi-host filtering via drizzle-orm - All 6 API routes updated: accept hosts param (comma-separated) instead of host Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ tsconfig.tsbuildinfo
|
||||
CLAUDE.local.md
|
||||
/caddy-proxy-manager.wiki
|
||||
docs/plans
|
||||
/.playwright-mcp
|
||||
|
||||
@@ -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<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;
|
||||
@@ -144,12 +143,12 @@ function StatCard({ label, value, sub, color }: { label: string; value: string;
|
||||
|
||||
export default function AnalyticsClient() {
|
||||
const [interval, setIntervalVal] = useState<DisplayInterval>('1h');
|
||||
const [host, setHost] = useState<string>('all');
|
||||
const [hosts, setHosts] = useState<string[]>([]);
|
||||
const [selectedHosts, setSelectedHosts] = useState<string[]>([]);
|
||||
const [allHosts, setAllHosts] = useState<string[]>([]);
|
||||
|
||||
// Custom range (datetime-local strings: "YYYY-MM-DDTHH:MM")
|
||||
const [customFrom, setCustomFrom] = useState<string>('');
|
||||
const [customTo, setCustomTo] = useState<string>('');
|
||||
// Custom range as Dayjs objects
|
||||
const [customFrom, setCustomFrom] = useState<Dayjs | null>(null);
|
||||
const [customTo, setCustomTo] = useState<Dayjs | null>(null);
|
||||
|
||||
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelineBucket[]>([]);
|
||||
@@ -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
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<ToggleButtonGroup
|
||||
value={interval}
|
||||
exclusive
|
||||
size="small"
|
||||
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}
|
||||
onChange={(e: SelectChangeEvent) => setHost(e.target.value)}
|
||||
displayEmpty
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<ToggleButtonGroup
|
||||
value={interval}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={(_e, v) => {
|
||||
if (!v) return;
|
||||
if (v === 'custom' && !customFrom) {
|
||||
setCustomFrom(dayjs().subtract(24, 'hour'));
|
||||
setCustomTo(dayjs());
|
||||
}
|
||||
setIntervalVal(v);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All hosts</MenuItem>
|
||||
{hosts.map(h => <MenuItem key={h} value={h}>{h}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<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">
|
||||
<DateTimePicker
|
||||
value={customFrom}
|
||||
maxDateTime={customTo ?? undefined}
|
||||
onChange={setCustomFrom}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
sx: { width: 200 },
|
||||
},
|
||||
}}
|
||||
format="DD/MM/YYYY HH:mm"
|
||||
ampm={false}
|
||||
/>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ flexShrink: 0 }}>–</Typography>
|
||||
<DateTimePicker
|
||||
value={customTo}
|
||||
minDateTime={customFrom ?? undefined}
|
||||
onChange={setCustomTo}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
sx: { width: 200 },
|
||||
},
|
||||
}}
|
||||
format="DD/MM/YYYY HH:mm"
|
||||
ampm={false}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={allHosts}
|
||||
value={selectedHosts}
|
||||
onChange={(_e, v) => setSelectedHosts(v)}
|
||||
disableCloseOnSelect
|
||||
limitTags={2}
|
||||
sx={{ minWidth: 220, maxWidth: 380 }}
|
||||
renderOption={(props, option, { selected }) => (
|
||||
<li {...props} key={option}>
|
||||
<Checkbox size="small" checked={selected} sx={{ mr: 0.5, p: 0.5 }} />
|
||||
<ListItemText primary={option} primaryTypographyProps={{ variant: 'body2', noWrap: true }} />
|
||||
</li>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
label={option}
|
||||
size="small"
|
||||
sx={{ maxWidth: 120 }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={selectedHosts.length === 0 ? 'All hosts' : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</LocalizationProvider>
|
||||
</Stack>
|
||||
|
||||
{/* Logging disabled alert */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Interval, number> = {
|
||||
'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<AnalyticsSummary> {
|
||||
export async function getAnalyticsSummary(from: number, to: number, hosts: string[]): Promise<AnalyticsSummary> {
|
||||
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<TimelineBucket[]> {
|
||||
export async function getAnalyticsTimeline(from: number, to: number, hosts: string[]): Promise<TimelineBucket[]> {
|
||||
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<CountryStats[]> {
|
||||
const where = buildWhere(from, to, host);
|
||||
export async function getAnalyticsCountries(from: number, to: number, hosts: string[]): Promise<CountryStats[]> {
|
||||
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<ProtoStats[]> {
|
||||
const where = buildWhere(from, to, host);
|
||||
export async function getAnalyticsProtocols(from: number, to: number, hosts: string[]): Promise<ProtoStats[]> {
|
||||
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<UAStats[]> {
|
||||
const where = buildWhere(from, to, host);
|
||||
export async function getAnalyticsUserAgents(from: number, to: number, hosts: string[]): Promise<UAStats[]> {
|
||||
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<BlockedPage> {
|
||||
export async function getAnalyticsBlocked(from: number, to: number, hosts: string[], page: number): Promise<BlockedPage> {
|
||||
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<number>`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<string[]> {
|
||||
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<string>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user