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

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