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:
fuomag9
2026-02-27 11:42:54 +01:00
parent 9e2007eb0c
commit cf74451e9a
12 changed files with 286 additions and 137 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ tsconfig.tsbuildinfo
CLAUDE.local.md
/caddy-proxy-manager.wiki
docs/plans
/.playwright-mcp

View File

@@ -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 */}

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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();
}