Skip to content

Commit dfd2591

Browse files
feat: add date/time filter to /ui/logs page (#361)
## Summary Adds date/time range filtering to the Logs page (`/ui/logs`), addressing issue #357. ### Changes - **Added `startDate` and `endDate` to the filter state** in `Logs.tsx` - **Added two `datetime-local` inputs** (From / To) with Calendar icons in the filter bar, using the native HTML5 `datetime-local` input type — the same off-the-shelf component used by the existing `TimeRangeSelector` elsewhere in the project - **Added a clear (X) button** that appears when either date field has a value, allowing quick reset - **Wired date filters to the backend API** — the existing `/v0/management/usage` endpoint already supports `startDate` and `endDate` query parameters; the filter values are converted to ISO strings and passed through - **Added client-side date filtering for SSE live events** — incoming SSE events are filtered by `startTime` against the date range when the SSE stream is active (matching the existing pattern for key/model/provider filtering) ### Screenshot Reference The original issue showed the Logs page with only text-based filters (Key, Model, Provider). This PR adds "From" and "To" datetime pickers in the same filter bar row. Closes #357 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Cowger <matt@cowger.us>
1 parent 6ed45b8 commit dfd2591

2 files changed

Lines changed: 309 additions & 2 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from 'lucide-react';
4+
import { clsx } from 'clsx';
5+
6+
interface DateTimePickerProps {
7+
value: string;
8+
onChange: (value: string) => void;
9+
placeholder?: string;
10+
className?: string;
11+
}
12+
13+
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
14+
15+
function pad(n: number) {
16+
return n.toString().padStart(2, '0');
17+
}
18+
19+
function parseValue(v: string): { date: Date | null; hours: string; minutes: string } {
20+
if (!v) return { date: null, hours: '00', minutes: '00' };
21+
const d = new Date(v);
22+
if (isNaN(d.getTime())) return { date: null, hours: '00', minutes: '00' };
23+
return { date: d, hours: pad(d.getHours()), minutes: pad(d.getMinutes()) };
24+
}
25+
26+
function toISOStringLocal(date: Date, hours: string, minutes: string): string {
27+
const d = new Date(date);
28+
d.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0);
29+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${hours}:${minutes}`;
30+
}
31+
32+
export const DateTimePicker: React.FC<DateTimePickerProps> = ({
33+
value,
34+
onChange,
35+
placeholder = 'Select date...',
36+
className,
37+
}) => {
38+
const [open, setOpen] = useState(false);
39+
const parsed = parseValue(value);
40+
const [viewDate, setViewDate] = useState(() => parsed.date ?? new Date());
41+
const [hours, setHours] = useState(parsed.hours);
42+
const [minutes, setMinutes] = useState(parsed.minutes);
43+
const ref = useRef<HTMLDivElement>(null);
44+
const popupRef = useRef<HTMLDivElement>(null);
45+
46+
useEffect(() => {
47+
const p = parseValue(value);
48+
if (p.date) setViewDate(p.date);
49+
setHours(p.hours);
50+
setMinutes(p.minutes);
51+
}, [value]);
52+
53+
useEffect(() => {
54+
function onDocClick(e: MouseEvent) {
55+
if (
56+
ref.current &&
57+
!ref.current.contains(e.target as Node) &&
58+
popupRef.current &&
59+
!popupRef.current.contains(e.target as Node)
60+
) {
61+
setOpen(false);
62+
}
63+
}
64+
if (open) document.addEventListener('mousedown', onDocClick);
65+
return () => document.removeEventListener('mousedown', onDocClick);
66+
}, [open]);
67+
68+
const year = viewDate.getFullYear();
69+
const month = viewDate.getMonth();
70+
const firstDay = new Date(year, month, 1).getDay();
71+
const daysInMonth = new Date(year, month + 1, 0).getDate();
72+
73+
const selectedDate = parsed.date;
74+
75+
const days: (number | null)[] = [];
76+
for (let i = 0; i < firstDay; i++) days.push(null);
77+
for (let i = 1; i <= daysInMonth; i++) days.push(i);
78+
79+
const selectDay = (day: number) => {
80+
const newDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
81+
const formatted = toISOStringLocal(newDate, hours, minutes);
82+
onChange(formatted);
83+
setViewDate(newDate);
84+
};
85+
86+
const applyTime = (h: string, m: string) => {
87+
const baseDate = selectedDate ?? new Date(viewDate);
88+
const formatted = toISOStringLocal(baseDate, h, m);
89+
onChange(formatted);
90+
};
91+
92+
const displayValue = value
93+
? (() => {
94+
const d = parseValue(value);
95+
if (!d.date) return value;
96+
return `${d.date.toLocaleDateString('en-US', {
97+
month: 'short',
98+
day: 'numeric',
99+
year: 'numeric',
100+
})} ${d.hours}:${d.minutes}`;
101+
})()
102+
: '';
103+
104+
const popup = (
105+
<div
106+
ref={(el) => {
107+
popupRef.current = el;
108+
if (el && ref.current) {
109+
const rect = ref.current.getBoundingClientRect();
110+
el.style.top = `${rect.bottom + window.scrollY + 8}px`;
111+
el.style.left = `${rect.left + window.scrollX}px`;
112+
}
113+
}}
114+
className="fixed p-3 rounded-lg border border-border-glass shadow-modal"
115+
style={{
116+
zIndex: 500,
117+
backgroundColor: 'var(--color-bg-card)',
118+
backdropFilter: 'blur(16px)',
119+
minWidth: '280px',
120+
}}
121+
>
122+
{/* Header */}
123+
<div className="flex items-center justify-between mb-3">
124+
<button
125+
type="button"
126+
onClick={() => setViewDate(new Date(year, month - 1, 1))}
127+
className="p-1 rounded hover:bg-bg-hover text-text-secondary hover:text-text transition-colors"
128+
>
129+
<ChevronLeft size={16} />
130+
</button>
131+
<span className="text-sm font-semibold text-text">
132+
{viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
133+
</span>
134+
<button
135+
type="button"
136+
onClick={() => setViewDate(new Date(year, month + 1, 1))}
137+
className="p-1 rounded hover:bg-bg-hover text-text-secondary hover:text-text transition-colors"
138+
>
139+
<ChevronRight size={16} />
140+
</button>
141+
</div>
142+
143+
{/* Day labels */}
144+
<div className="grid grid-cols-7 mb-1">
145+
{DAYS.map((d) => (
146+
<div key={d} className="text-center text-[11px] font-medium text-text-muted py-1">
147+
{d}
148+
</div>
149+
))}
150+
</div>
151+
152+
{/* Day grid */}
153+
<div className="grid grid-cols-7 gap-1">
154+
{days.map((day, i) => {
155+
if (day === null) return <div key={`empty-${i}`} className="aspect-square" />;
156+
157+
const isSelected =
158+
selectedDate &&
159+
selectedDate.getDate() === day &&
160+
selectedDate.getMonth() === month &&
161+
selectedDate.getFullYear() === year;
162+
163+
return (
164+
<button
165+
key={day}
166+
type="button"
167+
onClick={() => selectDay(day)}
168+
className={clsx(
169+
'aspect-square flex items-center justify-center rounded text-[13px] transition-colors',
170+
isSelected ? 'bg-primary text-black font-semibold' : 'text-text hover:bg-bg-hover'
171+
)}
172+
>
173+
{day}
174+
</button>
175+
);
176+
})}
177+
</div>
178+
179+
{/* Time selector */}
180+
<div className="mt-3 pt-3 border-t border-border-glass flex items-center gap-2">
181+
<Clock size={14} className="text-text-secondary shrink-0" />
182+
<div className="flex items-center gap-1">
183+
<input
184+
type="number"
185+
min={0}
186+
max={23}
187+
value={hours}
188+
onChange={(e) => {
189+
let v = e.target.value.replace(/\D/g, '').slice(0, 2);
190+
if (v && parseInt(v, 10) > 23) v = '23';
191+
setHours(v);
192+
if (v.length === 2) applyTime(v, minutes);
193+
}}
194+
onBlur={() => {
195+
const v = pad(Math.max(0, Math.min(23, parseInt(hours || '0', 10))));
196+
setHours(v);
197+
applyTime(v, minutes);
198+
}}
199+
className="w-12 text-center py-1 rounded bg-bg-glass border border-border-glass text-text text-sm outline-none focus:border-primary"
200+
/>
201+
<span className="text-text-secondary">:</span>
202+
<input
203+
type="number"
204+
min={0}
205+
max={59}
206+
value={minutes}
207+
onChange={(e) => {
208+
let v = e.target.value.replace(/\D/g, '').slice(0, 2);
209+
if (v && parseInt(v, 10) > 59) v = '59';
210+
setMinutes(v);
211+
if (v.length === 2) applyTime(hours, v);
212+
}}
213+
onBlur={() => {
214+
const v = pad(Math.max(0, Math.min(59, parseInt(minutes || '0', 10))));
215+
setMinutes(v);
216+
applyTime(hours, v);
217+
}}
218+
className="w-12 text-center py-1 rounded bg-bg-glass border border-border-glass text-text text-sm outline-none focus:border-primary"
219+
/>
220+
</div>
221+
</div>
222+
</div>
223+
);
224+
225+
return (
226+
<div ref={ref} className={clsx('relative', className)}>
227+
<button
228+
type="button"
229+
onClick={() => setOpen(!open)}
230+
className={clsx(
231+
'w-full sm:w-56 flex items-center gap-2 py-2.5 pl-3 pr-3',
232+
'font-body text-sm text-left rounded-md border outline-none transition-all duration-fast',
233+
'backdrop-blur-md bg-bg-glass border-border-glass text-text',
234+
'hover:border-text-secondary/40 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/25'
235+
)}
236+
>
237+
<Calendar size={14} className="shrink-0 text-text-secondary" />
238+
<span className={clsx('flex-1 truncate', !displayValue && 'text-text-muted')}>
239+
{displayValue || placeholder}
240+
</span>
241+
{value && (
242+
<span
243+
onClick={(e) => {
244+
e.stopPropagation();
245+
onChange('');
246+
}}
247+
className="cursor-pointer text-text-muted hover:text-text transition-colors"
248+
role="button"
249+
aria-label="Clear"
250+
>
251+
<X size={12} />
252+
</span>
253+
)}
254+
</button>
255+
256+
{open && createPortal(popup, document.body)}
257+
</div>
258+
);
259+
};

packages/frontend/src/pages/Logs.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ import {
2323
formatTPS,
2424
} from '../lib/format';
2525
import { isClipboardAvailable, copyToClipboard } from '../lib/clipboard';
26+
import { DateTimePicker } from '../components/ui/DateTimePicker';
2627
import {
2728
ChevronLeft,
2829
ChevronRight,
30+
PlayCircle,
31+
Circle,
2932
Trash2,
3033
Bug,
3134
Zap,
@@ -56,6 +59,7 @@ import {
5659
Plane,
5760
Eye,
5861
ScanSearch,
62+
X,
5963
} from 'lucide-react';
6064
import { clsx } from 'clsx';
6165
import { useNavigate } from 'react-router-dom';
@@ -117,6 +121,8 @@ export const Logs = () => {
117121
apiKey: '',
118122
incomingModelAlias: '',
119123
provider: '',
124+
startDate: '',
125+
endDate: '',
120126
});
121127

122128
const apiLogos: Record<string, string> = {
@@ -151,6 +157,8 @@ export const Logs = () => {
151157
if (filters.apiKey) cleanFilters.apiKey = filters.apiKey;
152158
if (filters.incomingModelAlias) cleanFilters.incomingModelAlias = filters.incomingModelAlias;
153159
if (filters.provider) cleanFilters.provider = filters.provider;
160+
if (filters.startDate) cleanFilters.startDate = new Date(filters.startDate).toISOString();
161+
if (filters.endDate) cleanFilters.endDate = new Date(filters.endDate).toISOString();
154162

155163
const res = await api.getLogs(limit, offset, cleanFilters, sortBy, sortDir);
156164
setLogs(res.data);
@@ -289,6 +297,15 @@ export const Logs = () => {
289297
) {
290298
matches = false;
291299
}
300+
// Client-side date filtering for SSE events
301+
if (currentFilters.startDate && newLog.startTime) {
302+
const filterStart = new Date(currentFilters.startDate).getTime();
303+
if (newLog.startTime < filterStart) matches = false;
304+
}
305+
if (currentFilters.endDate && newLog.startTime) {
306+
const filterEnd = new Date(currentFilters.endDate).getTime();
307+
if (newLog.startTime > filterEnd) matches = false;
308+
}
292309

293310
if (matches) {
294311
setLogs((prev) => {
@@ -375,9 +392,12 @@ export const Logs = () => {
375392
try {
376393
const d = new Date(dateStr);
377394
if (isNaN(d.getTime())) return { time: 'Invalid', date: 'Date' };
395+
const year = d.getFullYear();
396+
const month = String(d.getMonth() + 1).padStart(2, '0');
397+
const day = String(d.getDate()).padStart(2, '0');
378398
return {
379399
time: d.toLocaleTimeString(),
380-
date: d.toISOString().split('T')[0],
400+
date: `${year}-${month}-${day}`,
381401
};
382402
} catch (e) {
383403
return { time: 'Error', date: 'Date' };
@@ -401,7 +421,7 @@ export const Logs = () => {
401421
<div className="p-3 sm:p-4 border-b border-border-glass">
402422
<form
403423
onSubmit={handleSearch}
404-
className="flex flex-col sm:flex-row sm:flex-wrap sm:items-end gap-3 justify-between"
424+
className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3 justify-between"
405425
>
406426
<div className="flex flex-col sm:flex-row sm:flex-wrap gap-2 min-w-0 flex-1">
407427
{/* The apiKey filter is redundant for limited users — backend
@@ -429,6 +449,34 @@ export const Logs = () => {
429449
onChange={(v) => setFilters({ ...filters, provider: v })}
430450
/>
431451
</div>
452+
<div className="w-full sm:w-auto flex items-center gap-2">
453+
<div className="flex items-center gap-2">
454+
<PlayCircle size={24} color="#94a3b8" />
455+
<DateTimePicker
456+
value={filters.startDate}
457+
onChange={(v) => setFilters((prev) => ({ ...prev, startDate: v }))}
458+
placeholder="Start date"
459+
/>
460+
</div>
461+
<div className="flex items-center gap-2">
462+
<Circle size={24} color="#94a3b8" />
463+
<DateTimePicker
464+
value={filters.endDate}
465+
onChange={(v) => setFilters((prev) => ({ ...prev, endDate: v }))}
466+
placeholder="End date"
467+
/>
468+
</div>
469+
{(filters.startDate || filters.endDate) && (
470+
<button
471+
type="button"
472+
onClick={() => setFilters({ ...filters, startDate: '', endDate: '' })}
473+
className="rounded-md text-text-muted hover:text-text hover:bg-bg-hover transition-colors duration-fast bg-transparent border-0 cursor-pointer"
474+
title="Clear date filters"
475+
>
476+
<X size={14} />
477+
</button>
478+
)}
479+
</div>
432480
<Button type="submit" variant="primary">
433481
Search
434482
</Button>

0 commit comments

Comments
 (0)