diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index 9eef0340f7..18ec728800 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -43,7 +43,7 @@ export default async function Layout({ children, breadcrumb }: LayoutProps) { />
-
+
{workspace.enabled ? ( <> {/* Hacky way to make the breadcrumbs line up with the Teamswitcher on the left, because that also has h12 */} diff --git a/apps/dashboard/app/(app)/logs/components/chart.tsx b/apps/dashboard/app/(app)/logs/components/chart.tsx new file mode 100644 index 0000000000..c19d73b7fc --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/chart.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { format } from "date-fns"; +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; +import { useLogSearchParams } from "../query-state"; + +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; + +export function LogsChart({ logs }: { logs: Log[] }) { + const { searchParams } = useLogSearchParams(); + const data = aggregateData( + logs, + searchParams.startTime, + searchParams.endTime + ); + + return ( + + + { + const date = new Date(value); + return date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + }} + /> + + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + }} + /> + } + /> + + + + + + ); +} + +function aggregateData(data: Log[], startTime: number, endTime: number) { + const aggregatedData: { + date: string; + success: number; + warning: number; + error: number; + }[] = []; + + const intervalMs = 60 * 1000 * 10; // 10 minutes + + if (data.length === 0) { + return aggregatedData; + } + + const buckets = new Map(); + + // Create a bucket for each 10 minute interval + for ( + let timestamp = startTime; + timestamp < endTime; + timestamp += intervalMs + ) { + buckets.set(timestamp, { + date: format(timestamp, "yyyy-MM-dd'T'HH:mm:ss"), + success: 0, + warning: 0, + error: 0, + }); + } + console.log(buckets); + + // For each log, find its bucket then increment the appropriate counter + for (const log of data) { + const bucketIndex = Math.floor((log.time - startTime) / intervalMs); + const bucket = buckets.get(startTime + bucketIndex * intervalMs); + + if (bucket) { + const status = log.response_status; + if (status >= 200 && status < 300) bucket.success++; + else if (status >= 400 && status < 500) bucket.warning++; + else if (status >= 500) bucket.error++; + } + } + + return Array.from(buckets.values()); +} diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx new file mode 100644 index 0000000000..fa917252ba --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { 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) { + const [interimDate, setInterimDate] = useState({ + from: new Date(), + to: new Date(), + }); + const [finalDate, setFinalDate] = useState(); + const [startTime, setStartTime] = useState({ HH: "09", mm: "00", ss: "00" }); + 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]); + + const handleFinalDate = (interimDate: DateRange | undefined) => { + setOpen(false); + + if (interimDate?.from) { + let mergedFrom = setHours(interimDate.from, Number(startTime.HH)); + 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: undefined, + endTime: undefined, + }); + } + }; + + return ( +
+ + +
+
+
+ +
+ {finalDate?.from ? ( + finalDate.to ? ( +
+ {format(finalDate.from, "LLL dd, y")} - {format(finalDate.to, "LLL dd, y")} +
+ ) : ( + format(finalDate.from, "LLL dd, y") + ) + ) : ( + Custom + )} +
+
+
+ + + setInterimDate({ + from: date?.from, + to: date?.to, + }) + } + /> +
+
+
+ + + +
+
+
+
+ + +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/response-status.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/response-status.tsx new file mode 100644 index 0000000000..f7abfd2399 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/response-status.tsx @@ -0,0 +1,110 @@ +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: "5XX", label: "Error", description: "5XX error codes" }, + { id: "2XX", label: "Success", description: "2XX success codes" }, + { id: "4XX", label: "Warning", description: "4XX warning codes" }, +]; + +export const ResponseStatus = () => { + const [open, setOpen] = useState(false); + const { searchParams, setSearchParams } = useLogSearchParams(); + const [checkedItems, setCheckedItems] = useState([]); + + 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: checkedItems, + })); + }; + + 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 ( + + +
{getStatusDisplay()}
+
+ + {checkboxItems.map((item, index) => ( + + { + handleItemChange(Number(item.id) as Status, checked); + }} + /> + {index < checkboxItems.length - 1 && ( +
+ )} + + ))} + + + ); +}; + +const CheckboxItem = ({ + id, + label, + description, + checked, + onCheckedChange, +}: CheckboxItemProps) => ( +
+ +
+ +

{description}

+
+
+); diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/badge.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/badge.tsx new file mode 100644 index 0000000000..589e5c6aba --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/badge.tsx @@ -0,0 +1,164 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { OPTIONS } from "./constants"; +import type { SearchItem } from "./hooks"; + +type BadgeProps = { + item: SearchItem; + index: number; + editingIndex: number; + editInputRef: React.RefObject; + onEditChange: (item: SearchItem, index: number) => void; + onEditBlur: () => void; + onEditKeyDown: (e: React.KeyboardEvent) => void; + onFocus: (index: number) => void; + onRemove: (item: SearchItem) => void; +}; + +const ActiveBadgeContent = ({ + item, + index, + editInputRef, + onEditChange, + onEditBlur, + onEditKeyDown, +}: Pick< + BadgeProps, + "item" | "index" | "editInputRef" | "onEditChange" | "onEditBlur" | "onEditKeyDown" +>) => { + const [value, setValue] = useState(item.label + item.searchValue); + const timeoutIdRef = useRef>(); + + const debouncedOnEditChange = useCallback( + (newItem: typeof item, idx: number) => { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = setTimeout(() => { + onEditChange(newItem, idx); + }, 300); + }, + [onEditChange], + ); + + const internalHandleEditChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + const undeletablePart = OPTIONS.find((o) => o.value === item.value)?.label || ""; + const newSearchValue = value.slice(undeletablePart.length); + setValue(item.label + newSearchValue); + + debouncedOnEditChange( + { + value: item.value, + label: item.label, + searchValue: newSearchValue, + }, + index, + ); + }, + [item.value, item.label, index, debouncedOnEditChange], + ); + + useEffect(() => { + return () => { + clearTimeout(timeoutIdRef.current); + }; + }, []); + + return ( + { + // Clear any pending timeout and call onEditChange immediately on blur + clearTimeout(timeoutIdRef.current); + onEditChange( + { + value: item.value, + label: item.label, + searchValue: value.slice(item.label.length), + }, + index, + ); + onEditBlur?.(); + }} + onKeyDown={onEditKeyDown} + className="h-3 w-24 px-1 py-0 text-xs bg-transparent border-none focus:ring-0 focus:outline-none" + /> + ); +}; +const PassiveBadgeContent = ({ + item, + index, + onFocus: handleFocusOnClick, +}: Pick) => ( +
+ { + e.stopPropagation(); + handleFocusOnClick(index); + }} + title={item.label + item.searchValue} + > + {item.label} + {item.searchValue} + +
+); + +const RemoveButton = ({ item, onRemove }: Pick) => ( + +); + +export const ComboboxBadge = ({ + item, + index, + editingIndex, + editInputRef, + onEditChange: handleEditChange, + onEditBlur: handleEditBlur, + onEditKeyDown: handleEditKeyDown, + onFocus: handleFocusOnClick, + onRemove: handleRemove, +}: BadgeProps) => ( + + {editingIndex === index ? ( + + ) : ( + + )} + + +); diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/constants.ts b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/constants.ts new file mode 100644 index 0000000000..cb5ca43a69 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/constants.ts @@ -0,0 +1,21 @@ +// When editingIndex is NO_ITEM_EDITING (-1), no item is in edit mode. +export const NO_ITEM_EDITING = -1; +export const KEYS = { + ESCAPE: "Escape", + ENTER: "Enter", +} as const; +export const PLACEHOLDER_TEXT = "Search logs..."; + +export const OPTIONS = [ + { value: "requestId", label: "requestId: " }, + { value: "host", label: "host: " }, + { value: "method", label: "method: " }, + { value: "path", label: "path: " }, +] as const; + +export const OPTION_EXPLANATIONS: Record = { + requestId: "Request identifier", + host: "Domain name", + method: "HTTP method", + path: "Request URL path", +}; diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/hooks.ts b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/hooks.ts new file mode 100644 index 0000000000..80c4c24b00 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/hooks.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import { type PickKeys, type QuerySearchParams, useLogSearchParams } from "../../../../query-state"; +import { KEYS, NO_ITEM_EDITING, OPTIONS } from "./constants"; + +export const useFocusOnBadge = (currentFocusedItemIndex: number) => { + const editInputRef = useRef(null); + + // Focuses on a badge + useEffect(() => { + if (currentFocusedItemIndex !== NO_ITEM_EDITING && editInputRef.current) { + editInputRef.current.focus(); + } + }, [currentFocusedItemIndex]); + + return { editInputRef }; +}; + +export type Option = { + value: PickKeys; + label: string; +}; +export type SearchItem = Option & { searchValue: string }; + +export const useSelectComboboxItems = () => { + const [selectedItems, setSelectedItems] = useState([]); + const { searchParams, setSearchParams } = useLogSearchParams(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: When "setSelectedItems" included hook does too many renders + useEffect(() => { + const initialItems = OPTIONS.filter((option) => searchParams[option.value]).map((option) => ({ + ...option, + searchValue: searchParams[option.value] as string, + })); + setSelectedItems(initialItems); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: When setSearchParams included component does too many retries + useEffect(() => { + setSearchParams( + selectedItems.reduce( + (params, item) => { + if (item.searchValue) { + params[item.value] = item.searchValue; + } + return params; + }, + {} as Partial, + ), + ); + }, [selectedItems]); + + return { selectedItems, setSelectedItems } as const; +}; + +export const useListenEscapeKey = (cb: () => void) => { + // Allow you escape from double-clicked badge after editting + useEffect(() => { + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === KEYS.ESCAPE) { + cb(); + } + }; + + document.addEventListener("keydown", handleEscKey); + + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [cb]); +}; + +const DELETE_KEYS = { + DELETE: "Delete", + BACKSPACE: "Backspace", +} as const; + +export const useDeleteFromSelection = ( + selectedItems: SearchItem[], + onRemoveFromSelectedItems: (item: SearchItem) => void, + elementRef: React.RefObject, +) => { + useEffect(() => { + const handleDeleteKey = (event: KeyboardEvent) => { + if (document.activeElement !== elementRef.current) { + return; + } + + if (event.key === DELETE_KEYS.DELETE || event.key === DELETE_KEYS.BACKSPACE) { + event.preventDefault(); + const lastItem = selectedItems?.at(-1); + if (selectedItems.length > 0 && lastItem) { + onRemoveFromSelectedItems(lastItem); + } + } + }; + + document.addEventListener("keydown", handleDeleteKey); + return () => { + document.removeEventListener("keydown", handleDeleteKey); + }; + }, [selectedItems, elementRef, onRemoveFromSelectedItems]); +}; diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/search-combobox.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/search-combobox.tsx new file mode 100644 index 0000000000..65e3461cc5 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/search-combobox/search-combobox.tsx @@ -0,0 +1,167 @@ +import { Button } from "@/components/ui/button"; +import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { CheckCircle, Search } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; + +import { useLogSearchParams } from "../../../../query-state"; +import { ComboboxBadge } from "./badge"; +import { KEYS, NO_ITEM_EDITING, OPTIONS, OPTION_EXPLANATIONS, PLACEHOLDER_TEXT } from "./constants"; +import { + type Option, + type SearchItem, + useDeleteFromSelection, + useFocusOnBadge, + useListenEscapeKey, + useSelectComboboxItems, +} from "./hooks"; + +export function SearchCombobox() { + const [open, setOpen] = useState(false); + const selectedItemWrapperRef = useRef(null); + const [currentFocusedItemIndex, setCurrentFocusedItemIndex] = useState(NO_ITEM_EDITING); + + const { selectedItems, setSelectedItems } = useSelectComboboxItems(); + const { editInputRef } = useFocusOnBadge(currentFocusedItemIndex); + const { setSearchParams } = useLogSearchParams(); + + const handlePressEscape = useCallback(() => { + setOpen(false); + setCurrentFocusedItemIndex(NO_ITEM_EDITING); + }, []); + + useListenEscapeKey(handlePressEscape); + + const handleRemove = useCallback( + (item: SearchItem) => { + setSelectedItems((prevState) => + prevState.filter((selected) => selected.value !== item.value), + ); + setSearchParams({ [item.value]: null }); + }, + [setSelectedItems, setSearchParams], + ); + + useDeleteFromSelection(selectedItems, handleRemove, selectedItemWrapperRef); + + const handleSelect = (item: Option) => { + setSelectedItems((prevItems) => { + if (!prevItems.some((selected) => selected.value === item.value)) { + const newItems = [...prevItems, { ...item, searchValue: "" }]; + // Schedule the edit of the last item after the state update + setTimeout(() => handleFocusOnClick(newItems.length - 1), 0); + return newItems; + } + return prevItems; + }); + }; + + const handleFocusOnClick = (index: number) => { + setCurrentFocusedItemIndex(index); + }; + + const handleEditChange = (item: SearchItem, index: number) => { + setSelectedItems((prevItems) => { + const newSearchValue = item.searchValue; + const newItems = [...prevItems]; + newItems[index] = { ...item, searchValue: newSearchValue }; + + setSearchParams({ + [item.value]: newSearchValue.length > 0 ? newSearchValue : null, + }); + + return newItems; + }); + }; + + const handleEditBlur = () => { + setCurrentFocusedItemIndex(NO_ITEM_EDITING); + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === KEYS.ENTER) { + setCurrentFocusedItemIndex(NO_ITEM_EDITING); + } + }; + + return ( + + + + + {/* Forces popover content to strech relative to its parent */} + + + + + {OPTIONS.map((framework) => ( + { + //Focuses on clicked item if exists + setCurrentFocusedItemIndex( + selectedItems.findIndex((i) => i.value === framework.value), + ); + handleSelect(framework); + }} + className="group" + > +
+
+ {framework.label} +
+
+ {OPTION_EXPLANATIONS[framework.value]} +
+
+ item.value === framework.value) + ? "opacity-100" + : "opacity-0", + )} + /> +
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx new file mode 100644 index 0000000000..2ddb4e4ac1 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx @@ -0,0 +1,281 @@ +// Reference: https://github.com/supabase/supabase/blob/master/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx +import { format } from "date-fns"; +import { Clock } from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; + +export type Time = { + HH: string; + mm: string; + ss: string; +}; + +export type TimeType = "HH" | "mm" | "ss"; + +export interface TimeSplitInputProps { + time: Time; + setTime: (x: Time) => void; + type: "start" | "end"; + setStartTime: (x: Time) => void; + setEndTime: (x: Time) => void; + startTime: Time; + endTime: Time; + startDate: Date; + endDate: Date; +} + +const TimeSplitInput = ({ + type, + time, + setTime, + setStartTime, + setEndTime, + startTime, + endTime, + startDate, + endDate, +}: TimeSplitInputProps) => { + const [focus, setFocus] = useState(false); + + function handleOnBlur() { + const _time = time; + + if (_time.HH.length === 1) { + _time.HH = `0${_time.HH}`; + } + if (_time.mm.length === 1) { + _time.mm = `0${_time.mm}`; + } + if (_time.ss.length === 1) { + _time.ss = `0${_time.ss}`; + } + + if (!_time.HH) { + _time.HH = "00"; + } + if (!_time.mm) { + _time.mm = "00"; + } + if (!_time.ss) { + _time.ss = "00"; + } + + let endTimeChanges = false; + const endTimePayload = endTime; + + let startTimeChanges = false; + const startTimePayload = startTime; + + // Only run time conflicts if + // startDate and endDate are the same date + + if (format(new Date(startDate), "dd/mm/yyyy") === format(new Date(endDate), "dd/mm/yyyy")) { + // checks if start time is ahead of end time + + if (type === "start") { + if (_time.HH && Number(_time.HH) > Number(endTime.HH)) { + endTimePayload.HH = _time.HH; + endTimeChanges = true; + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) >= Number(endTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) > Number(endTime.mm) + ) { + endTimePayload.mm = _time.mm; + endTimeChanges = true; + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) >= Number(endTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) >= Number(endTime.mm) && + // check the seconds + _time.ss && + Number(_time.ss) > Number(endTime.ss) + ) { + endTimePayload.ss = _time.ss; + endTimeChanges = true; + } + } + + if (type === "end") { + if (_time.HH && Number(_time.HH) < Number(startTime.HH)) { + startTimePayload.HH = _time.HH; + startTimeChanges = true; + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) <= Number(startTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) < Number(startTime.mm) + ) { + startTimePayload.mm = _time.mm; + startTimeChanges = true; + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) <= Number(startTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) <= Number(startTime.mm) && + // check the seconds + _time.ss && + Number(_time.ss) < Number(startTime.ss) + ) { + startTimePayload.ss = _time.ss; + startTimeChanges = true; + } + } + } + + setTime({ ..._time }); + + if (endTimeChanges) { + setEndTime({ ...endTimePayload }); + } + if (startTimeChanges) { + setStartTime({ ...startTimePayload }); + } + + setFocus(false); + } + + function handleOnChange(value: string, valueType: TimeType) { + const payload = { + HH: time.HH, + mm: time.mm, + ss: time.ss, + }; + if (value.length > 2) { + return; + } + + switch (valueType) { + case "HH": + if (value && Number(value) > 23) { + return; + } + break; + case "mm": + if (value && Number(value) > 59) { + return; + } + break; + case "ss": + if (value && Number(value) > 59) { + return; + } + break; + default: + break; + } + + payload[valueType] = value; + setTime({ ...payload }); + } + + const handleFocus = (event: React.FocusEvent) => { + event.target.select(); + setFocus(true); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: no need to call every + useEffect(() => { + handleOnBlur(); + }, [startDate, endDate]); + + return ( +
+
+ +
+ + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-23]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, "HH")} + aria-label="Hours" + className=" + ring-none + w-4 + border-none + bg-transparent + p-0 text-center text-xs + text-foreground + outline-none + ring-0 + focus:ring-0 + " + value={time.HH} + /> + : + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-12]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, "mm")} + aria-label="Minutes" + className=" + ring-none + w-4 + border-none + bg-transparent + p-0 text-center text-xs + text-foreground + outline-none + ring-0 + focus:ring-0 + " + value={time.mm} + /> + : + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-59]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, "ss")} + aria-label="Seconds" + className=" + ring-none + w-4 + border-none + bg-transparent + p-0 text-center text-xs + text-foreground + outline-none + ring-0 + focus:ring-0 + " + value={time.ss} + /> +
+ ); +}; + +export default TimeSplitInput; diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/timeline.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/timeline.tsx new file mode 100644 index 0000000000..f55be9ad91 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/components/timeline.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { sub } from "date-fns"; +import { Check, Clock } from "lucide-react"; +import { useState } from "react"; +import { type Timeline as TimelineType, useLogSearchParams } from "../../../query-state"; + +const OPTIONS = [ + { value: "1h", label: "Last hour" }, + { value: "3h", label: "Last 3 hours" }, + { value: "6h", label: "Last 6 hours" }, + { value: "12h", label: "Last 12 hours" }, + { value: "24h", label: "Last 24 hours" }, +] as const; + +export function Timeline() { + const [open, setOpen] = useState(false); + const [value, setValue] = useState("1h"); + + const { setSearchParams } = useLogSearchParams(); + + const handleTimelineSet = (value: TimelineType) => { + setValue(value); + const now = new Date(); + + const startTime = sub(now, { + hours: Number.parseInt(value.replace("h", "")), + }); + + setSearchParams({ + startTime: startTime.getTime(), + endTime: now.getTime(), + }); + setOpen(false); + }; + + return ( + + +
+ + {OPTIONS.find((o) => o.value === value)?.label} +
+
+ + + + + {OPTIONS.map((option) => ( + handleTimelineSet(v as TimelineType)} + > + + {option.label} + + ))} + + + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/components/filters/index.tsx b/apps/dashboard/app/(app)/logs/components/filters/index.tsx new file mode 100644 index 0000000000..0f0424cea8 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/filters/index.tsx @@ -0,0 +1,56 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/group-button"; +import { RefreshCcw } from "lucide-react"; +import { ONE_DAY_MS } from "../../constants"; +import { useLogSearchParams } from "../../query-state"; +import { DatePickerWithRange } from "./components/custom-date-filter"; +import { ResponseStatus } from "./components/response-status"; +import { SearchCombobox } from "./components/search-combobox/search-combobox"; +import { Timeline } from "./components/timeline"; + +export const LogsFilters = () => { + const { setSearchParams } = useLogSearchParams(); + + const handleRefresh = () => { + const now = Date.now(); + const startTime = now - ONE_DAY_MS; + const endTime = Date.now(); + + setSearchParams({ + endTime: endTime, + host: null, + method: null, + path: null, + requestId: null, + responseStatus: [], + startTime: startTime, + }); + }; + + return ( +
+
+
+ +
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/log-body.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/log-body.tsx new file mode 100644 index 0000000000..f15bcbd256 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/log-body.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Copy } from "lucide-react"; +import React, { useEffect } from "react"; +import { createHighlighter } from "shiki"; + +type Props = { + field: string; + title: string; +}; + +const highlighter = createHighlighter({ + themes: ["github-light", "github-dark"], + langs: ["json"], +}); + +export function LogBody({ field, title }: Props) { + const [innerHtml, setHtml] = React.useState("Loading..."); + + useEffect(() => { + highlighter.then((highlight) => { + const html = highlight.codeToHtml(JSON.stringify(JSON.parse(field), null, 2), { + lang: "json", + themes: { + dark: "github-dark", + light: "github-light", + }, + mergeWhitespaces: true, + }); + setHtml(html); + }); + }, [field]); + + return ( +
+ {title} + + +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/log-footer.tsx new file mode 100644 index 0000000000..2a7a1f1c2c --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/log-footer.tsx @@ -0,0 +1,100 @@ +"use client"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { RED_STATES, YELLOW_STATES } from "../../../constants"; +import type { Log } from "../../../types"; +import { getRequestHeader, getResponseBodyFieldOutcome } from "../../../utils"; +import { RequestResponseDetails } from "./request-response-details"; + +type Props = { + log: Log; +}; +//TODO: Move meta part to log details index, then remove shiki +const DEFAULT_OUTCOME = "VALID"; +export const LogFooter = ({ log }: Props) => { + return ( + {content}, + content: format(log.time, "MMM dd HH:mm:ss.SS"), + tooltipContent: "Copy Time", + tooltipSuccessMessage: "Time copied to clipboard", + }, + { + label: "Host", + description: (content) => {content}, + content: log.host, + tooltipContent: "Copy Host", + tooltipSuccessMessage: "Host copied to clipboard", + }, + { + label: "Request Path", + description: (content) => {content}, + content: log.path, + tooltipContent: "Copy Request Path", + tooltipSuccessMessage: "Request path copied to clipboard", + }, + { + label: "Request ID", + description: (content) => {content}, + content: log.request_id, + tooltipContent: "Copy Request ID", + tooltipSuccessMessage: "Request ID copied to clipboard", + }, + { + label: "Request User Agent", + description: (content) => {content}, + content: getRequestHeader(log, "user-agent") ?? "", + tooltipContent: "Copy Request User Agent", + tooltipSuccessMessage: "Request user agent copied to clipboard", + }, + { + label: "Outcome", + description: (content) => { + let contentCopy = content; + if (contentCopy == null) { + contentCopy = DEFAULT_OUTCOME; + } + return ( + + {content} + + ); + }, + content: getResponseBodyFieldOutcome(log, "code"), + tooltipContent: "Copy Outcome", + tooltipSuccessMessage: "Outcome copied to clipboard", + }, + { + label: "Permissions", + description: (content) => ( + + {content.map((permission) => ( + + {permission} + + ))} + + ), + content: getResponseBodyFieldOutcome(log, "permissions"), + tooltipContent: "Copy Permissions", + tooltipSuccessMessage: "Permissions copied to clipboard", + }, + ]} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/log-header.tsx new file mode 100644 index 0000000000..3314efba42 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/log-header.tsx @@ -0,0 +1,38 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { X } from "lucide-react"; +import type { Log } from "../../../types"; + +type Props = { + log: Log; + onClose: () => void; +}; +export const LogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.method} + +

{log.path}

+
+ +
+ = 400 && "border-red-6 text-red-11", + )} + > + {log.response_status} + + + | + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/log-meta.tsx new file mode 100644 index 0000000000..1179f768eb --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/log-meta.tsx @@ -0,0 +1,14 @@ +import { Card, CardContent } from "@/components/ui/card"; + +export const LogMetaSection = ({ content }: { content: string }) => { + return ( +
+
Meta
+ + +
{content}
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/log-section.tsx new file mode 100644 index 0000000000..6c194fb45b --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/log-section.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent } from "@/components/ui/card"; + +export const LogSection = ({ + details, + title, +}: { + details: string[]; + title: string; +}) => { + return ( +
+ {title} + + +
+            {details.map((header) => {
+              const [key, ...valueParts] = header.split(":");
+              const value = valueParts.join(":").trim();
+              return (
+                
+                  {key}
+                  
+                    : {value}
+                  
+                  {"\n"}
+                
+              );
+            })}
+          
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/meta-content.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/meta-content.tsx new file mode 100644 index 0000000000..ac43f8a1cb --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/meta-content.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { useEffect, useState } from "react"; +import { createHighlighter } from "shiki"; + +const highlighter = createHighlighter({ + themes: ["github-light", "github-dark"], + langs: ["json"], +}); + +export function MetaContent({ content }: { content: any }) { + const [innerHtml, setHtml] = useState("Loading..."); + + useEffect(() => { + highlighter.then((highlight) => { + const html = highlight.codeToHtml(JSON.stringify(content), { + lang: "json", + themes: { + dark: "github-dark", + light: "github-light", + }, + mergeWhitespaces: true, + }); + setHtml(html); + }); + }, [content]); + + return ( + + + + ); +} diff --git a/apps/dashboard/app/(app)/logs/components/log-details/components/request-response-details.tsx b/apps/dashboard/app/(app)/logs/components/log-details/components/request-response-details.tsx new file mode 100644 index 0000000000..1daff3293b --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/components/request-response-details.tsx @@ -0,0 +1,85 @@ +import { toast } from "@/components/ui/toaster"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +type Field = { + label: string; + description: (content: NonNullable) => ReactNode; + content: T | null; + tooltipContent: ReactNode; + tooltipSuccessMessage: string; + className?: string; +}; + +type Props = { + fields: { [K in keyof T]: Field }; + className?: string; +}; +//This function ensures that content is not nil, and if it's an object or array, it has some content. +const isNonEmpty = (content: unknown): boolean => { + if (content === undefined || content === null) { + return false; + } + + if (Array.isArray(content)) { + return content.some((item) => item !== null && item !== undefined); + } + + if (typeof content === "object" && content !== null) { + return Object.values(content).some((value) => value !== null && value !== undefined); + } + + if (typeof content === "string") { + return content.trim().length > 0; + } + + return Boolean(content); +}; + +export const RequestResponseDetails = ({ fields, className }: Props) => { + const handleClick = (field: Field) => { + try { + const text = + typeof field.content === "object" ? JSON.stringify(field.content) : String(field.content); + + navigator.clipboard + .writeText(text) + .then(() => { + toast.success(field.tooltipSuccessMessage); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + } catch (error) { + console.error("Error preparing content for clipboard:", error); + toast.error("Failed to prepare content for clipboard"); + } + }; + return ( +
+ {fields.map( + (field, index) => + isNonEmpty(field.content) && ( + + + handleClick(field)} + > + {field.label} + {field.description(field.content as NonNullable)} + + {field.tooltipContent} + + + ), + )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/log-details/index.tsx b/apps/dashboard/app/(app)/logs/components/log-details/index.tsx new file mode 100644 index 0000000000..9db4378e02 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/index.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { memo, useMemo, useState } from "react"; +import { useDebounceCallback } from "usehooks-ts"; +import { DEFAULT_DRAGGABLE_WIDTH } from "../../constants"; +import type { Log } from "../../types"; +import { LogFooter } from "./components/log-footer"; +import ResizablePanel from "./resizable-panel"; +import { getResponseBodyFieldOutcome } from "../../utils"; +import { LogMetaSection } from "./components/log-meta"; +import { LogHeader } from "./components/log-header"; +import { LogSection } from "./components/log-section"; + +type Props = { + log: Log | null; + onClose: () => void; + distanceToTop: number; +}; + +const PANEL_WIDTH_SET_DELAY = 150; + +const _LogDetails = ({ log, onClose, distanceToTop }: Props) => { + const [panelWidth, setPanelWidth] = useState(DEFAULT_DRAGGABLE_WIDTH); + + const debouncedSetPanelWidth = useDebounceCallback((newWidth) => { + setPanelWidth(newWidth); + }, PANEL_WIDTH_SET_DELAY); + + const panelStyle = useMemo( + () => ({ + top: `${distanceToTop}px`, + width: `${panelWidth}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", + }), + [distanceToTop, panelWidth] + ); + + if (!log) { + return null; + } + + return ( + + + +
+
+ + + + +
+ + + + ); +}; + +// Without memo each time trpc makes a request LogDetails re-renders +export const LogDetails = memo( + _LogDetails, + (prev, next) => prev.log?.request_id === next.log?.request_id +); + +function flattenObject(obj: object, prefix = ""): string[] { + return Object.entries(obj).flatMap(([key, value]) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null) { + return flattenObject(value, newKey); + } + return `${newKey}:${value}`; + }); +} diff --git a/apps/dashboard/app/(app)/logs/components/log-details/resizable-panel.tsx b/apps/dashboard/app/(app)/logs/components/log-details/resizable-panel.tsx new file mode 100644 index 0000000000..f37164e3da --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/log-details/resizable-panel.tsx @@ -0,0 +1,81 @@ +import type React from "react"; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; +import { useOnClickOutside } from "usehooks-ts"; +import { MAX_DRAGGABLE_WIDTH, MIN_DRAGGABLE_WIDTH } from "../../constants"; + +const ResizablePanel = ({ + children, + onResize, + onClose, + className, + style, + minW = MIN_DRAGGABLE_WIDTH, + maxW = MAX_DRAGGABLE_WIDTH, +}: PropsWithChildren<{ + onResize?: (newWidth: number) => void; + onClose: () => void; + className: string; + style: Record; + minW?: number; + maxW?: number; +}>) => { + const [isDragging, setIsDragging] = useState(false); + const [width, setWidth] = useState(String(style?.width)); + const panelRef = useRef(null); + + useOnClickOutside(panelRef, onClose); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !panelRef.current) { + return; + } + + const containerRect = panelRef.current.getBoundingClientRect(); + const newWidth = Math.min(Math.max(containerRect.right - e.clientX, minW), maxW); + setWidth(`${newWidth}px`); + onResize?.(newWidth); + }, + [isDragging, minW, maxW, onResize], + ); + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } else { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+
+ {children} +
+ ); +}; + +export default ResizablePanel; diff --git a/apps/dashboard/app/(app)/logs/components/logs-table.tsx b/apps/dashboard/app/(app)/logs/components/logs-table.tsx new file mode 100644 index 0000000000..84e2205339 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/logs-table.tsx @@ -0,0 +1,170 @@ +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ScrollText } from "lucide-react"; +import { useRef, useState } from "react"; +import type { Log } from "../types"; +import { LogDetails } from "./log-details"; + +const TABLE_BORDER_THICKNESS = 1; +const ROW_HEIGHT = 26; + +export const LogsTable = ({ logs }: { logs?: Log[] }) => { + const [selectedLog, setSelectedLog] = useState(null); + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: logs?.length ?? 0, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 5, + }); + + const handleLogSelection = (log: Log) => { + setSelectedLog(log); + setTableDistanceToTop( + document.getElementById("log-table")!.getBoundingClientRect().top + + window.scrollY - + TABLE_BORDER_THICKNESS, + ); + }; + + return ( +
+
+
Time
+
Status
+
Request
+
Message
+
+
+ +
+ {logs?.length === 0 || !logs ? ( +
+ + + +
+ There are no runtime logs in this time range +
+
+
+
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const l = logs ? logs[virtualRow.index] : null; + return ( + l && ( +
handleLogSelection(l)} + tabIndex={virtualRow.index} + aria-selected={selectedLog?.request_id === l.request_id} + onKeyDown={(event) => { + // Handle Enter or Space key press + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleLogSelection(l); + } + + // Add arrow key navigation + if (event.key === "ArrowDown") { + event.preventDefault(); + const nextElement = document.querySelector( + `[data-index="${virtualRow.index + 1}"]`, + ) as HTMLElement; + nextElement?.focus(); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + const prevElement = document.querySelector( + `[data-index="${virtualRow.index - 1}"]`, + ) as HTMLElement; + prevElement?.focus(); + } + }} + className={cn( + "font-mono grid grid-cols-[166px_72px_20%_1fr] text-[13px] leading-[14px] mb-[1px] rounded-[5px] h-[26px] cursor-pointer absolute top-0 left-0 w-full", + "hover:bg-background-subtle/90 pl-1", + { + "bg-amber-2 text-amber-11 hover:bg-amber-3": + l.response_status >= 400 && l.response_status < 500, + "bg-red-2 text-red-11 hover:bg-red-3": l.response_status >= 500, + }, + selectedLog && { + "opacity-50": selectedLog.request_id !== l.request_id, + "opacity-100": selectedLog.request_id === l.request_id, + "bg-background-subtle/90": + selectedLog.request_id === l.request_id && + l.response_status >= 200 && + l.response_status < 300, + "bg-amber-3": + selectedLog.request_id === l.request_id && + l.response_status >= 400 && + l.response_status < 500, + "bg-red-3": + selectedLog.request_id === l.request_id && l.response_status >= 500, + }, + )} + style={{ + transform: `translateY(${virtualRow.start}px)`, + }} + > +
+ +
+
+ = 400, + }, + "uppercase", + )} + > + {l.response_status} + +
+
+ + {l.method} + + {l.path} +
+
+ {l.response_body} +
+
+ ) + ); + })} +
+ )} + setSelectedLog(null)} + distanceToTop={tableDistanceToTop} + /> +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/constants.ts b/apps/dashboard/app/(app)/logs/constants.ts new file mode 100644 index 0000000000..22bb62f1cb --- /dev/null +++ b/apps/dashboard/app/(app)/logs/constants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_DRAGGABLE_WIDTH = 500; +export const MAX_DRAGGABLE_WIDTH = 800; +export const MIN_DRAGGABLE_WIDTH = 300; + +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_LOGS_FETCH_COUNT = 100; + +export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; +export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; diff --git a/apps/dashboard/app/(app)/logs/logs-page.tsx b/apps/dashboard/app/(app)/logs/logs-page.tsx new file mode 100644 index 0000000000..b3a11f31e3 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/logs-page.tsx @@ -0,0 +1,84 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import { useCallback, useEffect, useState } from "react"; +import { useInterval } from "usehooks-ts"; +import { LogsChart } from "./components/chart"; +import { LogsFilters } from "./components/filters"; +import { LogsTable } from "./components/logs-table"; +import { ONE_DAY_MS } from "./constants"; +import { useLogSearchParams } from "./query-state"; +import type { Log } from "./types"; + +export function LogsPage({ + initialLogs, + workspaceId, +}: { + initialLogs: Log[]; + workspaceId: string; +}) { + const { searchParams } = useLogSearchParams(); + const [logs, setLogs] = useState(initialLogs); + const [endTime, setEndTime] = useState(() => searchParams.endTime); + + // Update to current timestamp every 3s unless endTime is fixed in URL params + useInterval(() => setEndTime(Date.now()), searchParams.endTime ? null : 3000); + + const { data: newData } = trpc.logs.queryLogs.useQuery( + { + workspaceId, + limit: 100, + startTime: searchParams.startTime ?? endTime - ONE_DAY_MS, + endTime, + host: searchParams.host, + requestId: searchParams.requestId, + method: searchParams.method, + path: searchParams.path, + responseStatus: searchParams.responseStatus, + }, + { + refetchInterval: searchParams.endTime ? false : 3000, + keepPreviousData: true, + }, + ); + + const updateLogs = useCallback(() => { + // If any filter is set, replace all logs with new data + const hasFilters = Boolean( + searchParams.host || + searchParams.requestId || + searchParams.path || + searchParams.method || + searchParams.responseStatus, + ); + + if (hasFilters) { + setLogs(newData ?? []); + return; + } + + // No new data to process + if (!newData?.length) { + return; + } + + // Merge new logs with existing ones, avoiding duplicates + setLogs((prevLogs) => { + const existingIds = new Set(prevLogs.map((log) => log.request_id)); + const uniqueNewLogs = newData.filter((newLog) => !existingIds.has(newLog.request_id)); + + return [...uniqueNewLogs, ...prevLogs]; + }); + }, [newData, searchParams]); + + useEffect(() => { + updateLogs(); + }, [updateLogs]); + + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/page.tsx b/apps/dashboard/app/(app)/logs/page.tsx index cb7e27341e..b3050f2650 100644 --- a/apps/dashboard/app/(app)/logs/page.tsx +++ b/apps/dashboard/app/(app)/logs/page.tsx @@ -1,11 +1,22 @@ -import { PageHeader } from "@/components/dashboard/page-header"; +"use server"; + import { getTenantId } from "@/lib/auth"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; +import { createSearchParamsCache } from "nuqs/server"; +import { DEFAULT_LOGS_FETCH_COUNT } from "./constants"; +import { LogsPage } from "./logs-page"; +import { queryParamsPayload } from "./query-state"; -export const revalidate = 0; +const searchParamsCache = createSearchParamsCache(queryParamsPayload); -export default async function Page() { +export default async function Page({ + searchParams, +}: { + params: { slug: string }; + searchParams: Record; +}) { + const parsedParams = searchParamsCache.parse(searchParams); const tenantId = getTenantId(); const workspace = await db.query.workspaces.findFirst({ @@ -16,13 +27,20 @@ export default async function Page() { return
Workspace with tenantId: {tenantId} not found
; } - const logs = await clickhouse.api.logs({ workspaceId: workspace.id, limit: 10 }); - - return ( -
- + const logs = await clickhouse.api.logs({ + workspaceId: workspace.id, + limit: DEFAULT_LOGS_FETCH_COUNT, + startTime: parsedParams.startTime, + endTime: parsedParams.endTime, + host: parsedParams.host, + requestId: parsedParams.requestId, + method: parsedParams.method, + path: parsedParams.path, + responseStatus: parsedParams.responseStatus, + }); + if (logs.err) { + throw new Error(`Something went wrong when fetching logs from ClickHouse: ${logs.err.message}`); + } -
{JSON.stringify(logs, null, 2)}
-
- ); + return ; } diff --git a/apps/dashboard/app/(app)/logs/query-state.ts b/apps/dashboard/app/(app)/logs/query-state.ts new file mode 100644 index 0000000000..513d847f6e --- /dev/null +++ b/apps/dashboard/app/(app)/logs/query-state.ts @@ -0,0 +1,44 @@ +import { + parseAsArrayOf, + parseAsInteger, + parseAsNumberLiteral, + parseAsString, + useQueryStates, +} from "nuqs"; +import { useCallback } from "react"; +import { ONE_DAY_MS } from "./constants"; + +export type PickKeys = K; + +const TIMELINE_OPTIONS = ["1h", "3h", "6h", "12h", "24h"] as const; +export const STATUSES = [400, 500, 200] as const; +export type ResponseStatus = (typeof STATUSES)[number]; +export type Timeline = (typeof TIMELINE_OPTIONS)[number]; + +export type QuerySearchParams = { + host: string; + requestId: string; + method: string; + path: string; + responseStatuses: ResponseStatus[]; + startTime: number; + endTime: number; +}; + +export const queryParamsPayload = { + requestId: parseAsString, + host: parseAsString, + method: parseAsString, + path: parseAsString, + responseStatus: parseAsArrayOf(parseAsNumberLiteral(STATUSES)).withDefault( + [] + ), + startTime: parseAsInteger.withDefault(Date.now() - ONE_DAY_MS), + endTime: parseAsInteger.withDefault(Date.now()), +}; + +export const useLogSearchParams = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + + return { searchParams, setSearchParams }; +}; diff --git a/apps/dashboard/app/(app)/logs/types.ts b/apps/dashboard/app/(app)/logs/types.ts new file mode 100644 index 0000000000..8936833049 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/types.ts @@ -0,0 +1,31 @@ +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; +}; + +export type ResponseBody = { + keyId: string; + valid: boolean; + meta: Record; + enabled: boolean; + permissions: string[]; + code: + | "VALID" + | "RATE_LIMITED" + | "EXPIRED" + | "USAGE_EXCEEDED" + | "DISABLED" + | "FORBIDDEN" + | "INSUFFICIENT_PERMISSIONS"; +}; diff --git a/apps/dashboard/app/(app)/logs/utils.ts b/apps/dashboard/app/(app)/logs/utils.ts new file mode 100644 index 0000000000..3a5daae8ad --- /dev/null +++ b/apps/dashboard/app/(app)/logs/utils.ts @@ -0,0 +1,71 @@ +import type { Log, ResponseBody } from "./types"; + +class ResponseBodyParseError extends Error { + constructor( + message: string, + public readonly context?: unknown, + ) { + super(message); + this.name = "ResponseBodyParseError"; + } +} + +export const getResponseBodyFieldOutcome = ( + log: Log, + fieldName: K, +): ResponseBody[K] | null => { + if (!log?.response_body) { + console.error("Invalid log or missing response_body"); + return null; + } + + try { + const parsedBody = JSON.parse(log.response_body) as ResponseBody; + + if (typeof parsedBody !== "object" || parsedBody === null) { + throw new ResponseBodyParseError("Parsed response body is not an object", parsedBody); + } + + if (!(fieldName in parsedBody)) { + throw new ResponseBodyParseError(`Field "${String(fieldName)}" not found in response body`, { + availableFields: Object.keys(parsedBody), + }); + } + + return parsedBody[fieldName]; + } catch (error) { + if (error instanceof ResponseBodyParseError) { + console.error(`Error parsing response body or accessing field: ${error.message}`, { + context: error.context, + fieldName, + logId: log.request_id, + }); + } else { + console.error("An unknown error occurred while parsing response body"); + } + return null; + } +}; + +export const getRequestHeader = (log: Log, headerName: string): string | null => { + if (!headerName.trim()) { + console.error("Invalid header name provided"); + return null; + } + + if (!Array.isArray(log.request_headers)) { + console.error("request_headers is not an array"); + return null; + } + + const lowerHeaderName = headerName.toLowerCase(); + const header = log.request_headers.find((h) => h.toLowerCase().startsWith(`${lowerHeaderName}:`)); + + if (!header) { + console.warn(`Header "${headerName}" not found in request headers`); + return null; + } + + const [, value] = header.split(":", 2); + return value ? value.trim() : null; +}; diff --git a/apps/dashboard/components/timestamp-info.tsx b/apps/dashboard/components/timestamp-info.tsx new file mode 100644 index 0000000000..ee5b10d609 --- /dev/null +++ b/apps/dashboard/components/timestamp-info.tsx @@ -0,0 +1,109 @@ +//https://github.com/supabase/supabase/blob/master/packages/ui-patterns/TimestampInfo/index.tsx +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { format, formatRelative, fromUnixTime } from "date-fns"; +import { useEffect, useRef, useState } from "react"; + +const unixMicroToDate = (unix: string | number): Date => { + return fromUnixTime(Number(unix) / 1000 / 1000); +}; + +const isUnixMicro = (unix: string | number): boolean => { + const digitLength = String(unix).length === 16; + const isNum = !Number.isNaN(Number(unix)); + return isNum && digitLength; +}; + +const timestampLocalFormatter = (value: string | number) => { + const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); + return format(date, "dd MMM HH:mm:ss"); +}; + +const timestampUtcFormatter = (value: string | number) => { + const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); + const isoDate = date.toISOString(); + const utcDate = `${isoDate.substring(0, 10)} ${isoDate.substring(11, 19)}`; + return format(utcDate, "dd MMM HH:mm:ss"); +}; + +const timestampRelativeFormatter = (value: string | number) => { + const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); + return formatRelative(date, new Date()); +}; + +export const TimestampInfo = ({ + value, + className, +}: { + className?: string; + value: string | number; +}) => { + const local = timestampLocalFormatter(value); + const utc = timestampUtcFormatter(value); + const relative = timestampRelativeFormatter(value); + const [align, setAlign] = useState<"start" | "end">("start"); + const triggerRef = useRef(null); + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + useEffect(() => { + const updateAlignment = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + const windowHeight = window.innerHeight; + setAlign(rect.top < windowHeight / 2 ? "start" : "end"); + } + }; + updateAlignment(); + window.addEventListener("scroll", updateAlignment); + window.addEventListener("resize", updateAlignment); + return () => { + window.removeEventListener("scroll", updateAlignment); + window.removeEventListener("resize", updateAlignment); + }; + }, []); + + const TooltipRow = ({ label, value }: { label: string; value: string }) => { + const [copied, setCopied] = useState(false); + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: + { + e.stopPropagation(); + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1000); + }} + className={cn( + "flex items-center space-x-2 hover:bg-background-subtle text-left px-3 py-2", + { + "bg-background-subtle": copied, + }, + )} + > + {label}: + {copied ? "Copied!" : value} + + ); + }; + + return ( + + + {timestampLocalFormatter(value)} + + + +
+ +
+ +
+ + + + ); +}; diff --git a/apps/dashboard/components/ui/calendar.tsx b/apps/dashboard/components/ui/calendar.tsx new file mode 100644 index 0000000000..9260b45371 --- /dev/null +++ b/apps/dashboard/components/ui/calendar.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/apps/dashboard/components/ui/chart.tsx b/apps/dashboard/components/ui/chart.tsx new file mode 100644 index 0000000000..cf2f8771f8 --- /dev/null +++ b/apps/dashboard/components/ui/chart.tsx @@ -0,0 +1,329 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + {children} +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +