-
Notifications
You must be signed in to change notification settings - Fork 531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: New logs page #2143
feat: New logs page #2143
Changes from 30 commits
7759006
5993ae5
4344d3a
42e481d
78eec7b
20ea6ae
3e18a34
d2b8b1f
1cc48a8
d2a517c
9519fe4
88667d5
9258053
0fe8ba3
873e64e
0ae2d82
0da98b5
6d231b5
b9c6346
ee7f73a
7a2f3fc
ad2d539
3c22672
029e4d4
5b21935
1714490
444eb2e
215d7b8
8fe44eb
610531b
1eee930
5213279
cc79423
02b3251
230fb5a
d247461
cb2b70e
56a14bf
80391cc
9b0f94d
3bf4794
7f94805
b457ad7
542e577
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
"use client"; | ||
|
||
import { | ||
type ChartConfig, | ||
ChartContainer, | ||
ChartTooltip, | ||
ChartTooltipContent, | ||
} from "@/components/ui/chart"; | ||
import { format } from "date-fns"; | ||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; | ||
|
||
export type Log = { | ||
request_id: string; | ||
time: number; | ||
workspace_id: string; | ||
host: string; | ||
method: string; | ||
path: string; | ||
request_headers: string[]; | ||
request_body: string; | ||
response_status: number; | ||
response_headers: string[]; | ||
response_body: string; | ||
error: string; | ||
service_latency: number; | ||
}; | ||
|
||
const chartConfig = { | ||
success: { | ||
label: "Success", | ||
color: "hsl(var(--chart-3))", | ||
}, | ||
warning: { | ||
label: "Warning", | ||
color: "hsl(var(--chart-4))", | ||
}, | ||
error: { | ||
label: "Error", | ||
color: "hsl(var(--chart-1))", | ||
}, | ||
} satisfies ChartConfig; | ||
|
||
function aggregateData(data: Log[]) { | ||
const aggregatedData: { | ||
date: string; | ||
success: number; | ||
warning: number; | ||
error: number; | ||
}[] = []; | ||
const intervalMs = 60 * 1000 * 10; | ||
|
||
if (data.length === 0) { | ||
return aggregatedData; | ||
} | ||
|
||
const startOfDay = new Date(data[0].time).setHours(0, 0, 0, 0); | ||
const endOfDay = startOfDay + 24 * 60 * 60 * 1000; | ||
|
||
for (let timestamp = startOfDay; timestamp < endOfDay; timestamp += intervalMs) { | ||
const filteredLogs = data.filter((d) => d.time >= timestamp && d.time < timestamp + intervalMs); | ||
|
||
const success = filteredLogs.filter( | ||
(log) => log.response_status >= 200 && log.response_status < 300, | ||
).length; | ||
const warning = filteredLogs.filter( | ||
(log) => log.response_status >= 400 && log.response_status < 500, | ||
).length; | ||
const error = filteredLogs.filter((log) => log.response_status >= 500).length; | ||
|
||
aggregatedData.push({ | ||
date: format(timestamp, "yyyy-MM-dd'T'HH:mm:ss"), | ||
success, | ||
warning, | ||
error, | ||
}); | ||
} | ||
|
||
return aggregatedData; | ||
} | ||
|
||
export function LogsChart({ logs }: { logs: Log[] }) { | ||
const data = aggregateData(logs); | ||
|
||
return ( | ||
<ChartContainer config={chartConfig} className="h-[125px] w-full"> | ||
<BarChart accessibilityLayer data={data}> | ||
<XAxis | ||
dataKey="date" | ||
tickLine={false} | ||
axisLine={false} | ||
dx={-40} | ||
tickFormatter={(value) => { | ||
const date = new Date(value); | ||
return date.toLocaleDateString("en-US", { | ||
month: "short", | ||
day: "2-digit", | ||
hour: "2-digit", | ||
minute: "2-digit", | ||
hour12: true, | ||
}); | ||
}} | ||
/> | ||
<CartesianGrid strokeDasharray="2" stroke="#DFE2E6" vertical={false} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how will this work in dark/light mode? |
||
<ChartTooltip | ||
content={ | ||
<ChartTooltipContent | ||
className="w-[200px]" | ||
nameKey="views" | ||
labelFormatter={(value) => { | ||
return new Date(value).toLocaleDateString("en-US", { | ||
month: "short", | ||
day: "numeric", | ||
year: "numeric", | ||
hour: "2-digit", | ||
minute: "2-digit", | ||
hour12: true, | ||
}); | ||
}} | ||
/> | ||
} | ||
/> | ||
<Bar dataKey="success" stackId="a" fill="var(--color-success)" radius={3} /> | ||
<Bar dataKey="warning" stackId="a" fill="var(--color-warning)" radius={3} /> | ||
<Bar dataKey="error" stackId="a" fill="var(--color-error)" radius={3} /> | ||
</BarChart> | ||
</ChartContainer> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
"use client"; | ||
|
||
import { addHours, format, setHours, setMinutes, setSeconds } from "date-fns"; | ||
import type { DateRange } from "react-day-picker"; | ||
|
||
import { Button } from "@/components/ui/button"; | ||
import { Calendar } from "@/components/ui/calendar"; | ||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | ||
import { cn } from "@/lib/utils"; | ||
import { ArrowRight, Calendar as CalendarIcon } from "lucide-react"; | ||
import { useEffect, useState } from "react"; | ||
import { useLogSearchParams } from "../../../query-state"; | ||
import TimeSplitInput from "./time-split"; | ||
|
||
export function DatePickerWithRange({ className }: React.HTMLAttributes<HTMLDivElement>) { | ||
const [interimDate, setInterimDate] = useState<DateRange>({ | ||
from: new Date(), | ||
to: new Date(), | ||
}); | ||
const [finalDate, setFinalDate] = useState<DateRange>(); | ||
const [startTime, setStartTime] = useState({ HH: "09", mm: "00", ss: "00" }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where do these defaults come from? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's just a random starting hour. Do you have something else on your mind? |
||
const [endTime, setEndTime] = useState({ HH: "17", mm: "00", ss: "00" }); | ||
const [open, setOpen] = useState(false); | ||
const { searchParams, setSearchParams } = useLogSearchParams(); | ||
|
||
useEffect(() => { | ||
if (searchParams.startTime && searchParams.endTime) { | ||
const from = new Date(searchParams.startTime); | ||
const to = new Date(searchParams.endTime); | ||
setFinalDate({ from, to }); | ||
setInterimDate({ from, to }); | ||
setStartTime({ | ||
HH: from.getHours().toString().padStart(2, "0"), | ||
mm: from.getMinutes().toString().padStart(2, "0"), | ||
ss: from.getSeconds().toString().padStart(2, "0"), | ||
}); | ||
setEndTime({ | ||
HH: to.getHours().toString().padStart(2, "0"), | ||
mm: to.getMinutes().toString().padStart(2, "0"), | ||
ss: to.getSeconds().toString().padStart(2, "0"), | ||
}); | ||
} | ||
}, [searchParams.startTime, searchParams.endTime]); | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const handleFinalDate = (interimDate: DateRange | undefined) => { | ||
setOpen(false); | ||
|
||
if (interimDate?.from) { | ||
let mergedFrom = setHours(interimDate.from, Number(startTime.HH)); | ||
addHours(interimDate?.from, Number(startTime.HH)); | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
mergedFrom = setMinutes(mergedFrom, Number(startTime.mm)); | ||
mergedFrom = setSeconds(mergedFrom, Number(startTime.ss)); | ||
|
||
let mergedTo: Date; | ||
if (interimDate.to) { | ||
mergedTo = setHours(interimDate.to, Number(endTime.HH)); | ||
mergedTo = setMinutes(mergedTo, Number(endTime.mm)); | ||
mergedTo = setSeconds(mergedTo, Number(endTime.ss)); | ||
} else { | ||
mergedTo = setHours(interimDate.from, Number(endTime.HH)); | ||
mergedTo = setMinutes(mergedTo, Number(endTime.mm)); | ||
mergedTo = setSeconds(mergedTo, Number(endTime.ss)); | ||
} | ||
|
||
setFinalDate({ from: mergedFrom, to: mergedTo }); | ||
setSearchParams({ | ||
startTime: mergedFrom.getTime(), | ||
endTime: mergedTo.getTime(), | ||
}); | ||
} else { | ||
setFinalDate(interimDate); | ||
setSearchParams({ | ||
startTime: null, | ||
endTime: null, | ||
}); | ||
} | ||
}; | ||
|
||
return ( | ||
<div className={cn("grid gap-2", className)}> | ||
<Popover open={open} onOpenChange={setOpen}> | ||
<PopoverTrigger asChild> | ||
<div | ||
id="date" | ||
className={cn( | ||
"justify-start text-left font-normal flex gap-2 items-center", | ||
!finalDate && "text-muted-foreground", | ||
)} | ||
> | ||
<div className="flex gap-2 items-center w-fit"> | ||
<div> | ||
<CalendarIcon className="h-4 w-4" /> | ||
</div> | ||
{finalDate?.from ? ( | ||
finalDate.to ? ( | ||
<div className="truncate"> | ||
{format(finalDate.from, "LLL dd, y")} - {format(finalDate.to, "LLL dd, y")} | ||
</div> | ||
) : ( | ||
format(finalDate.from, "LLL dd, y") | ||
) | ||
) : ( | ||
<span>Custom</span> | ||
)} | ||
</div> | ||
</div> | ||
</PopoverTrigger> | ||
<PopoverContent className="w-auto p-0 bg-background"> | ||
<Calendar | ||
initialFocus | ||
mode="range" | ||
defaultMonth={interimDate?.from} | ||
selected={interimDate} | ||
onSelect={(date) => | ||
setInterimDate({ | ||
from: date?.from, | ||
to: date?.to, | ||
}) | ||
} | ||
/> | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
<div className="flex flex-col gap-2"> | ||
<div className="border-t border-border" /> | ||
<div className="flex gap-2 items-center w-full justify-evenly"> | ||
<TimeSplitInput | ||
type="start" | ||
startTime={startTime} | ||
endTime={endTime} | ||
time={startTime} | ||
setTime={setStartTime} | ||
setStartTime={setStartTime} | ||
setEndTime={setEndTime} | ||
startDate={interimDate.from} | ||
endDate={interimDate.to} | ||
/> | ||
<ArrowRight strokeWidth={1.5} size={14} /> | ||
<TimeSplitInput | ||
type="end" | ||
startTime={startTime} | ||
endTime={endTime} | ||
time={endTime} | ||
setTime={setEndTime} | ||
setStartTime={setStartTime} | ||
setEndTime={setEndTime} | ||
startDate={interimDate.from} | ||
endDate={interimDate.to} | ||
/> | ||
</div> | ||
<div className="border-t border-border" /> | ||
</div> | ||
<div className="flex gap-2 p-2 w-full justify-end bg-background-subtle"> | ||
<Button size="sm" variant="outline" onClick={() => handleFinalDate(undefined)}> | ||
Clear | ||
</Button> | ||
<Button size="sm" variant="primary" onClick={() => handleFinalDate(interimDate)}> | ||
Apply | ||
</Button> | ||
</div> | ||
</PopoverContent> | ||
</Popover> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import { Checkbox } from "@/components/ui/checkbox"; | ||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | ||
import React, { useEffect, useState } from "react"; | ||
import { type ResponseStatus as Status, useLogSearchParams } from "../../../query-state"; | ||
|
||
interface CheckboxItemProps { | ||
id: string; | ||
label: string; | ||
description: string; | ||
checked: boolean; | ||
onCheckedChange: (checked: boolean) => void; | ||
} | ||
|
||
const checkboxItems = [ | ||
{ id: "500", label: "Error", description: "500 error codes" }, | ||
ogzhanolguncu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ id: "200", label: "Success", description: "200 success codes" }, | ||
{ id: "400", label: "Warning", description: "400 warning codes" }, | ||
]; | ||
|
||
export const ResponseStatus = () => { | ||
const [open, setOpen] = useState(false); | ||
const { searchParams, setSearchParams } = useLogSearchParams(); | ||
const [checkedItems, setCheckedItems] = useState<Status[]>([]); | ||
|
||
useEffect(() => { | ||
if (searchParams.responseStatus) { | ||
setCheckedItems(searchParams.responseStatus); | ||
} | ||
}, [searchParams.responseStatus]); | ||
|
||
const handleItemChange = (status: Status, checked: boolean) => { | ||
const newCheckedItems = checked | ||
? [...checkedItems, status] | ||
: checkedItems.filter((item) => item !== status); | ||
|
||
setCheckedItems(newCheckedItems); | ||
setSearchParams((prevState) => ({ | ||
...prevState, | ||
responseStatus: newCheckedItems.length > 0 ? newCheckedItems : null, | ||
})); | ||
}; | ||
|
||
const getStatusDisplay = () => { | ||
if (checkedItems.length === 0) { | ||
return "Response Status"; | ||
} | ||
|
||
const statusLabels = checkedItems | ||
.map((status) => checkboxItems.find((item) => Number(item.id) === status)?.label) | ||
.filter(Boolean) | ||
.join(", "); | ||
|
||
return `Response Status (${statusLabels})`; | ||
}; | ||
|
||
return ( | ||
<Popover open={open} onOpenChange={setOpen}> | ||
<PopoverTrigger asChild> | ||
<div className="cursor-pointer">{getStatusDisplay()}</div> | ||
</PopoverTrigger> | ||
<PopoverContent className="w-80 bg-background p-0"> | ||
{checkboxItems.map((item, index) => ( | ||
<React.Fragment key={item.id}> | ||
<CheckboxItem | ||
{...item} | ||
checked={checkedItems.includes(Number(item.id) as Status)} | ||
onCheckedChange={(checked) => { | ||
handleItemChange(Number(item.id) as Status, checked); | ||
}} | ||
/> | ||
{index < checkboxItems.length - 1 && <div className="border-b border-border" />} | ||
</React.Fragment> | ||
))} | ||
</PopoverContent> | ||
</Popover> | ||
); | ||
}; | ||
|
||
const CheckboxItem = ({ id, label, description, checked, onCheckedChange }: CheckboxItemProps) => ( | ||
<div className="items-top flex space-x-2 p-4"> | ||
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} /> | ||
<div className="grid gap-1.5 leading-none"> | ||
<label | ||
htmlFor={id} | ||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" | ||
> | ||
{label} | ||
</label> | ||
<p className="text-xs text-content-subtle">{description}</p> | ||
</div> | ||
</div> | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Optimize
aggregateData
function for performanceCurrently, the
aggregateData
function filters the entire data array for each time interval, which can lead to performance issues with large datasets. Consider refactoring the function to process the data in a single pass.Here is a suggested refactor:
This refactor processes the data in a single pass, improving performance for large datasets.
📝 Committable suggestion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah that feels a bit better, our arrays aren't huge, but as we might add more status types, we could end up looping over them quite a few times.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chronark, I'm glad the refactored version feels better. Since you might add more status types, we can modify the
aggregateData
function to make it more flexible and scalable. By defining the status categories in a configuration array, we can easily add new types without changing the core aggregation logic.Here's how you could refactor the code:
chartConfig
dynamically:aggregateData
function:With this approach:
statusCategories
array without modifying the aggregation logic.Let me know if you have any questions or need further assistance!