From fc8fec6b36a202126bef36b21eaede0df7db83e4 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 14:22:14 +0300 Subject: [PATCH 01/68] fix: style issues --- .../components/filter-checkbox.tsx | 37 +++++---- .../logs-filters/components/paths-filter.tsx | 38 +++++---- .../controls/components/logs-search/index.tsx | 83 +++++++++++-------- 3 files changed, 95 insertions(+), 63 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx index 7541df11e..ff5928c22 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx @@ -21,7 +21,9 @@ interface BaseCheckboxFilterProps { scrollContainerRef?: React.RefObject; renderBottomGradient?: () => React.ReactNode; renderOptionContent?: (option: TCheckbox) => React.ReactNode; - createFilterValue: (option: TCheckbox) => Pick; + createFilterValue: ( + option: TCheckbox + ) => Pick; } export const FilterCheckbox = ({ @@ -36,15 +38,18 @@ export const FilterCheckbox = ({ renderBottomGradient, }: BaseCheckboxFilterProps) => { const { filters, updateFilters } = useFilters(); - const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ - options, - filters, - filterField, - checkPath, - }); + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = + useCheckboxState({ + options, + filters, + filterField, + checkPath, + }); const handleApplyFilter = useCallback(() => { - const selectedValues = checkboxes.filter((c) => c.checked).map((c) => createFilterValue(c)); + const selectedValues = checkboxes + .filter((c) => c.checked) + .map((c) => createFilterValue(c)); const otherFilters = filters.filter((f) => f.field !== filterField); const newFilters: FilterValue[] = selectedValues.map((filterValue) => ({ @@ -63,13 +68,13 @@ export const FilterCheckbox = ({ className={cn( "flex flex-col gap-2 font-mono px-2 py-2", showScroll && - "max-h-64 overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", + "max-h-64 overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]" )} ref={scrollContainerRef} >
{checkboxes.map((checkbox, index) => ( diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx index 50e521879..728b8f550 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx @@ -1,10 +1,20 @@ import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; import { KeyboardButton } from "@/components/keyboard-button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { CaretRight } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { + type KeyboardEvent, + type PropsWithChildren, + useEffect, + useRef, + useState, +} from "react"; import { MethodsFilter } from "./methods-filter"; import { PathsFilter } from "./paths-filter"; import { StatusFilter } from "./status-filter"; @@ -42,6 +52,13 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { const [focusedIndex, setFocusedIndex] = useState(null); const [activeFilter, setActiveFilter] = useState(null); + // Clean up activeFilter to prevent unnecessary render when opening and closing + useEffect(() => { + return () => { + setActiveFilter(null); + }; + }, [open]); + useKeyboardShortcut("f", () => { setOpen((prev) => !prev); }); @@ -51,10 +68,12 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { return; } - // If we have an active filter and press left, close it - if ((e.key === "ArrowLeft" || e.key === "h") && activeFilter) { - e.preventDefault(); - setActiveFilter(null); + // Don't handle navigation in main popover if we have an active filter + if (activeFilter) { + if (e.key === "ArrowLeft" || e.key === "h") { + e.preventDefault(); + setActiveFilter(null); + } return; } @@ -62,7 +81,9 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { case "ArrowDown": case "j": e.preventDefault(); - setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length)); + setFocusedIndex((prev) => + prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length + ); break; case "ArrowUp": case "k": @@ -70,7 +91,7 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { setFocusedIndex((prev) => prev === null ? FILTER_ITEMS.length - 1 - : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, + : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length ); break; case "Enter": @@ -80,13 +101,7 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { if (focusedIndex !== null) { const selectedFilter = FILTER_ITEMS[focusedIndex]; if (selectedFilter) { - // Find the filterItem component and trigger its open state - const filterRefs = document.querySelectorAll("[data-filter-id]"); - const selectedRef = filterRefs[focusedIndex] as HTMLElement; - if (selectedRef) { - selectedRef.click(); - setActiveFilter(selectedFilter.id); - } + setActiveFilter(selectedFilter.id); } } break; @@ -108,7 +123,12 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => {
{FILTER_ITEMS.map((item, index) => ( - + ))}
@@ -127,12 +147,21 @@ const PopoverHeader = () => { type FilterItemProps = FilterItemConfig & { isFocused?: boolean; + isActive?: boolean; }; -export const FilterItem = ({ label, shortcut, id, component, isFocused }: FilterItemProps) => { +export const FilterItem = ({ + label, + shortcut, + id, + component, + isFocused, + isActive, +}: FilterItemProps) => { const { filters } = useFilters(); const [open, setOpen] = useState(false); const itemRef = useRef(null); + const contentRef = useRef(null); const handleKeyDown = (e: KeyboardEvent) => { if ((e.key === "ArrowLeft" || e.key === "h") && open) { @@ -147,7 +176,7 @@ export const FilterItem = ({ label, shortcut, id, component, isFocused }: Filter () => { setOpen(true); }, - { preventDefault: true }, + { preventDefault: true } ); // Focus the element when isFocused changes @@ -157,6 +186,24 @@ export const FilterItem = ({ label, shortcut, id, component, isFocused }: Filter } }, [isFocused]); + // Handle focus transfer to sub-popover when active + useEffect(() => { + if (isActive && !open) { + setOpen(true); + } + if (isActive && open && contentRef.current) { + // Focus the first focusable element in the sub-popover + const focusableElements = contentRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusableElements.length > 0) { + (focusableElements[0] as HTMLElement).focus(); + } else { + contentRef.current.focus(); + } + } + }, [isActive, open]); + return ( @@ -167,7 +214,6 @@ export const FilterItem = ({ label, shortcut, id, component, isFocused }: Filter ${isFocused ? "bg-gray-3" : ""}`} tabIndex={0} role="button" - data-filter-id={id} >
{shortcut && ( @@ -179,7 +225,9 @@ export const FilterItem = ({ label, shortcut, id, component, isFocused }: Filter title={`Press '⌘${shortcut?.toUpperCase()}' to toggle ${label} options`} /> )} - {label} + + {label} +
{filters.filter((filter) => filter.field === id).length > 0 && ( @@ -187,18 +235,25 @@ export const FilterItem = ({ label, shortcut, id, component, isFocused }: Filter {filters.filter((filter) => filter.field === id).length}
)} -
{component} diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx index cfbba6b05..0e01de495 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx @@ -94,17 +94,15 @@ export const PathsFilter = () => { const [isAtBottom, setIsAtBottom] = useState(false); const scrollContainerRef = useRef(null); - const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = - useCheckboxState({ - options, - filters, - filterField: "paths", - checkPath: "path", - }); + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ + options, + filters, + filterField: "paths", + checkPath: "path", + }); const handleScroll = useCallback(() => { if (scrollContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = - scrollContainerRef.current; + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 1; setIsAtBottom(isBottom); } @@ -122,9 +120,7 @@ export const PathsFilter = () => { }, [handleScroll]); const handleApplyFilter = useCallback(() => { - const selectedPaths = checkboxes - .filter((c) => c.checked) - .map((c) => c.path); + const selectedPaths = checkboxes.filter((c) => c.checked).map((c) => c.path); // Keep all non-paths filters and add new path filters const otherFilters = filters.filter((f) => f.field !== "paths"); @@ -153,9 +149,7 @@ export const PathsFilter = () => { onClick={handleSelectAll} /> - {checkboxes.every((checkbox) => checkbox.checked) - ? "Unselect All" - : "Select All"} + {checkboxes.every((checkbox) => checkbox.checked) ? "Unselect All" : "Select All"}
@@ -177,9 +171,7 @@ export const PathsFilter = () => { className="size-4 rounded border-gray-4 [&_svg]:size-3" onClick={() => handleCheckboxChange(index)} /> -
- {checkbox.path} -
+
{checkbox.path}
))}
diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx index 355a89d11..55730119f 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx @@ -1,18 +1,8 @@ import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; -import { - CaretRightOutline, - CircleInfoSparkle, - Magnifier, - Refresh3, -} from "@unkey/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@unkey/ui"; +import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3 } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useRef, useState } from "react"; @@ -74,7 +64,7 @@ export const LogsSearch = () => { "focus-within:bg-gray-4", "transition-all duration-200", searchText.length > 0 ? "bg-gray-4" : "", - isLoading ? "bg-gray-4" : "" + isLoading ? "bg-gray-4" : "", )} >
@@ -112,9 +102,7 @@ export const LogsSearch = () => {
Try queries like: - - (click to use) - + (click to use)
  • @@ -122,9 +110,7 @@ export const LogsSearch = () => { @@ -134,9 +120,7 @@ export const LogsSearch = () => { @@ -147,9 +131,7 @@ export const LogsSearch = () => { type="button" className="hover:text-accent-11 transition-colors cursor-pointer hover:underline" onClick={() => - handlePresetQuery( - "API calls from a path that includes /api/v1/oz" - ) + handlePresetQuery("API calls from a path that includes /api/v1/oz") } > "API calls from a path that includes /api/v1/oz" From df7353008459c01e6dc57941a1dff2254d57c3fe Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 14:56:40 +0300 Subject: [PATCH 03/68] fix: search focus issue --- .../components/filters-popover.tsx | 38 +++++-------------- .../controls/components/logs-search/index.tsx | 6 ++- .../logs-v2/hooks/use-keyboard-shortcut.tsx | 12 ++++++ 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx index 728b8f550..619cb271f 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx @@ -1,20 +1,10 @@ import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; import { KeyboardButton } from "@/components/keyboard-button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CaretRight } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { - type KeyboardEvent, - type PropsWithChildren, - useEffect, - useRef, - useState, -} from "react"; +import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { MethodsFilter } from "./methods-filter"; import { PathsFilter } from "./paths-filter"; import { StatusFilter } from "./status-filter"; @@ -30,7 +20,7 @@ const FILTER_ITEMS: FilterItemConfig[] = [ { id: "status", label: "Status", - shortcut: "s", + shortcut: "e", component: , }, { @@ -53,6 +43,7 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { const [activeFilter, setActiveFilter] = useState(null); // Clean up activeFilter to prevent unnecessary render when opening and closing + // biome-ignore lint/correctness/useExhaustiveDependencies(a): without clean up doesn't work properly useEffect(() => { return () => { setActiveFilter(null); @@ -81,9 +72,7 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { case "ArrowDown": case "j": e.preventDefault(); - setFocusedIndex((prev) => - prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length - ); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length)); break; case "ArrowUp": case "k": @@ -91,7 +80,7 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { setFocusedIndex((prev) => prev === null ? FILTER_ITEMS.length - 1 - : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length + : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, ); break; case "Enter": @@ -176,7 +165,7 @@ export const FilterItem = ({ () => { setOpen(true); }, - { preventDefault: true } + { preventDefault: true }, ); // Focus the element when isFocused changes @@ -194,7 +183,7 @@ export const FilterItem = ({ if (isActive && open && contentRef.current) { // Focus the first focusable element in the sub-popover const focusableElements = contentRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusableElements.length > 0) { (focusableElements[0] as HTMLElement).focus(); @@ -225,9 +214,7 @@ export const FilterItem = ({ title={`Press '⌘${shortcut?.toUpperCase()}' to toggle ${label} options`} /> )} - - {label} - + {label}
{filters.filter((filter) => filter.field === id).length > 0 && ( @@ -235,12 +222,7 @@ export const FilterItem = ({ {filters.filter((filter) => filter.field === id).length}
)} -
diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx index 55730119f..bb571d3d7 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx @@ -43,6 +43,10 @@ export const LogsSearch = () => { }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + (document.activeElement as HTMLElement)?.blur(); + } if (e.key === "Enter") { e.preventDefault(); handleSearch(searchText); @@ -60,7 +64,7 @@ export const LogsSearch = () => {
0 ? "bg-gray-4" : "", diff --git a/apps/dashboard/app/(app)/logs-v2/hooks/use-keyboard-shortcut.tsx b/apps/dashboard/app/(app)/logs-v2/hooks/use-keyboard-shortcut.tsx index 8b6801216..dc83d452e 100644 --- a/apps/dashboard/app/(app)/logs-v2/hooks/use-keyboard-shortcut.tsx +++ b/apps/dashboard/app/(app)/logs-v2/hooks/use-keyboard-shortcut.tsx @@ -33,6 +33,18 @@ export function useKeyboardShortcut( // Normalize the key to lowercase for comparison const keyMatch = e.key.toLowerCase() === combo.key.toLowerCase(); + // Check if any modifier keys are pressed when they're not part of the shortcut + const hasUnwantedModifiers = + (combo.ctrl === undefined && e.ctrlKey) || + (combo.meta === undefined && e.metaKey) || + (combo.shift === undefined && e.shiftKey) || + (combo.alt === undefined && e.altKey); + + // If unwanted modifiers are pressed, don't trigger the shortcut + if (hasUnwantedModifiers) { + return; + } + // Check modifier keys if specified const ctrlMatch = combo.ctrl === undefined || e.ctrlKey === combo.ctrl; const metaMatch = combo.meta === undefined || e.metaKey === combo.meta; From bebcf773bbc96fb23137eaad27bb70c92c02303f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 15:13:02 +0300 Subject: [PATCH 04/68] feat: add focus to control cloud --- .../logs-v2/components/control-cloud/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx index e4fb5e946..2e2bc565c 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx @@ -106,6 +106,10 @@ export const ControlCloud = () => { updateFilters([]); }); + useKeyboardShortcut({ key: "c", meta: true }, () => { + setFocusedIndex(0); + }); + const handleRemoveFilter = useCallback( (id: string) => { removeFilter(id); @@ -192,7 +196,7 @@ export const ControlCloud = () => { return (
{filters.map((filter, index) => ( @@ -206,8 +210,16 @@ export const ControlCloud = () => { /> ))}
- Clear filters - +
+
+ Clear filters + +
+
+ Focus filters + +
+
); From 150987accf92a37acc8d31408d9142fb39dd9b60 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 17:34:02 +0300 Subject: [PATCH 05/68] feat: adjust rows for dynamic selection --- .../components/display-popover.tsx | 92 ++++++++++ .../components/logs-display/index.tsx | 25 +++ .../logs-v2/components/controls/index.tsx | 16 +- .../(app)/logs-v2/components/logs-client.tsx | 5 +- .../logs-v2/components/table/logs-table.tsx | 110 ++++++++---- .../app/(app)/logs-v2/context/logs.tsx | 95 ++++++++++ .../virtual-table/components/table-row.tsx | 168 ++++++++++++------ .../components/virtual-table/types.ts | 22 ++- 8 files changed, 435 insertions(+), 98 deletions(-) create mode 100644 apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx create mode 100644 apps/dashboard/app/(app)/logs-v2/context/logs.tsx diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx new file mode 100644 index 000000000..be3b57f04 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx @@ -0,0 +1,92 @@ +import { + isDisplayProperty, + useLogsContext, +} from "@/app/(app)/logs-v2/context/logs"; +import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; +import { KeyboardButton } from "@/components/keyboard-button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { type PropsWithChildren, useState } from "react"; + +const DISPLAY_PROPERTIES = [ + { id: "time", label: "Time" }, + { id: "response_status", label: "Status" }, + { id: "method", label: "Method" }, + { id: "path", label: "Path" }, + { id: "response_body", label: "Response Body" }, + { id: "request_id", label: "Request ID" }, + { id: "workspace_id", label: "Workspace ID" }, + { id: "host", label: "Host" }, + { id: "request_headers", label: "Request Headers" }, + { id: "request_body", label: "Request Body" }, + { id: "response_headers", label: "Response Headers" }, +]; + +const DisplayPropertyItem = ({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) => ( +
+ {label} +
+); + +const PopoverHeader = () => { + return ( +
+ Display Properties... + +
+ ); +}; + +export const DisplayPopover = ({ children }: PropsWithChildren) => { + const [open, setOpen] = useState(false); + const { displayProperties, toggleDisplayProperty } = useLogsContext(); + + useKeyboardShortcut("d", () => { + setOpen((prev) => !prev); + }); + + return ( + + {children} + +
+ +
+ {DISPLAY_PROPERTIES.map((prop) => ( + { + if (isDisplayProperty(prop.id)) { + toggleDisplayProperty(prop.id); + } + }} + /> + ))} +
+
+
+
+ ); +}; + +export default DisplayPopover; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx new file mode 100644 index 000000000..c264052d6 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx @@ -0,0 +1,25 @@ +import { Sliders } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { DisplayPopover } from "./components/display-popover"; + +export const LogsDisplay = () => { + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx index 7206c476a..ff6f490a4 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx @@ -1,4 +1,5 @@ -import { Calendar, CircleCarretRight, Refresh3, Sliders } from "@unkey/icons"; +import { Calendar, CircleCarretRight, Refresh3 } from "@unkey/icons"; +import { LogsDisplay } from "./components/logs-display"; import { LogsFilters } from "./components/logs-filters"; import { LogsSearch } from "./components/logs-search"; @@ -15,7 +16,9 @@ export function LogsControls() {
- Last 24 hours + + Last 24 hours +
@@ -26,11 +29,12 @@ export function LogsControls() {
- Refresh + + Refresh +
-
- - Display +
+
diff --git a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx index b12d76baa..8979d6bc7 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx @@ -7,6 +7,7 @@ import { ControlCloud } from "./control-cloud"; import { LogsControls } from "./controls"; import { LogDetails } from "./table/log-details"; import { LogsTable } from "./table/logs-table"; +import { LogsProvider } from "../context/logs"; export const LogsClient = () => { const [selectedLog, setSelectedLog] = useState(null); @@ -21,7 +22,7 @@ export const LogsClient = () => { }, []); return ( - <> + @@ -31,6 +32,6 @@ export const LogsClient = () => { onClose={() => handleLogSelection(null)} distanceToTop={tableDistanceToTop} /> - + ); }; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx index 8452a6c23..67738e7d5 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx @@ -9,6 +9,7 @@ import type { Log } from "@unkey/clickhouse/src/logs"; import { TriangleWarning2 } from "@unkey/icons"; import { useMemo } from "react"; import { generateMockLogs } from "./utils"; +import { isDisplayProperty, useLogsContext } from "../../context/logs"; const logs = generateMockLogs(50); @@ -90,7 +91,20 @@ const getSelectedClassName = (log: Log, isSelected: boolean) => { return style.selected; }; +const WarningIcon = ({ status }: { status: number }) => ( + = 400 && status < 500 && WARNING_ICON_STYLES.warning, + status >= 500 && WARNING_ICON_STYLES.error + )} + /> +); + export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { + const { displayProperties } = useLogsContext(); + const getRowClassName = (log: Log) => { const style = getStatusStyle(log.response_status); const isSelected = selectedLog?.request_id === log.request_id; @@ -102,48 +116,40 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { "focus:outline-none focus:ring-1 focus:ring-opacity-40", style.focusRing, isSelected && style.selected, - // This creates a spotlight effect selectedLog && { "opacity-50 z-0": !isSelected, "opacity-100 z-10": isSelected, - }, + } ); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: it's okay - const columns: Column[] = useMemo( + const basicColumns: Column[] = useMemo( () => [ { key: "time", header: "Time", width: "165px", headerClassName: "pl-9", + noTruncate: true, // Disable truncation for timestamp render: (log) => ( -
- = 400 && - log.response_status < 500 && - WARNING_ICON_STYLES.warning, - log.response_status >= 500 && WARNING_ICON_STYLES.error, - )} - /> +
), }, { - key: "status", + key: "response_status", header: "Status", width: "78px", + noTruncate: true, // Disable truncation for badges render: (log) => { const style = getStatusStyle(log.response_status); const isSelected = selectedLog?.request_id === log.request_id; @@ -151,7 +157,7 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { {log.response_status} @@ -163,10 +169,16 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { key: "method", header: "Method", width: "78px", + noTruncate: true, // Disable truncation for badges render: (log) => { const isSelected = selectedLog?.request_id === log.request_id; return ( - + {log.method} ); @@ -176,24 +188,62 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { key: "path", header: "Path", width: "15%", - render: (log) => ( -
{log.path}
- ), - }, - { - key: "response", - header: "Response Body", - width: "1fr", - render: (log) => {log.response_body}, + render: (log) =>
{log.path}
, }, ], - [selectedLog?.request_id], + [selectedLog?.request_id] ); + const additionalColumns: Column[] = [ + "response_body", + "request_body", + "request_headers", + "response_headers", + "request_id", + "workspace_id", + "host", + ].map((key) => ({ + key, + header: key + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + width: "1fr", + render: (log: Log) => ( +
+ {log[key as keyof Log]} +
+ ), + })); + + const visibleColumns = useMemo(() => { + const filtered = [...basicColumns, ...additionalColumns].filter( + (column) => + isDisplayProperty(column.key) && displayProperties.has(column.key) + ); + + // If we have visible columns + if (filtered.length > 0) { + const originalRender = filtered[0].render; + filtered[0] = { + ...filtered[0], + headerClassName: "pl-9", + render: (log: Log) => ( +
+ +
{originalRender(log)}
+
+ ), + }; + } + + return filtered; + }, [basicColumns, additionalColumns, displayProperties]); + return ( log.request_id} diff --git a/apps/dashboard/app/(app)/logs-v2/context/logs.tsx b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx new file mode 100644 index 000000000..413eeab6b --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { type Log } from "@unkey/clickhouse/src/logs"; +import { + createContext, + useContext, + useState, + type PropsWithChildren, +} from "react"; + +type DisplayProperty = + | "time" + | "response_status" + | "method" + | "path" + | "response_body" + | "request_id" + | "workspace_id" + | "host" + | "request_headers" + | "request_body" + | "response_headers"; + +type LogsContextType = { + selectedLog: Log | null; + setSelectedLog: (log: Log | null) => void; + displayProperties: Set; + toggleDisplayProperty: (property: DisplayProperty) => void; +}; + +const DEFAULT_DISPLAY_PROPERTIES: DisplayProperty[] = [ + "time", + "response_status", + "method", + "path", + "response_body", +]; + +const LogsContext = createContext(null); + +export const LogsProvider = ({ children }: PropsWithChildren) => { + const [selectedLog, setSelectedLog] = useState(null); + const [displayProperties, setDisplayProperties] = useState< + Set + >(new Set(DEFAULT_DISPLAY_PROPERTIES)); + + const toggleDisplayProperty = (property: DisplayProperty) => { + setDisplayProperties((prev) => { + const next = new Set(prev); + if (next.has(property)) { + next.delete(property); + } else { + next.add(property); + } + return next; + }); + }; + + return ( + + {children} + + ); +}; + +export const useLogsContext = () => { + const context = useContext(LogsContext); + if (!context) { + throw new Error("useLogsContext must be used within a LogsProvider"); + } + return context; +}; + +export const isDisplayProperty = (value: string): value is DisplayProperty => { + return [ + "time", + "response_status", + "method", + "path", + "response_body", + "request_id", + "workspace_id", + "host", + "request_headers", + "request_body", + "response_headers", + ].includes(value); +}; diff --git a/apps/dashboard/components/virtual-table/components/table-row.tsx b/apps/dashboard/components/virtual-table/components/table-row.tsx index eca759d80..6ec4cddc8 100644 --- a/apps/dashboard/components/virtual-table/components/table-row.tsx +++ b/apps/dashboard/components/virtual-table/components/table-row.tsx @@ -1,6 +1,43 @@ import type { VirtualItem } from "@tanstack/react-virtual"; -import { cn } from "@unkey/ui/src/lib/utils"; -import type { Column } from "../types"; +import { cn } from "@/lib/utils"; +import { Column } from "../types"; + +const calculateGridTemplateColumns = (columns: Column[]) => { + return columns + .map((column) => { + if (typeof column.width === "number") { + return `${column.width}px`; + } + + if (typeof column.width === "string") { + // Handle existing pixel and percentage values + if (column.width.endsWith("px") || column.width.endsWith("%")) { + return column.width; + } + if (column.width === "auto") { + return "minmax(min-content, auto)"; + } + if (column.width === "min") { + return "min-content"; + } + if (column.width === "1fr") { + return "1fr"; + } + } + + if (typeof column.width === "object") { + if ("min" in column.width && "max" in column.width) { + return `minmax(${column.width.min}px, ${column.width.max}px)`; + } + if ("flex" in column.width) { + return `${column.width.flex}fr`; + } + } + + return "1fr"; // Default fallback + }) + .join(" "); +}; export const TableRow = ({ item, @@ -11,8 +48,8 @@ export const TableRow = ({ rowClassName, selectedClassName, onClick, - measureRef, onRowClick, + measureRef, }: { item: T; columns: Column[]; @@ -24,60 +61,77 @@ export const TableRow = ({ onClick: () => void; onRowClick?: (item: T | null) => void; measureRef: (element: HTMLElement | null) => void; -}) => ( -
{ - if (event.key === "Escape") { - event.preventDefault(); - onRowClick?.(null); - const activeElement = document.activeElement as HTMLElement; - activeElement?.blur(); - } +}) => { + const gridTemplateColumns = calculateGridTemplateColumns(columns); - if (event.key === "ArrowDown" || event.key === "j") { - event.preventDefault(); - const nextElement = document.querySelector( - `[data-index="${virtualRow.index + 1}"]`, - ) as HTMLElement; - if (nextElement) { - nextElement.focus(); - nextElement.click(); // This will trigger onClick which calls handleRowClick + return ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + onRowClick?.(null); + const activeElement = document.activeElement as HTMLElement; + activeElement?.blur(); } - } - if (event.key === "ArrowUp" || event.key === "k") { - event.preventDefault(); - const prevElement = document.querySelector( - `[data-index="${virtualRow.index - 1}"]`, - ) as HTMLElement; - if (prevElement) { - prevElement.focus(); - prevElement.click(); // This will trigger onClick which calls handleRowClick + if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + const nextElement = document.querySelector( + `[data-index="${virtualRow.index + 1}"]` + ) as HTMLElement; + if (nextElement) { + nextElement.focus(); + nextElement.click(); + } } - } - }} - ref={measureRef} - onClick={onClick} - className={cn( - "grid text-xs cursor-pointer absolute top-0 left-0 w-full", - "transition-all duration-75 ease-in-out", - "hover:bg-accent-3 ", - "group rounded-md", - rowClassName?.(item), - selectedClassName?.(item, isSelected), - )} - style={{ - gridTemplateColumns: columns.map((col) => col.width).join(" "), - height: `${rowHeight}px`, - top: `${virtualRow.start}px`, - }} - > - {columns.map((column) => ( -
- {column.render(item)} -
- ))} -
-); + if (event.key === "ArrowUp" || event.key === "k") { + event.preventDefault(); + const prevElement = document.querySelector( + `[data-index="${virtualRow.index - 1}"]` + ) as HTMLElement; + if (prevElement) { + prevElement.focus(); + prevElement.click(); + } + } + }} + ref={measureRef} + onClick={onClick} + className={cn( + "grid text-xs cursor-pointer absolute top-0 left-0 w-full", + "transition-all duration-75 ease-in-out", + "group", + rowClassName?.(item), + selectedClassName?.(item, isSelected) + )} + style={{ + gridTemplateColumns, + height: `${rowHeight}px`, + top: `${virtualRow.start}px`, + }} + > + {columns.map((column) => ( +
+ {column.noTruncate ? ( + column.render(item) + ) : ( +
{column.render(item)}
+ )} +
+ ))} +
+ ); +}; diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index 9fd4949cf..1a885edd2 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -1,9 +1,21 @@ +type ColumnWidth = + | number // Fixed pixel width + | string // CSS values like "165px", "15%", etc. + | "1fr" // Flex grow + | "min" // Minimum width based on content + | "auto" // Automatic width based on content + | { min: number; max: number } // Responsive range + | { flex: number }; // Flex grow ratio + export type Column = { key: string; - header: string; + header?: string; + width: ColumnWidth; headerClassName?: string; - width: string; + minWidth?: number; + maxWidth?: number; render: (item: T) => React.ReactNode; + noTruncate?: boolean; // Add this to disable truncation for specific columns }; export type TableConfig = { @@ -29,5 +41,9 @@ export type VirtualTableProps = { selectedClassName?: (item: T, isSelected: boolean) => string; selectedItem?: T | null; isFetchingNextPage?: boolean; - renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; + renderDetails?: ( + item: T, + onClose: () => void, + distanceToTop: number + ) => React.ReactNode; }; From b59a1c749519e3d83b94c30c486b14c3eeaad751 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 18:04:13 +0300 Subject: [PATCH 06/68] feat: add accessbility to display --- .../components/display-popover.tsx | 116 +++++++++++++++--- .../components/logs-display/index.tsx | 4 +- .../logs-v2/components/controls/index.tsx | 8 +- .../(app)/logs-v2/components/logs-client.tsx | 16 +-- .../components/table/log-details/index.tsx | 15 ++- .../logs-v2/components/table/logs-table.tsx | 88 ++++++------- .../app/(app)/logs-v2/context/logs.tsx | 15 +-- .../virtual-table/components/table-row.tsx | 12 +- .../components/virtual-table/types.ts | 6 +- 9 files changed, 161 insertions(+), 119 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx index be3b57f04..4294bd9bd 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx @@ -1,15 +1,14 @@ -import { - isDisplayProperty, - useLogsContext, -} from "@/app/(app)/logs-v2/context/logs"; +import { isDisplayProperty, useLogsContext } from "@/app/(app)/logs-v2/context/logs"; import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; import { KeyboardButton } from "@/components/keyboard-button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { type PropsWithChildren, useState } from "react"; + type KeyboardEvent, + type PropsWithChildren, + useCallback, + useEffect, + useState, +} from "react"; const DISPLAY_PROPERTIES = [ { id: "time", label: "Time" }, @@ -29,48 +28,125 @@ const DisplayPropertyItem = ({ label, selected, onClick, + isFocused, + index, }: { label: string; selected: boolean; onClick: () => void; + isFocused: boolean; + index: number; }) => (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} > {label}
); -const PopoverHeader = () => { - return ( -
- Display Properties... - -
- ); -}; +const PopoverHeader = () => ( +
+ Display Properties... + +
+); export const DisplayPopover = ({ children }: PropsWithChildren) => { const [open, setOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(null); const { displayProperties, toggleDisplayProperty } = useLogsContext(); useKeyboardShortcut("d", () => { setOpen((prev) => !prev); + if (!open) { + setFocusedIndex(0); + } }); + const handleKeyNavigation = useCallback( + (e: KeyboardEvent) => { + const itemsPerRow = Math.floor(384 / 120); // Approximate width / item width + const totalItems = DISPLAY_PROPERTIES.length; + const currentRow = Math.floor((focusedIndex ?? 0) / itemsPerRow); + const currentCol = (focusedIndex ?? 0) % itemsPerRow; + + const moveToIndex = (newIndex: number) => { + e.preventDefault(); + setFocusedIndex(Math.max(0, Math.min(newIndex, totalItems - 1))); + }; + + switch (e.key) { + case "ArrowRight": + case "l": { + moveToIndex((focusedIndex ?? -1) + 1); + break; + } + case "ArrowLeft": + case "h": { + moveToIndex((focusedIndex ?? 1) - 1); + break; + } + case "ArrowDown": + case "j": { + const nextRowIndex = (currentRow + 1) * itemsPerRow + currentCol; + if (nextRowIndex < totalItems) { + moveToIndex(nextRowIndex); + } + break; + } + case "ArrowUp": + case "k": { + const prevRowIndex = (currentRow - 1) * itemsPerRow + currentCol; + if (prevRowIndex >= 0) { + moveToIndex(prevRowIndex); + } + break; + } + case "Enter": + case " ": { + if (focusedIndex !== null) { + const prop = DISPLAY_PROPERTIES[focusedIndex]; + if (isDisplayProperty(prop.id)) { + toggleDisplayProperty(prop.id); + } + } + break; + } + } + }, + [focusedIndex, toggleDisplayProperty], + ); + + useEffect(() => { + if (!open) { + setFocusedIndex(null); + } + }, [open]); + return ( {children}
- {DISPLAY_PROPERTIES.map((prop) => ( + {DISPLAY_PROPERTIES.map((prop, index) => ( { toggleDisplayProperty(prop.id); } }} + isFocused={focusedIndex === index} + index={index} /> ))}
diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx index c264052d6..c34fdda8e 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx @@ -15,9 +15,7 @@ export const LogsDisplay = () => { title="Press 'F' to toggle filters" > - - Display - + Display
diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx index ff6f490a4..cb8705634 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/index.tsx @@ -16,9 +16,7 @@ export function LogsControls() {
- - Last 24 hours - + Last 24 hours
@@ -29,9 +27,7 @@ export function LogsControls() {
- - Refresh - + Refresh
diff --git a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx index 8979d6bc7..745265961 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx @@ -1,37 +1,27 @@ "use client"; -import type { Log } from "@unkey/clickhouse/src/logs"; import { useCallback, useState } from "react"; +import { LogsProvider } from "../context/logs"; import { LogsChart } from "./charts"; import { ControlCloud } from "./control-cloud"; import { LogsControls } from "./controls"; import { LogDetails } from "./table/log-details"; import { LogsTable } from "./table/logs-table"; -import { LogsProvider } from "../context/logs"; export const LogsClient = () => { - const [selectedLog, setSelectedLog] = useState(null); const [tableDistanceToTop, setTableDistanceToTop] = useState(0); const handleDistanceToTop = useCallback((distanceToTop: number) => { setTableDistanceToTop(distanceToTop); }, []); - const handleLogSelection = useCallback((log: Log | null) => { - setSelectedLog(log); - }, []); - return ( - - handleLogSelection(null)} - distanceToTop={tableDistanceToTop} - /> + + ); }; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx index f5017a0d5..5049d1d56 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx @@ -1,8 +1,8 @@ "use client"; -import type { Log } from "@unkey/clickhouse/src/logs"; import { useMemo } from "react"; import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { useLogsContext } from "../../../context/logs"; import { extractResponseField, safeParseJson } from "../../../utils"; import { LogFooter } from "./components/log-footer"; import { LogHeader } from "./components/log-header"; @@ -21,27 +21,30 @@ const createPanelStyle = (distanceToTop: number) => ({ }); type Props = { - log: Log | null; - onClose: () => void; distanceToTop: number; }; -export const LogDetails = ({ log, onClose, distanceToTop }: Props) => { +export const LogDetails = ({ distanceToTop }: Props) => { + const { setSelectedLog, selectedLog: log } = useLogsContext(); const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); if (!log) { return null; } + const handleClose = () => { + setSelectedLog(null); + }; + return ( - + void; - selectedLog: Log | null; -}; - type StatusStyle = { base: string; hover: string; @@ -97,13 +92,33 @@ const WarningIcon = ({ status }: { status: number }) => ( WARNING_ICON_STYLES.base, status < 300 && "invisible", status >= 400 && status < 500 && WARNING_ICON_STYLES.warning, - status >= 500 && WARNING_ICON_STYLES.error + status >= 500 && WARNING_ICON_STYLES.error, )} /> ); -export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { - const { displayProperties } = useLogsContext(); +const additionalColumns: Column[] = [ + "response_body", + "request_body", + "request_headers", + "response_headers", + "request_id", + "workspace_id", + "host", +].map((key) => ({ + key, + header: key + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + width: "1fr", + render: (log: Log) => ( +
{log[key as keyof Log]}
+ ), +})); + +export const LogsTable = () => { + const { displayProperties, setSelectedLog, selectedLog } = useLogsContext(); const getRowClassName = (log: Log) => { const style = getStatusStyle(log.response_status); @@ -112,17 +127,18 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { return cn( style.base, style.hover, - "group", + "group rounded-md", "focus:outline-none focus:ring-1 focus:ring-opacity-40", style.focusRing, isSelected && style.selected, selectedLog && { "opacity-50 z-0": !isSelected, "opacity-100 z-10": isSelected, - } + }, ); }; + // biome-ignore lint/correctness/useExhaustiveDependencies: it's okay const basicColumns: Column[] = useMemo( () => [ { @@ -130,16 +146,14 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { header: "Time", width: "165px", headerClassName: "pl-9", - noTruncate: true, // Disable truncation for timestamp + noTruncate: true, render: (log) => (
@@ -149,7 +163,7 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { key: "response_status", header: "Status", width: "78px", - noTruncate: true, // Disable truncation for badges + noTruncate: true, render: (log) => { const style = getStatusStyle(log.response_status); const isSelected = selectedLog?.request_id === log.request_id; @@ -157,7 +171,7 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { {log.response_status} @@ -169,16 +183,11 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { key: "method", header: "Method", width: "78px", - noTruncate: true, // Disable truncation for badges + noTruncate: true, render: (log) => { const isSelected = selectedLog?.request_id === log.request_id; return ( - + {log.method} ); @@ -191,35 +200,12 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { render: (log) =>
{log.path}
, }, ], - [selectedLog?.request_id] + [selectedLog?.request_id], ); - const additionalColumns: Column[] = [ - "response_body", - "request_body", - "request_headers", - "response_headers", - "request_id", - "workspace_id", - "host", - ].map((key) => ({ - key, - header: key - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "), - width: "1fr", - render: (log: Log) => ( -
- {log[key as keyof Log]} -
- ), - })); - const visibleColumns = useMemo(() => { const filtered = [...basicColumns, ...additionalColumns].filter( - (column) => - isDisplayProperty(column.key) && displayProperties.has(column.key) + (column) => isDisplayProperty(column.key) && displayProperties.has(column.key), ); // If we have visible columns @@ -238,13 +224,13 @@ export const LogsTable = ({ onLogSelect, selectedLog }: Props) => { } return filtered; - }, [basicColumns, additionalColumns, displayProperties]); + }, [basicColumns, displayProperties]); return ( log.request_id} rowClassName={getRowClassName} diff --git a/apps/dashboard/app/(app)/logs-v2/context/logs.tsx b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx index 413eeab6b..fca258ca3 100644 --- a/apps/dashboard/app/(app)/logs-v2/context/logs.tsx +++ b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx @@ -1,12 +1,7 @@ "use client"; -import { type Log } from "@unkey/clickhouse/src/logs"; -import { - createContext, - useContext, - useState, - type PropsWithChildren, -} from "react"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { type PropsWithChildren, createContext, useContext, useState } from "react"; type DisplayProperty = | "time" @@ -40,9 +35,9 @@ const LogsContext = createContext(null); export const LogsProvider = ({ children }: PropsWithChildren) => { const [selectedLog, setSelectedLog] = useState(null); - const [displayProperties, setDisplayProperties] = useState< - Set - >(new Set(DEFAULT_DISPLAY_PROPERTIES)); + const [displayProperties, setDisplayProperties] = useState>( + new Set(DEFAULT_DISPLAY_PROPERTIES), + ); const toggleDisplayProperty = (property: DisplayProperty) => { setDisplayProperties((prev) => { diff --git a/apps/dashboard/components/virtual-table/components/table-row.tsx b/apps/dashboard/components/virtual-table/components/table-row.tsx index 6ec4cddc8..379b3a17d 100644 --- a/apps/dashboard/components/virtual-table/components/table-row.tsx +++ b/apps/dashboard/components/virtual-table/components/table-row.tsx @@ -1,6 +1,6 @@ -import type { VirtualItem } from "@tanstack/react-virtual"; import { cn } from "@/lib/utils"; -import { Column } from "../types"; +import type { VirtualItem } from "@tanstack/react-virtual"; +import type { Column } from "../types"; const calculateGridTemplateColumns = (columns: Column[]) => { return columns @@ -79,7 +79,7 @@ export const TableRow = ({ if (event.key === "ArrowDown" || event.key === "j") { event.preventDefault(); const nextElement = document.querySelector( - `[data-index="${virtualRow.index + 1}"]` + `[data-index="${virtualRow.index + 1}"]`, ) as HTMLElement; if (nextElement) { nextElement.focus(); @@ -89,7 +89,7 @@ export const TableRow = ({ if (event.key === "ArrowUp" || event.key === "k") { event.preventDefault(); const prevElement = document.querySelector( - `[data-index="${virtualRow.index - 1}"]` + `[data-index="${virtualRow.index - 1}"]`, ) as HTMLElement; if (prevElement) { prevElement.focus(); @@ -104,7 +104,7 @@ export const TableRow = ({ "transition-all duration-75 ease-in-out", "group", rowClassName?.(item), - selectedClassName?.(item, isSelected) + selectedClassName?.(item, isSelected), )} style={{ gridTemplateColumns, @@ -118,7 +118,7 @@ export const TableRow = ({ className={cn( "flex items-center h-full", "min-w-0", // Essential for truncation - !column.noTruncate && "overflow-hidden" // Allow disabling truncation + !column.noTruncate && "overflow-hidden", // Allow disabling truncation )} style={{ minWidth: column.minWidth, diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index 1a885edd2..db918cd7b 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -41,9 +41,5 @@ export type VirtualTableProps = { selectedClassName?: (item: T, isSelected: boolean) => string; selectedItem?: T | null; isFetchingNextPage?: boolean; - renderDetails?: ( - item: T, - onClose: () => void, - distanceToTop: number - ) => React.ReactNode; + renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; }; From c7f877dab424c1fa44d6929c94ea1b33b5d6612c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 18:08:56 +0300 Subject: [PATCH 07/68] fix: show xx instead of direct status --- .../app/(app)/logs-v2/components/control-cloud/index.tsx | 6 +++--- .../components/logs-filters/components/status-filter.tsx | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx index 2e2bc565c..237825f96 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx @@ -27,11 +27,11 @@ const formatValue = (value: string | number): string => { const statusFamily = Math.floor(Number.parseInt(value) / 100); switch (statusFamily) { case 5: - return "5XX (Error)"; + return "5xx (Error)"; case 4: - return "4XX (Warning)"; + return "4xx (Warning)"; case 2: - return "2XX (Success)"; + return "2xx (Success)"; default: return `${statusFamily}xx`; } diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/status-filter.tsx index 18fe8e811..2547109d1 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/status-filter.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/status-filter.tsx @@ -4,6 +4,7 @@ import { FilterCheckbox } from "./filter-checkbox"; type StatusOption = { id: number; status: ResponseStatus; + display: string; label: string; color: string; checked: boolean; @@ -13,6 +14,7 @@ const options: StatusOption[] = [ { id: 1, status: 200, + display: "2xx", label: "Success", color: "bg-success-9", checked: false, @@ -20,6 +22,7 @@ const options: StatusOption[] = [ { id: 2, status: 400, + display: "4xx", label: "Warning", color: "bg-warning-8", checked: false, @@ -27,6 +30,7 @@ const options: StatusOption[] = [ { id: 3, status: 500, + display: "5xx", label: "Error", color: "bg-error-9", checked: false, @@ -42,7 +46,7 @@ export const StatusFilter = () => { renderOptionContent={(checkbox) => ( <>
- {checkbox.status} + {checkbox.display} {checkbox.label} )} From 97448262cb4e6ebab151bbdcfd26e569ce5120b5 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 15 Jan 2025 18:52:57 +0300 Subject: [PATCH 08/68] fix: duplicate filter when querying openai --- .../controls/components/logs-search/index.tsx | 21 ++++++-- .../app/(app)/logs-v2/filters.schema.ts | 49 +++++++++++++++++++ .../lib/trpc/routers/logs/llm-search.ts | 40 +-------------- 3 files changed, 68 insertions(+), 42 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx index bb571d3d7..0c7d4b52b 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx @@ -1,15 +1,30 @@ +import { transformStructuredOutputToFilters } from "@/app/(app)/logs-v2/filters.schema"; +import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3 } from "@unkey/icons"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; import { useRef, useState } from "react"; export const LogsSearch = () => { + const { filters, updateFilters } = useFilters(); const queryLLMForStructuredOutput = trpc.logs.llmSearch.useMutation({ onSuccess(data) { - console.info("OUTPUT", data); + if (data) { + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + } else { + toast.error("Try to be more descriptive about your query", { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }); + } }, onError(error) { toast.error(error.message, { diff --git a/apps/dashboard/app/(app)/logs-v2/filters.schema.ts b/apps/dashboard/app/(app)/logs-v2/filters.schema.ts index 845f04527..eea5633f1 100644 --- a/apps/dashboard/app/(app)/logs-v2/filters.schema.ts +++ b/apps/dashboard/app/(app)/logs-v2/filters.schema.ts @@ -4,6 +4,7 @@ import type { FieldConfig, FilterField, FilterFieldConfigs, + FilterValue, HttpMethod, NumberConfig, StatusConfig, @@ -52,6 +53,54 @@ export const filterOutputSchema = z.object({ ), }); +// Required for transforming OpenAI structured outputs into our own Filter types +export const transformStructuredOutputToFilters = ( + data: z.infer, + existingFilters: FilterValue[] = [], +): FilterValue[] => { + const uniqueFilters = [...existingFilters]; + const seenFilters = new Set(existingFilters.map((f) => `${f.field}-${f.operator}-${f.value}`)); + + for (const filterGroup of data.filters) { + filterGroup.filters.forEach((filter) => { + const baseFilter = { + field: filterGroup.field, + operator: filter.operator, + value: filter.value, + }; + + const filterKey = `${baseFilter.field}-${baseFilter.operator}-${baseFilter.value}`; + + if (seenFilters.has(filterKey)) { + return; + } + + if (filterGroup.field === "status") { + const numericValue = + typeof filter.value === "string" ? Number.parseInt(filter.value) : filter.value; + + uniqueFilters.push({ + id: crypto.randomUUID(), + ...baseFilter, + value: numericValue, + metadata: { + colorClass: filterFieldConfig.status.getColorClass?.(numericValue), + }, + }); + } else { + uniqueFilters.push({ + id: crypto.randomUUID(), + ...baseFilter, + }); + } + + seenFilters.add(filterKey); + }); + } + + return uniqueFilters; +}; + // Type guard for config types function isStatusConfig(config: FieldConfig): config is StatusConfig { return "validate" in config && config.type === "number"; diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts index 1207b6641..cdc5168d7 100644 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts +++ b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts @@ -1,6 +1,5 @@ import { METHODS } from "@/app/(app)/logs-v2/constants"; import { filterFieldConfig, filterOutputSchema } from "@/app/(app)/logs-v2/filters.schema"; -import type { QuerySearchParams } from "@/app/(app)/logs-v2/filters.type"; import { db } from "@/lib/db"; import { env } from "@/lib/env"; import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; @@ -55,7 +54,7 @@ async function getStructuredSearchFromLLM(userSearchMsg: string) { }); } - return transformFiltersToQuerySearchParams(completion.choices[0].message.parsed); + return completion.choices[0].message.parsed; } catch (error) { console.error( `Something went wrong when querying OpenAI. Input: ${JSON.stringify( @@ -108,43 +107,6 @@ export const llmSearch = rateLimitedProcedure(ratelimit.update) }); // HELPERS -function transformFiltersToQuerySearchParams( - result: z.infer, -): QuerySearchParams { - const output: QuerySearchParams = { - host: null, - requestId: null, - methods: null, - paths: null, - status: null, - }; - - for (const filter of result.filters) { - const filterValues = filter.filters.map((f) => ({ - operator: f.operator, - value: f.value, - })); - - switch (filter.field) { - case "host": - case "requestId": - if (filter.filters.length > 0) { - output[filter.field] = filterValues[0]; - } - break; - - case "methods": - case "paths": - case "status": - if (filter.filters.length > 0) { - output[filter.field] = filterValues; - } - break; - } - } - - return output; -} const getSystemPrompt = () => { const operatorsByField = Object.entries(filterFieldConfig) From bf54d05af0dd140ba6c106c0a284a4d17f693d0a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 16 Jan 2025 14:16:42 +0300 Subject: [PATCH 09/68] feat: add optional bottom content for table --- .../app/(app)/logs-v2/components/table/logs-table.tsx | 8 +++++++- apps/dashboard/components/virtual-table/index.tsx | 3 +++ apps/dashboard/components/virtual-table/types.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx index 1e5e92a2e..6f18f7c68 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx @@ -6,7 +6,7 @@ import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; import { cn } from "@/lib/utils"; import type { Log } from "@unkey/clickhouse/src/logs"; -import { TriangleWarning2 } from "@unkey/icons"; +import { CircleCarretRight, TriangleWarning2 } from "@unkey/icons"; import { useMemo } from "react"; import { isDisplayProperty, useLogsContext } from "../../context/logs"; import { generateMockLogs } from "./utils"; @@ -235,6 +235,12 @@ export const LogsTable = () => { keyExtractor={(log) => log.request_id} rowClassName={getRowClassName} selectedClassName={getSelectedClassName} + renderBottomContent={ +
+ + Live +
+ } /> ); }; diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index 0c80fae92..ad445740b 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -25,6 +25,7 @@ export function VirtualTable({ selectedItem, renderDetails, isFetchingNextPage, + renderBottomContent, }: VirtualTableProps) { const config = { ...DEFAULT_CONFIG, ...userConfig }; const parentRef = useRef(null); @@ -138,6 +139,8 @@ export function VirtualTable({ })}
+ {/* Render optional bottom components like Live separator row on logs page */} + {renderBottomContent} {isFetchingNextPage && } {selectedItem && diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index db918cd7b..1d7ba0a3a 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -42,4 +42,5 @@ export type VirtualTableProps = { selectedItem?: T | null; isFetchingNextPage?: boolean; renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; + renderBottomContent?: React.ReactNode; }; From 9d637bf633b70f6c7ab631b52e5bb000ed32a794 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 16 Jan 2025 21:14:38 +0300 Subject: [PATCH 10/68] feat: add initial paginated data fetching --- .../components/table/hooks/use-logs-query.ts | 169 ++++++++++++++++++ .../logs-v2/components/table/logs-table.tsx | 24 +-- .../components/table/query-logs.schema.ts | 19 ++ .../lib/trpc/routers/logs/query-log.ts | 43 ++++- internal/clickhouse/src/logs.ts | 30 ++-- 5 files changed, 253 insertions(+), 32 deletions(-) create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts new file mode 100644 index 000000000..62f26b98c --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts @@ -0,0 +1,169 @@ +import { trpc } from "@/lib/trpc/client"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { useEffect, useMemo, useState } from "react"; +import type { z } from "zod"; +import { useFilters } from "../../../hooks/use-filters"; +import type { queryLogsPayload } from "../query-logs.schema"; + +interface UseLogsQueryParams { + limit?: number; +} + +export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { + const [logs, setLogs] = useState([]); + const { filters } = useFilters(); + + // Without this initial request happens twice + const timestamps = useMemo( + () => ({ + startTime: Date.now() - 24 * 60 * 60 * 1000, + endTime: Date.now(), + }), + [], + ); + + const queryParams = useMemo(() => { + const params: z.infer = { + limit, + startTime: timestamps.startTime, + endTime: timestamps.endTime, + host: null, + requestId: null, + method: null, + path: null, + responseStatus: [], + }; + + // Process each filter + filters.forEach((filter) => { + switch (filter.field) { + case "startTime": + case "endTime": + params[filter.field] = filter.value as number; + break; + + case "status": { + if (!params.responseStatus) { + params.responseStatus = []; + } + // Convert string status to number and handle ranges (2xx, 4xx, 5xx) + const status = Number.parseInt(filter.value as string); + if (status === 200) { + params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 200 + i)); + } else if (status === 400) { + params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 400 + i)); + } else if (status === 500) { + params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 500 + i)); + } else { + params.responseStatus.push(status); + } + break; + } + + case "methods": + params.method = filter.value as string; + break; + + case "paths": + if (filter.operator === "is") { + params.path = filter.value as string; + } + // TODO: Other path operators (contains, startsWith, endsWith) would need backend support + break; + + case "host": + if (filter.operator === "is") { + params.host = filter.value as string; + } + break; + + case "requestId": + if (filter.operator === "is") { + params.requestId = filter.value as string; + } + break; + } + }); + + return params; + }, [filters, limit, timestamps]); + + // Main query for historical data + const { + data: initialData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.logs.queryLogs.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: { requestId: null, time: null }, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + // // Query for new logs (polling) + // const pollForNewLogs = async () => { + // if (!isLive || !logs[0]) return; + // + // const pollParams = { + // ...queryParams, + // limit: 10, + // startTime: logs[0].time, + // endTime: Date.now(), + // }; + // + // const result = await queryClient.fetchQuery({ + // queryKey: trpc.logs.queryLogs.getQueryKey(pollParams), + // queryFn: () => trpc.logs.queryLogs.fetch(pollParams), + // }); + // + // if (result.logs.length > 0) { + // const newLogs = result.logs.filter( + // (newLog) => + // !logs.some( + // (existingLog) => existingLog.request_id === newLog.request_id + // ) + // ); + // if (newLogs.length > 0) { + // setLogs((prev) => [...newLogs, ...prev]); + // } + // } + // }; + // + useEffect(() => { + if (initialData) { + const allLogs = initialData.pages.flatMap((page) => page.logs); + setLogs(allLogs); + } + }, [initialData]); + + // // Set up polling + // useEffect(() => { + // if (isLive) { + // pollInterval.current = window.setInterval(pollForNewLogs, 5000); + // } + // + // return () => { + // if (pollInterval.current) { + // clearInterval(pollInterval.current); + // } + // }; + // }, [isLive, logs[0]?.time, queryParams]); + // + // const toggleLive = () => { + // setIsLive((prev) => !prev); + // if (!isLive) { + // refetch(); + // } + // }; + + return { + logs, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + }; +} diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx index 6f18f7c68..5c8918e4b 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx @@ -6,12 +6,10 @@ import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; import { cn } from "@/lib/utils"; import type { Log } from "@unkey/clickhouse/src/logs"; -import { CircleCarretRight, TriangleWarning2 } from "@unkey/icons"; +import { TriangleWarning2 } from "@unkey/icons"; import { useMemo } from "react"; import { isDisplayProperty, useLogsContext } from "../../context/logs"; -import { generateMockLogs } from "./utils"; - -const logs = generateMockLogs(50); +import { useLogsQuery } from "./hooks/use-logs-query"; type StatusStyle = { base: string; @@ -27,7 +25,7 @@ type StatusStyle = { const STATUS_STYLES = { success: { base: "text-accent-9", - hover: "hover:text-accent-11 dark:hover:text-accent-12", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-accent-3", selected: "text-accent-11 bg-accent-3 dark:text-accent-12", badge: { default: "bg-accent-4 text-accent-11 group-hover:bg-accent-5", @@ -119,6 +117,7 @@ const additionalColumns: Column[] = [ export const LogsTable = () => { const { displayProperties, setSelectedLog, selectedLog } = useLogsContext(); + const { logs, isLoading, isLoadingMore, loadMore } = useLogsQuery(); const getRowClassName = (log: Log) => { const style = getStatusStyle(log.response_status); @@ -229,18 +228,21 @@ export const LogsTable = () => { return ( log.request_id} rowClassName={getRowClassName} selectedClassName={getSelectedClassName} - renderBottomContent={ -
- - Live -
- } + // renderBottomContent={ + //
+ // + // Live + //
+ // } /> ); }; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts new file mode 100644 index 000000000..7ab837c61 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const queryLogsPayload = z.object({ + limit: z.number().int(), + startTime: z.number().int(), + endTime: z.number().int(), + path: z.string().optional().nullable(), + host: z.string().optional().nullable(), + method: z.string().optional().nullable(), + requestId: z.string().optional().nullable(), + responseStatus: z.array(z.number().int()).nullable(), + cursor: z + .object({ + requestId: z.string().nullable(), + time: z.number().nullable(), + }) + .optional() + .nullable(), +}); diff --git a/apps/dashboard/lib/trpc/routers/logs/query-log.ts b/apps/dashboard/lib/trpc/routers/logs/query-log.ts index 29c3f3f24..f41e3478d 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-log.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-log.ts @@ -1,12 +1,29 @@ +import { queryLogsPayload } from "@/app/(app)/logs-v2/components/table/query-logs.schema"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; import { TRPCError } from "@trpc/server"; -import { getLogsClickhousePayload } from "@unkey/clickhouse/src/logs"; +import { log } from "@unkey/clickhouse/src/logs"; +import { z } from "zod"; + +const LogsResponse = z.object({ + logs: z.array(log), + hasMore: z.boolean(), + nextCursor: z + .object({ + time: z.number().int(), + requestId: z.string(), + }) + .optional(), +}); + +type LogsResponse = z.infer; export const queryLogs = rateLimitedProcedure(ratelimit.update) - .input(getLogsClickhousePayload.omit({ workspaceId: true })) + .input(queryLogsPayload) + .output(LogsResponse) .query(async ({ ctx, input }) => { + // Get workspace const workspace = await db.query.workspaces .findFirst({ where: (table, { and, eq, isNull }) => @@ -26,15 +43,35 @@ export const queryLogs = rateLimitedProcedure(ratelimit.update) message: "Workspace not found, please contact support using support@unkey.dev.", }); } + const result = await clickhouse.api.logs({ ...input, + cursorRequestId: input.cursor?.requestId ?? null, + cursorTime: input.cursor?.time ?? null, workspaceId: workspace.id, }); + if (result.err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Something went wrong when fetching data from clickhouse.", }); } - return result.val; + + const logs = result.val; + + // Prepare the response with pagination info + const response: LogsResponse = { + logs, + hasMore: logs.length === input.limit, + nextCursor: + logs.length > 0 + ? { + time: logs[logs.length - 1].time, + requestId: logs[logs.length - 1].request_id, + } + : undefined, + }; + + return response; }); diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index a5e41f0f4..d440a2795 100644 --- a/internal/clickhouse/src/logs.ts +++ b/internal/clickhouse/src/logs.ts @@ -9,10 +9,13 @@ export const getLogsClickhousePayload = z.object({ endTime: z.number().int(), path: z.string().optional().nullable(), host: z.string().optional().nullable(), - requestId: z.string().optional().nullable(), method: z.string().optional().nullable(), + requestId: z.string().optional().nullable(), responseStatus: z.array(z.number().int()).nullable(), + cursorTime: z.number().int().nullable(), + cursorRequestId: z.string().nullable(), }); + export const log = z.object({ request_id: z.string(), time: z.number().int(), @@ -51,7 +54,7 @@ export function getLogs(ch: Querier) { error, service_latency FROM metrics.raw_api_requests_v1 - WHERE workspace_id = {workspaceId: String} + WHERE workspace_id = {workspaceId: String} AND time BETWEEN {startTime: UInt64} AND {endTime: UInt64} AND (CASE WHEN {host: String} != '' THEN host = {host: String} @@ -88,24 +91,15 @@ export function getLogs(ch: Querier) { ) ELSE TRUE END) - ORDER BY time DESC + AND (CASE + WHEN {cursorTime: Nullable(UInt64)} IS NOT NULL AND {cursorRequestId: Nullable(String)} IS NOT NULL THEN + (time, request_id) < ({cursorTime: Nullable(UInt64)}, {cursorRequestId: Nullable(String)}) + ELSE TRUE + END) + ORDER BY time DESC, request_id DESC LIMIT {limit: Int}`, params: getLogsClickhousePayload, - schema: z.object({ - request_id: z.string(), - time: z.number().int(), - workspace_id: z.string(), - host: z.string(), - method: z.string(), - path: z.string(), - request_headers: z.array(z.string()), - request_body: z.string(), - response_status: z.number().int(), - response_headers: z.array(z.string()), - response_body: z.string(), - error: z.string(), - service_latency: z.number().int(), - }), + schema: log, }); return query(args); }; From 57083fae6121442f1f42f9ff421b7dafa629d390 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 17 Jan 2025 15:43:08 +0300 Subject: [PATCH 11/68] fix: no need to send all the status variations its already handled on the backend --- .../logs-v2/components/table/hooks/use-logs-query.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts index 62f26b98c..8d25caf67 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts @@ -19,7 +19,7 @@ export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { startTime: Date.now() - 24 * 60 * 60 * 1000, endTime: Date.now(), }), - [], + [] ); const queryParams = useMemo(() => { @@ -48,15 +48,7 @@ export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { } // Convert string status to number and handle ranges (2xx, 4xx, 5xx) const status = Number.parseInt(filter.value as string); - if (status === 200) { - params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 200 + i)); - } else if (status === 400) { - params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 400 + i)); - } else if (status === 500) { - params.responseStatus.push(...Array.from({ length: 100 }, (_, i) => 500 + i)); - } else { - params.responseStatus.push(status); - } + params.responseStatus.push(status); break; } From 542711c5c67921280b7211da386b79a644e7dfaa Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 17 Jan 2025 17:40:22 +0300 Subject: [PATCH 12/68] feat: add different variants for searching path --- .../components/table/hooks/use-logs-query.ts | 71 ++++--- .../components/table/query-logs.schema.ts | 56 +++++- .../components/virtual-table/index.tsx | 2 +- .../lib/trpc/routers/logs/query-log.ts | 35 +++- internal/clickhouse/src/logs.ts | 176 ++++++++++++------ 5 files changed, 249 insertions(+), 91 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts index 8d25caf67..c3e3ac753 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/logs-v2/components/table/hooks/use-logs-query.ts @@ -19,7 +19,7 @@ export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { startTime: Date.now() - 24 * 60 * 60 * 1000, endTime: Date.now(), }), - [] + [], ); const queryParams = useMemo(() => { @@ -27,11 +27,11 @@ export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { limit, startTime: timestamps.startTime, endTime: timestamps.endTime, - host: null, - requestId: null, - method: null, - path: null, - responseStatus: [], + host: { filters: [] }, + requestId: { filters: [] }, + method: { filters: [] }, + path: { filters: [] }, + status: { filters: [] }, }; // Process each filter @@ -43,37 +43,60 @@ export function useLogsQuery({ limit = 50 }: UseLogsQueryParams = {}) { break; case "status": { - if (!params.responseStatus) { - params.responseStatus = []; - } - // Convert string status to number and handle ranges (2xx, 4xx, 5xx) - const status = Number.parseInt(filter.value as string); - params.responseStatus.push(status); + params.status?.filters.push({ + operator: "is", + value: Number.parseInt(filter.value as string), + }); break; } - case "methods": - params.method = filter.value as string; + case "methods": { + if (typeof filter.value !== "string") { + console.error("Method filter value type has to be 'string'"); + } + + params.method?.filters.push({ + operator: "is", + value: filter.value as string, + }); break; + } - case "paths": - if (filter.operator === "is") { - params.path = filter.value as string; + case "paths": { + if (typeof filter.value !== "string") { + console.error("Path filter value type has to be 'string'"); } - // TODO: Other path operators (contains, startsWith, endsWith) would need backend support + + params.path?.filters.push({ + operator: filter.operator, + value: filter.value as string, + }); break; + } - case "host": - if (filter.operator === "is") { - params.host = filter.value as string; + case "host": { + if (typeof filter.value !== "string") { + console.error("Host filter value type has to be 'string'"); } + + params.host?.filters.push({ + operator: "is", + value: filter.value as string, + }); break; + } - case "requestId": - if (filter.operator === "is") { - params.requestId = filter.value as string; + case "requestId": { + if (typeof filter.value !== "string") { + console.error("Request ID filter value type has to be 'string'"); } + + params.requestId?.filters.push({ + operator: "is", + value: filter.value as string, + }); break; + } } }); diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts index 7ab837c61..99c951833 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts +++ b/apps/dashboard/app/(app)/logs-v2/components/table/query-logs.schema.ts @@ -1,14 +1,60 @@ import { z } from "zod"; +import { filterOperatorEnum } from "../../filters.schema"; export const queryLogsPayload = z.object({ limit: z.number().int(), startTime: z.number().int(), endTime: z.number().int(), - path: z.string().optional().nullable(), - host: z.string().optional().nullable(), - method: z.string().optional().nullable(), - requestId: z.string().optional().nullable(), - responseStatus: z.array(z.number().int()).nullable(), + path: z + .object({ + filters: z.array( + z.object({ + operator: filterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + host: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.string(), + }), + ), + }) + .nullable(), + method: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.string(), + }), + ), + }) + .nullable(), + requestId: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.string(), + }), + ), + }) + .nullable(), + status: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.number(), + }), + ), + }) + .nullable(), cursor: z .object({ requestId: z.string().nullable(), diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index ad445740b..86b0b2d9d 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -83,7 +83,7 @@ export function VirtualTable({
; +export function transformFilters( + params: z.infer, +): Omit { + // Transform path filters to include operators + const paths = + params.path?.filters.map((f) => ({ + operator: f.operator, + value: f.value, + })) || []; + + // Extract other filters as before + const requestIds = params.requestId?.filters.map((f) => f.value) || []; + const hosts = params.host?.filters.map((f) => f.value) || []; + const methods = params.method?.filters.map((f) => f.value) || []; + const statusCodes = params.status?.filters.map((f) => f.value) || []; + + return { + limit: params.limit, + startTime: params.startTime, + endTime: params.endTime, + requestIds, + hosts, + methods, + paths, + statusCodes, + cursorTime: params.cursor?.time ?? null, + cursorRequestId: params.cursor?.requestId ?? null, + }; +} + export const queryLogs = rateLimitedProcedure(ratelimit.update) .input(queryLogsPayload) .output(LogsResponse) @@ -44,8 +74,9 @@ export const queryLogs = rateLimitedProcedure(ratelimit.update) }); } + const transformedInputs = transformFilters(input); const result = await clickhouse.api.logs({ - ...input, + ...transformedInputs, cursorRequestId: input.cursor?.requestId ?? null, cursorTime: input.cursor?.time ?? null, workspaceId: workspace.id, diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index d440a2795..f715c4950 100644 --- a/internal/clickhouse/src/logs.ts +++ b/internal/clickhouse/src/logs.ts @@ -7,11 +7,18 @@ export const getLogsClickhousePayload = z.object({ limit: z.number().int(), startTime: z.number().int(), endTime: z.number().int(), - path: z.string().optional().nullable(), - host: z.string().optional().nullable(), - method: z.string().optional().nullable(), - requestId: z.string().optional().nullable(), - responseStatus: z.array(z.number().int()).nullable(), + paths: z + .array( + z.object({ + operator: z.enum(["is", "startsWith", "endsWith", "contains"]), + value: z.string(), + }), + ) + .nullable(), + hosts: z.array(z.string()).nullable(), + methods: z.array(z.string()).nullable(), + requestIds: z.array(z.string()).nullable(), + statusCodes: z.array(z.number().int()).nullable(), cursorTime: z.number().int().nullable(), cursorRequestId: z.string().nullable(), }); @@ -37,70 +44,121 @@ export type GetLogsClickhousePayload = z.infer; export function getLogs(ch: Querier) { return async (args: GetLogsClickhousePayload) => { + // Generate dynamic path conditions + const pathConditions = + args.paths + ?.map((p) => { + switch (p.operator) { + case "is": + return `path = '${p.value}'`; + case "startsWith": + return `startsWith(path, '${p.value}')`; + case "endsWith": + return `endsWith(path, '${p.value}')`; + case "contains": + return `like(path, '%${p.value}%')`; + default: + return null; + } + }) + .filter(Boolean) + .join(" OR ") || "TRUE"; + const query = ch.query({ query: ` - SELECT - request_id, - time, - workspace_id, - host, - method, - path, - request_headers, - request_body, - response_status, - response_headers, - response_body, - error, - service_latency - FROM metrics.raw_api_requests_v1 - WHERE workspace_id = {workspaceId: String} - AND time BETWEEN {startTime: UInt64} AND {endTime: UInt64} - AND (CASE - WHEN {host: String} != '' THEN host = {host: String} - ELSE TRUE - END) - AND (CASE - WHEN {requestId: String} != '' THEN request_id = {requestId: String} + WITH filtered_requests AS ( + SELECT * + FROM metrics.raw_api_requests_v1 + WHERE workspace_id = {workspaceId: String} + AND time BETWEEN {startTime: UInt64} AND {endTime: UInt64} + + ---------- Apply request ID filter if present (highest priority) + AND ( + CASE + WHEN length({requestIds: Array(String)}) > 0 THEN + request_id IN {requestIds: Array(String)} ELSE TRUE - END) - AND (CASE - WHEN {method: String} != '' THEN method = {method: String} + END + ) + + ---------- Apply host filter + AND ( + CASE + WHEN length({hosts: Array(String)}) > 0 THEN + host IN {hosts: Array(String)} ELSE TRUE - END) - AND (CASE - WHEN {path: String} != '' THEN path = {path: String} + END + ) + + ---------- Apply method filter + AND ( + CASE + WHEN length({methods: Array(String)}) > 0 THEN + method IN {methods: Array(String)} ELSE TRUE - END) - AND (CASE - WHEN {responseStatus: Array(UInt16)} IS NOT NULL AND length({responseStatus: Array(UInt16)}) > 0 THEN - response_status IN ( - SELECT status - FROM ( - SELECT - multiIf( - code = 200, arrayJoin(range(200, 300)), - code = 400, arrayJoin(range(400, 500)), - code = 500, arrayJoin(range(500, 600)), - code - ) as status - FROM ( - SELECT arrayJoin({responseStatus: Array(UInt16)}) as code - ) - ) + END + ) + + ---------- Apply path filter using pre-generated conditions + AND (${pathConditions}) + + ---------- Apply status code filter + AND ( + CASE + WHEN length({statusCodes: Array(UInt16)}) > 0 THEN + response_status IN ( + SELECT status + FROM ( + SELECT multiIf( + code = 200, arrayJoin(range(200, 300)), + code = 400, arrayJoin(range(400, 500)), + code = 500, arrayJoin(range(500, 600)), + code + ) as status + FROM ( + SELECT arrayJoin({statusCodes: Array(UInt16)}) as code + ) ) + ) ELSE TRUE - END) - AND (CASE - WHEN {cursorTime: Nullable(UInt64)} IS NOT NULL AND {cursorRequestId: Nullable(String)} IS NOT NULL THEN - (time, request_id) < ({cursorTime: Nullable(UInt64)}, {cursorRequestId: Nullable(String)}) - ELSE TRUE - END) - ORDER BY time DESC, request_id DESC - LIMIT {limit: Int}`, + END + ) + + -- Apply cursor pagination last + AND ( + CASE + WHEN {cursorTime: Nullable(UInt64)} IS NOT NULL + AND {cursorRequestId: Nullable(String)} IS NOT NULL + THEN (time, request_id) < ( + {cursorTime: Nullable(UInt64)}, + {cursorRequestId: Nullable(String)} + ) + ELSE TRUE + END + ) + ) + + SELECT + request_id, + time, + workspace_id, + host, + method, + path, + request_headers, + request_body, + response_status, + response_headers, + response_body, + error, + service_latency + FROM filtered_requests + ORDER BY time DESC, request_id DESC + LIMIT {limit: Int}`, params: getLogsClickhousePayload, schema: log, }); + return query(args); }; } From 58bc61a41a5d768c9ae6f84ecd40cd642c73d092 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 17 Jan 2025 17:49:04 +0300 Subject: [PATCH 13/68] refactor: improve ai query --- .../lib/trpc/routers/logs/llm-search.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts index cdc5168d7..fe8387115 100644 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts +++ b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts @@ -113,21 +113,19 @@ const getSystemPrompt = () => { .map(([field, config]) => { const operators = config.operators.join(", "); let constraints = ""; - if (field === "methods") { constraints = ` and must be one of: ${METHODS.join(", ")}`; } else if (field === "status") { - constraints = " and must be between 100-599"; + constraints = " and must be between 200-599"; } - return `- ${field} accepts ${operators} operator${ config.operators.length > 1 ? "s" : "" }${constraints}`; }) .join("\n"); + return `You are an expert at converting natural language queries into filters. For queries with multiple conditions, output all relevant filters. We will process them in sequence to build the complete filter. For status codes, always return one for each variant like 200,400 or 500 instead of 200,201, etc... - the application will handle status code grouping internally. - return `You are an expert at converting natural language queries into filters. For queries with multiple conditions, output all relevant filters. We will process them in sequence to build the complete filter. Examples: - +Examples: Query: "path should start with /api/oz and method should be POST" Result: [ { @@ -155,6 +153,22 @@ Result: [ } ] +Query: "show me all okay statuses" +Result: [ + { + field: "status", + filters: [{ operator: "is", value: 200 }] + } +] + +Query: "get me request with ID req_3HagbMuvTs6gtGbijeHoqbU9Cijg" +Result: [ + { + field: "requestId", + filters: [{ operator: "is", value: "req_3HagbMuvTs6gtGbijeHoqbU9Cijg" }] + } +] + Query: "show 404 requests from test.example.com" Result: [ { From e8f19760f42d16df5c9c21502c4bafd368ee0a06 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 17 Jan 2025 17:51:49 +0300 Subject: [PATCH 14/68] chore: run formatter --- .../components/control-cloud/index.tsx | 32 ++++------------ .../components/display-popover.tsx | 13 ++----- .../controls/components/logs-search/index.tsx | 37 ++++--------------- .../logs-v2/components/table/logs-table.tsx | 26 ++++--------- .../app/(app)/logs-v2/context/logs.tsx | 13 ++----- 5 files changed, 30 insertions(+), 91 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx index 9476021e4..7c5bbd1c2 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx @@ -2,13 +2,7 @@ import { KeyboardButton } from "@/components/keyboard-button"; import { cn } from "@/lib/utils"; import { XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { - type KeyboardEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import type { FilterValue } from "../../filters.type"; import { useFilters } from "../../hooks/use-filters"; import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; @@ -53,13 +47,7 @@ type ControlPillProps = { index: number; }; -const ControlPill = ({ - filter, - onRemove, - isFocused, - onFocus, - index, -}: ControlPillProps) => { +const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPillProps) => { const { field, operator, value, metadata } = filter; const pillRef = useRef(null); @@ -90,9 +78,7 @@ const ControlPill = ({
)} {metadata?.icon} - - {formatValue(value)} - + {formatValue(value)}
@@ -160,9 +143,7 @@ export const LogsSearch = () => { @@ -173,9 +154,7 @@ export const LogsSearch = () => { type="button" className="hover:text-accent-11 transition-colors cursor-pointer hover:underline" onClick={() => - handlePresetQuery( - "API calls from a path that includes /api/v1/oz" - ) + handlePresetQuery("API calls from a path that includes /api/v1/oz") } > "API calls from a path that includes /api/v1/oz" diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx index b9bd69fb5..5c8918e4b 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx @@ -90,7 +90,7 @@ const WarningIcon = ({ status }: { status: number }) => ( WARNING_ICON_STYLES.base, status < 300 && "invisible", status >= 400 && status < 500 && WARNING_ICON_STYLES.warning, - status >= 500 && WARNING_ICON_STYLES.error + status >= 500 && WARNING_ICON_STYLES.error, )} /> ); @@ -111,9 +111,7 @@ const additionalColumns: Column[] = [ .join(" "), width: "1fr", render: (log: Log) => ( -
- {log[key as keyof Log]} -
+
{log[key as keyof Log]}
), })); @@ -135,7 +133,7 @@ export const LogsTable = () => { selectedLog && { "opacity-50 z-0": !isSelected, "opacity-100 z-10": isSelected, - } + }, ); }; @@ -154,9 +152,7 @@ export const LogsTable = () => { value={log.time} className={cn( "font-mono group-hover:underline decoration-dotted", - selectedLog && - selectedLog.request_id !== log.request_id && - "pointer-events-none" + selectedLog && selectedLog.request_id !== log.request_id && "pointer-events-none", )} />
@@ -174,7 +170,7 @@ export const LogsTable = () => { {log.response_status} @@ -190,12 +186,7 @@ export const LogsTable = () => { render: (log) => { const isSelected = selectedLog?.request_id === log.request_id; return ( - + {log.method} ); @@ -208,13 +199,12 @@ export const LogsTable = () => { render: (log) =>
{log.path}
, }, ], - [selectedLog?.request_id] + [selectedLog?.request_id], ); const visibleColumns = useMemo(() => { const filtered = [...basicColumns, ...additionalColumns].filter( - (column) => - isDisplayProperty(column.key) && displayProperties.has(column.key) + (column) => isDisplayProperty(column.key) && displayProperties.has(column.key), ); // If we have visible columns diff --git a/apps/dashboard/app/(app)/logs-v2/context/logs.tsx b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx index 002baaac5..0331c8475 100644 --- a/apps/dashboard/app/(app)/logs-v2/context/logs.tsx +++ b/apps/dashboard/app/(app)/logs-v2/context/logs.tsx @@ -1,12 +1,7 @@ "use client"; import type { Log } from "@unkey/clickhouse/src/logs"; -import { - type PropsWithChildren, - createContext, - useContext, - useState, -} from "react"; +import { type PropsWithChildren, createContext, useContext, useState } from "react"; type DisplayProperty = | "time" @@ -39,9 +34,9 @@ const LogsContext = createContext(null); export const LogsProvider = ({ children }: PropsWithChildren) => { const [selectedLog, setSelectedLog] = useState(null); - const [displayProperties, setDisplayProperties] = useState< - Set - >(new Set(DEFAULT_DISPLAY_PROPERTIES)); + const [displayProperties, setDisplayProperties] = useState>( + new Set(DEFAULT_DISPLAY_PROPERTIES), + ); const toggleDisplayProperty = (property: DisplayProperty) => { setDisplayProperties((prev) => { From cbf91c02e077f5fcb786438412cbba164ed3f65a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 20 Jan 2025 14:27:58 +0300 Subject: [PATCH 15/68] feat: add ability to return datetimes from ai --- .../controls/components/logs-search/index.tsx | 7 ++- .../lib/trpc/routers/logs/llm-search.ts | 46 +++++++++++++++---- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx index 49f516754..5495d6c8e 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-search/index.tsx @@ -50,7 +50,10 @@ export const LogsSearch = () => { const query = search.trim(); if (query) { try { - await queryLLMForStructuredOutput.mutateAsync(query); + await queryLLMForStructuredOutput.mutateAsync({ + query, + timestamp: Date.now(), + }); } catch (error) { console.error("Search failed:", error); } @@ -86,7 +89,7 @@ export const LogsSearch = () => { isLoading ? "bg-gray-4" : "", )} > -
+
{isLoading ? ( diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts index fe8387115..7a44b0e22 100644 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts +++ b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts @@ -14,14 +14,14 @@ const openai = env().OPENAI_API_KEY }) : null; -async function getStructuredSearchFromLLM(userSearchMsg: string) { +async function getStructuredSearchFromLLM(userSearchMsg: string, usersReferenceMS: number) { try { if (!openai) { return null; // Skip LLM processing in development environment when OpenAI API key is not configured } const completion = await openai.beta.chat.completions.parse({ // Don't change the model only a few models allow structured outputs - model: "gpt-4o-2024-08-06", + model: "gpt-4o-mini", temperature: 0.2, // Range 0-2, lower = more focused/deterministic top_p: 0.1, // Alternative to temperature, controls randomness frequency_penalty: 0.5, // Range -2 to 2, higher = less repetition @@ -30,7 +30,7 @@ async function getStructuredSearchFromLLM(userSearchMsg: string) { messages: [ { role: "system", - content: getSystemPrompt(), + content: getSystemPrompt(usersReferenceMS), }, { role: "user", @@ -81,7 +81,7 @@ async function getStructuredSearchFromLLM(userSearchMsg: string) { } export const llmSearch = rateLimitedProcedure(ratelimit.update) - .input(z.string()) + .input(z.object({ query: z.string(), timestamp: z.number() })) .mutation(async ({ ctx, input }) => { const workspace = await db.query.workspaces .findFirst({ @@ -103,12 +103,12 @@ export const llmSearch = rateLimitedProcedure(ratelimit.update) }); } - return await getStructuredSearchFromLLM(input); + return await getStructuredSearchFromLLM(input.query, input.timestamp); }); // HELPERS -const getSystemPrompt = () => { +const getSystemPrompt = (usersReferenceMS: number) => { const operatorsByField = Object.entries(filterFieldConfig) .map(([field, config]) => { const operators = config.operators.join(", "); @@ -123,7 +123,7 @@ const getSystemPrompt = () => { }${constraints}`; }) .join("\n"); - return `You are an expert at converting natural language queries into filters. For queries with multiple conditions, output all relevant filters. We will process them in sequence to build the complete filter. For status codes, always return one for each variant like 200,400 or 500 instead of 200,201, etc... - the application will handle status code grouping internally. + return `You are an expert at converting natural language queries into filters. For queries with multiple conditions, output all relevant filters. We will process them in sequence to build the complete filter. For status codes, always return one for each variant like 200,400 or 500 instead of 200,201, etc... - the application will handle status code grouping internally. Always use this ${usersReferenceMS} timestamp when dealing with time related queries. Examples: Query: "path should start with /api/oz and method should be POST" @@ -138,6 +138,35 @@ Result: [ } ] +Query: "give me the logs of last 10 minutes" +Result: [ + { + field: "startTime", + filters: [{ + operator: "is", + value: ${usersReferenceMS - 10 * 60 * 1000} // Current time - 10 minutes + }] + } +] + +Query: "show logs between 2024-01-19 and 2024-01-20" +Result: [ + { + field: "startTime", + filters: [{ + operator: "is", + value: 1705622400000 // 2024-01-19 00:00:00 + }] + }, + { + field: "endTime", + filters: [{ + operator: "is", + value: 1705708800000 // 2024-01-20 00:00:00 + }] + } +] + Query: "find POST and GET requests to api/v1" Result: [ { @@ -190,5 +219,6 @@ Result: [ ] Remember: -${operatorsByField}`; +${operatorsByField} +- startTime and endTime accept is operator for filtering logs by time range`; }; From a58b8fc0ef6e7430c1a6e3c338e7d40da2e92dd8 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 20 Jan 2025 14:33:18 +0300 Subject: [PATCH 16/68] fix: overflow issue of log details --- .../components/table/log-details/components/log-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx index d51eee553..a86487e14 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx @@ -28,14 +28,14 @@ export const LogSection = ({ {title}
- -
+        
+          
             {Array.isArray(details)
               ? details.map((header) => {
                   const [key, ...valueParts] = header.split(":");
                   const value = valueParts.join(":").trim();
                   return (
-                    
+
{key}: {value}
From fcda52ff89012d1e448a38c073e834efc1bdbe1a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 20 Jan 2025 14:57:05 +0300 Subject: [PATCH 17/68] fix: update prompt and allow multiple requestID and host pass --- .../app/(app)/logs-v2/filters.type.ts | 4 +- .../app/(app)/logs-v2/hooks/use-filters.ts | 64 ++++++------------- .../lib/trpc/routers/logs/llm-search.ts | 58 ++++++++++++++++- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/apps/dashboard/app/(app)/logs-v2/filters.type.ts b/apps/dashboard/app/(app)/logs-v2/filters.type.ts index 702680cfe..b6abde90e 100644 --- a/apps/dashboard/app/(app)/logs-v2/filters.type.ts +++ b/apps/dashboard/app/(app)/logs-v2/filters.type.ts @@ -47,13 +47,13 @@ export type FilterFieldConfigs = { export type AllowedOperators = FilterFieldConfigs[F]["operators"][number]; export type QuerySearchParams = { - host: FilterUrlValue | null; - requestId: FilterUrlValue | null; methods: FilterUrlValue[] | null; paths: FilterUrlValue[] | null; status: FilterUrlValue[] | null; startTime?: number | null; endTime?: number | null; + host: FilterUrlValue[] | null; + requestId: FilterUrlValue[] | null; }; export interface FilterUrlValue { diff --git a/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts index 63555c826..53e829fa3 100644 --- a/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts @@ -11,38 +11,6 @@ import type { ResponseStatus, } from "../filters.type"; -const parseAsFilterValue: Parser = { - parse: (str: string | null) => { - if (!str) { - return null; - } - try { - // Format: operator:value (e.g., "is:200" for {operator: "is", value: "200"}) - const [operator, val] = str.split(/:(.+)/); - if (!operator || !val) { - return null; - } - - if (!["is", "contains", "startsWith", "endsWith"].includes(operator)) { - return null; - } - - return { - operator: operator as FilterOperator, - value: val, - }; - } catch { - return null; - } - }, - serialize: (value: FilterUrlValue | null) => { - if (!value) { - return ""; - } - return `${value.operator}:${value.value}`; - }, -}; - const parseAsFilterValueArray: Parser = { parse: (str: string | null) => { if (!str) { @@ -73,8 +41,8 @@ const parseAsFilterValueArray: Parser = { }; export const queryParamsPayload = { - requestId: parseAsFilterValue, - host: parseAsFilterValue, + requestId: parseAsFilterValueArray, + host: parseAsFilterValueArray, methods: parseAsFilterValueArray, paths: parseAsFilterValueArray, status: parseAsFilterValueArray, @@ -118,23 +86,23 @@ export const useFilters = () => { }); }); - if (searchParams.host) { + searchParams.host?.forEach((hostFilter) => { activeFilters.push({ id: crypto.randomUUID(), field: "host", - operator: searchParams.host.operator, - value: searchParams.host.value, + operator: hostFilter.operator, + value: hostFilter.value, }); - } + }); - if (searchParams.requestId) { + searchParams.requestId?.forEach((requestIdFilter) => { activeFilters.push({ id: crypto.randomUUID(), field: "requestId", - operator: searchParams.requestId.operator, - value: searchParams.requestId.value, + operator: requestIdFilter.operator, + value: requestIdFilter.value, }); - } + }); ["startTime", "endTime"].forEach((field) => { const value = searchParams[field as keyof QuerySearchParams]; @@ -167,6 +135,8 @@ export const useFilters = () => { const responseStatusFilters: FilterUrlValue[] = []; const methodFilters: FilterUrlValue[] = []; const pathFilters: FilterUrlValue[] = []; + const hostFilters: FilterUrlValue[] = []; + const requestIdFilters: FilterUrlValue[] = []; newFilters.forEach((filter) => { switch (filter.field) { @@ -189,16 +159,16 @@ export const useFilters = () => { }); break; case "host": - newParams.host = { + hostFilters.push({ value: filter.value as string, operator: filter.operator, - }; + }); break; case "requestId": - newParams.requestId = { + requestIdFilters.push({ value: filter.value as string, operator: filter.operator, - }; + }); break; case "startTime": case "endTime": @@ -211,6 +181,8 @@ export const useFilters = () => { newParams.status = responseStatusFilters.length > 0 ? responseStatusFilters : null; newParams.methods = methodFilters.length > 0 ? methodFilters : null; newParams.paths = pathFilters.length > 0 ? pathFilters : null; + newParams.host = hostFilters.length > 0 ? hostFilters : null; + newParams.requestId = requestIdFilters.length > 0 ? requestIdFilters : null; setSearchParams(newParams); }, diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts index 7a44b0e22..da05426f3 100644 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts +++ b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts @@ -126,6 +126,52 @@ const getSystemPrompt = (usersReferenceMS: number) => { return `You are an expert at converting natural language queries into filters. For queries with multiple conditions, output all relevant filters. We will process them in sequence to build the complete filter. For status codes, always return one for each variant like 200,400 or 500 instead of 200,201, etc... - the application will handle status code grouping internally. Always use this ${usersReferenceMS} timestamp when dealing with time related queries. Examples: +Query: "show me failed requests" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: 400 }, + { operator: "is", value: 500 } + ] + } +] + +Query: "show me failed requests today" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: 400 }, + { operator: "is", value: 500 } + ] + }, + { + field: "startTime", + filters: [{ + operator: "is", + value: ${getDayStart(usersReferenceMS)} // Start of the current day + }] + } +] + +Query: "show requests from yesterday" +Result: [ + { + field: "startTime", + filters: [{ + operator: "is", + value: ${getDayStart(usersReferenceMS - 24 * 60 * 60 * 1000)} // Start of previous day + }], + }, + { + field: "endTime", + filters: [{ + operator: "is", + value: ${getDayStart(usersReferenceMS)} // Start of current day + }] + } +] Query: "path should start with /api/oz and method should be POST" Result: [ { @@ -220,5 +266,15 @@ Result: [ Remember: ${operatorsByField} -- startTime and endTime accept is operator for filtering logs by time range`; +- startTime and endTime accept is operator for filtering logs by time range +- For status codes, use ONLY: + • 200 for successful responses + • 400 for client errors (4XX series) + • 500 for server errors (5XX series)`; }; + +function getDayStart(timestamp: number): number { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} From 50fcb07390fee13369b4e48eb63e2704f5fbc764 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 20 Jan 2025 16:14:39 +0300 Subject: [PATCH 18/68] tests: add for filters --- .../components/control-cloud/index.tsx | 2 +- .../(app)/logs-v2/components/logs-client.tsx | 4 +- .../app/(app)/logs-v2/filters-schema.test.ts | 137 +++++++ .../app/(app)/logs-v2/filters.schema.ts | 6 +- .../(app)/logs-v2/hooks/use-filters.test.ts | 280 ++++++++++++++ .../app/(app)/logs-v2/hooks/use-filters.ts | 27 +- apps/dashboard/package.json | 9 +- apps/dashboard/vitest.config.ts | 7 + pnpm-lock.yaml | 348 +++++++++++++++++- 9 files changed, 772 insertions(+), 48 deletions(-) create mode 100644 apps/dashboard/app/(app)/logs-v2/filters-schema.test.ts create mode 100644 apps/dashboard/app/(app)/logs-v2/hooks/use-filters.test.ts create mode 100644 apps/dashboard/vitest.config.ts diff --git a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx index 7c5bbd1c2..0dcc8911e 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx @@ -98,7 +98,7 @@ const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPil ); }; -export const ControlCloud = () => { +export const LogsControlCloud = () => { const { filters, removeFilter, updateFilters } = useFilters(); const [focusedIndex, setFocusedIndex] = useState(null); diff --git a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx index 745265961..e2ebbd2d8 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/logs-client.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from "react"; import { LogsProvider } from "../context/logs"; import { LogsChart } from "./charts"; -import { ControlCloud } from "./control-cloud"; +import { LogsControlCloud } from "./control-cloud"; import { LogsControls } from "./controls"; import { LogDetails } from "./table/log-details"; import { LogsTable } from "./table/logs-table"; @@ -18,7 +18,7 @@ export const LogsClient = () => { return ( - + diff --git a/apps/dashboard/app/(app)/logs-v2/filters-schema.test.ts b/apps/dashboard/app/(app)/logs-v2/filters-schema.test.ts new file mode 100644 index 000000000..619224a5a --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/filters-schema.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import { + filterFieldConfig, + transformStructuredOutputToFilters, + validateFieldValue, +} from "./filters.schema"; +import type { FilterValue } from "./filters.type"; + +vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => "test-uuid"), +}); + +describe("transformStructuredOutputToFilters", () => { + it("should transform structured output to filters correctly", () => { + const input = { + filters: [ + { + field: "status", + filters: [{ operator: "is", value: 404 }], + }, + { + field: "paths", + filters: [ + { operator: "contains", value: "api" }, + { operator: "startsWith", value: "/v1" }, + ], + }, + ], + }; + + //@ts-ignore + const result = transformStructuredOutputToFilters(input, undefined); + + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ + field: "status", + operator: "is", + value: 404, + metadata: { + colorClass: "bg-warning-8", + }, + }); + expect(result[1]).toMatchObject({ + field: "paths", + operator: "contains", + value: "api", + }); + expect(result[2]).toMatchObject({ + field: "paths", + operator: "startsWith", + value: "/v1", + }); + }); + + it("should deduplicate filters with existing filters", () => { + const existingFilters: FilterValue[] = [ + { + id: "123", + field: "status", + operator: "is", + value: 404, + metadata: { colorClass: "bg-warning-8" }, + }, + ]; + + const input = { + filters: [ + { + field: "status", + filters: [{ operator: "is", value: 404 }], + }, + { + field: "paths", + filters: [{ operator: "contains", value: "api" }], + }, + ], + }; + + //@ts-ignore + const result = transformStructuredOutputToFilters(input, existingFilters); + + expect(result).toHaveLength(2); + expect(result[1]).toMatchObject({ + field: "paths", + operator: "contains", + value: "api", + }); + }); +}); + +describe("validateFieldValue", () => { + it("should validate status codes correctly", () => { + expect(validateFieldValue("status", 200)).toBe(true); + expect(validateFieldValue("status", 404)).toBe(true); + expect(validateFieldValue("status", 500)).toBe(true); + expect(validateFieldValue("status", 600)).toBe(false); + }); + + it("should validate HTTP methods correctly", () => { + expect(validateFieldValue("methods", "GET")).toBe(true); + expect(validateFieldValue("methods", "POST")).toBe(true); + expect(validateFieldValue("methods", "INVALID")).toBe(false); + }); + + it("should validate string fields correctly", () => { + expect(validateFieldValue("paths", "/api/v1")).toBe(true); + expect(validateFieldValue("host", "example.com")).toBe(true); + expect(validateFieldValue("requestId", "req-123")).toBe(true); + }); + + it("should validate number fields correctly", () => { + expect(validateFieldValue("startTime", 1234567890)).toBe(true); + expect(validateFieldValue("endTime", 1234567890)).toBe(true); + }); +}); + +describe("filterFieldConfig", () => { + it("should have correct status color classes", () => { + expect(filterFieldConfig.status.getColorClass!(200)).toBe("bg-success-9"); + expect(filterFieldConfig.status.getColorClass!(404)).toBe("bg-warning-8"); + expect(filterFieldConfig.status.getColorClass!(500)).toBe("bg-error-9"); + }); + + it("should have correct operators for each field", () => { + expect(filterFieldConfig.status.operators).toEqual(["is"]); + expect(filterFieldConfig.paths.operators).toEqual(["is", "contains", "startsWith", "endsWith"]); + expect(filterFieldConfig.host.operators).toEqual(["is"]); + expect(filterFieldConfig.requestId.operators).toEqual(["is"]); + }); + + it("should have correct field types", () => { + expect(filterFieldConfig.status.type).toBe("number"); + expect(filterFieldConfig.methods.type).toBe("string"); + expect(filterFieldConfig.paths.type).toBe("string"); + expect(filterFieldConfig.host.type).toBe("string"); + }); +}); diff --git a/apps/dashboard/app/(app)/logs-v2/filters.schema.ts b/apps/dashboard/app/(app)/logs-v2/filters.schema.ts index eea5633f1..7ae04f9c9 100644 --- a/apps/dashboard/app/(app)/logs-v2/filters.schema.ts +++ b/apps/dashboard/app/(app)/logs-v2/filters.schema.ts @@ -114,7 +114,7 @@ function isStringConfig(config: FieldConfig): config is StringConfig { return config.type === "string"; } -function validateFieldValue(field: FilterField, value: string | number): boolean { +export function validateFieldValue(field: FilterField, value: string | number): boolean { const config = filterFieldConfig[field]; if (isStatusConfig(config) && typeof value === "number") { @@ -152,7 +152,7 @@ export const filterFieldConfig: FilterFieldConfigs = { } return "bg-success-9"; }, - validate: (value) => value >= 100 && value <= 599, + validate: (value) => value >= 200 && value <= 599, }, methods: { type: "string", @@ -165,7 +165,7 @@ export const filterFieldConfig: FilterFieldConfigs = { }, host: { type: "string", - operators: ["is", "contains"], + operators: ["is"], }, requestId: { type: "string", diff --git a/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.test.ts b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.test.ts new file mode 100644 index 000000000..32a1917f2 --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.test.ts @@ -0,0 +1,280 @@ +import { act, renderHook } from "@testing-library/react"; +import { useQueryStates } from "nuqs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseAsFilterValueArray, useFilters } from "./use-filters"; + +vi.mock("nuqs", () => { + const mockSetSearchParams = vi.fn(); + + return { + useQueryStates: vi.fn(() => [ + { + status: null, + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }, + mockSetSearchParams, + ]), + parseAsInteger: { + parse: (str: string | null) => (str ? Number.parseInt(str) : null), + serialize: (value: number | null) => value?.toString() ?? "", + }, + }; +}); + +vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => "test-uuid"), +}); + +const mockUseQueryStates = vi.mocked(useQueryStates); +const mockSetSearchParams = vi.fn(); + +describe("parseAsFilterValueArray", () => { + it("should return empty array for null input", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray.parse(null)).toEqual([]); + }); + + it("should return empty array for empty string", () => { + expect(parseAsFilterValueArray.parse("")).toEqual([]); + }); + + it("should parse single filter correctly", () => { + const result = parseAsFilterValueArray.parse("is:200"); + expect(result).toEqual([ + { + operator: "is", + value: "200", + }, + ]); + }); + + it("should parse multiple filters correctly", () => { + const result = parseAsFilterValueArray.parse("is:200,contains:error"); + expect(result).toEqual([ + { operator: "is", value: "200" }, + { operator: "contains", value: "error" }, + ]); + }); + + it("should return empty array for invalid operator", () => { + expect(parseAsFilterValueArray.parse("invalid:200")).toEqual([]); + }); + + it("should serialize empty array to empty string", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray.serialize([])).toBe(""); + }); + + it("should serialize array of filters correctly", () => { + const filters = [ + { operator: "is", value: "200" }, + { operator: "contains", value: "error" }, + ]; + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray?.serialize(filters)).toBe("is:200,contains:error"); + }); +}); + +describe("useFilters hook", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQueryStates.mockImplementation(() => [ + { + status: null, + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }, + mockSetSearchParams, + ]); + }); + + it("should initialize with empty filters", () => { + const { result } = renderHook(() => useFilters()); + expect(result.current.filters).toEqual([]); + }); + + it("should initialize with existing filters", () => { + mockUseQueryStates.mockImplementation(() => [ + { + status: [{ operator: "is", value: "200" }], + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + expect(result.current.filters).toEqual([ + { + id: "test-uuid", + field: "status", + operator: "is", + value: "200", + metadata: expect.any(Object), + }, + ]); + }); + + it("should remove filter correctly", () => { + mockUseQueryStates.mockImplementation(() => [ + { + status: [{ operator: "is", value: "200" }], + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.removeFilter("test-uuid"); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + status: null, + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }); + }); + + it("should handle multiple filters", () => { + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.updateFilters([ + { + id: "test-uuid-1", + field: "status", + operator: "is", + value: 200, + }, + { + id: "test-uuid-2", + field: "methods", + operator: "is", + value: "GET", + }, + ]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + status: [{ operator: "is", value: 200 }], + methods: [{ operator: "is", value: "GET" }], + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }); + }); + + it("should handle time range filters", () => { + const { result } = renderHook(() => useFilters()); + const startTime = 1609459200000; + + act(() => { + result.current.updateFilters([ + { + id: "test-uuid", + field: "startTime", + operator: "is", + value: startTime, + }, + ]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + status: null, + methods: null, + paths: null, + host: null, + requestId: null, + startTime, + endTime: null, + }); + }); + + it("should handle complex filter operators", () => { + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.updateFilters([ + { + id: "test-uuid-1", + field: "paths", + operator: "contains", + value: "/api", + }, + { + id: "test-uuid-2", + field: "host", + operator: "startsWith", + value: "test", + }, + ]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + status: null, + methods: null, + paths: [{ operator: "contains", value: "/api" }], + host: [{ operator: "startsWith", value: "test" }], + requestId: null, + startTime: null, + endTime: null, + }); + }); + + it("should handle clearing all filters", () => { + mockUseQueryStates.mockImplementation(() => [ + { + status: [{ operator: "is", value: "200" }], + methods: [{ operator: "is", value: "GET" }], + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.updateFilters([]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + status: null, + methods: null, + paths: null, + host: null, + requestId: null, + startTime: null, + endTime: null, + }); + }); +}); diff --git a/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts index 53e829fa3..f3a8a6819 100644 --- a/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/logs-v2/hooks/use-filters.ts @@ -11,7 +11,7 @@ import type { ResponseStatus, } from "../filters.type"; -const parseAsFilterValueArray: Parser = { +export const parseAsFilterValueArray: Parser = { parse: (str: string | null) => { if (!str) { return []; @@ -197,33 +197,8 @@ export const useFilters = () => { [filters, updateFilters], ); - const addFilter = useCallback( - ( - field: FilterField, - operator: FilterOperator, - value: string | number | ResponseStatus | HttpMethod, - ) => { - const newFilter: FilterValue = { - id: crypto.randomUUID(), - field, - operator, - value, - metadata: - field === "status" - ? { - colorClass: filterFieldConfig.status.getColorClass?.(value as number), - } - : undefined, - }; - - updateFilters([...filters, newFilter]); - }, - [filters, updateFilters], - ); - return { filters, - addFilter, removeFilter, updateFilters, }; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 88fb99b45..dde8b4ad9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run" }, "dependencies": { "@ant-design/plots": "^1.2.5", @@ -107,14 +108,18 @@ "devDependencies": { "@clerk/types": "^3.63.1", "@tailwindcss/aspect-ratio": "^0.4.2", + "@testing-library/react": "^16.2.0", + "@testing-library/react-hooks": "^8.0.1", "@types/d3-array": "^3.2.1", "@types/ms": "^0.7.34", "@types/node": "^20.14.9", "@types/react": "18.3.11", "@types/react-dom": "18.3.0", "autoprefixer": "^10.4.19", + "jsdom": "^26.0.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", - "typescript": "^5.1.3" + "typescript": "^5.1.3", + "vitest": "^1.6.0" } } diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts new file mode 100644 index 000000000..9f6250a33 --- /dev/null +++ b/apps/dashboard/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43f956d06..d3f116b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,7 +55,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) apps/agent: {} @@ -145,7 +145,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) wrangler: specifier: ^3.92.0 version: 3.92.0(@cloudflare/workers-types@4.20240603.0) @@ -535,6 +535,12 @@ importers: '@tailwindcss/aspect-ratio': specifier: ^0.4.2 version: 0.4.2(tailwindcss@3.4.15) + '@testing-library/react': + specifier: ^16.2.0 + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@types/d3-array': specifier: ^3.2.1 version: 3.2.1 @@ -550,6 +556,12 @@ importers: '@types/react-dom': specifier: 18.3.0 version: 18.3.0 + jsdom: + specifier: ^26.0.0 + version: 26.0.0 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) apps/docs: dependencies: @@ -1027,7 +1039,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/checkly: devDependencies: @@ -1067,7 +1079,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/db: dependencies: @@ -1113,7 +1125,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/encryption: dependencies: @@ -1132,7 +1144,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/events: dependencies: @@ -1157,7 +1169,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/icons: dependencies: @@ -1189,7 +1201,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) internal/keys: dependencies: @@ -1444,7 +1456,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) packages/cache: dependencies: @@ -1478,7 +1490,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) packages/error: dependencies: @@ -1585,7 +1597,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) tools/k6: devDependencies: @@ -2080,6 +2092,16 @@ packages: js-yaml: 4.1.0 dev: false + /@asamuzakjp/css-color@2.8.3: + resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + dependencies: + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.7(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + dev: true + /@asteasolutions/zod-to-openapi@7.3.0(zod@3.23.8): resolution: {integrity: sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q==} peerDependencies: @@ -2689,7 +2711,7 @@ packages: esbuild: 0.17.19 miniflare: 3.20240524.2 semver: 7.6.3 - vitest: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + vitest: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) wrangler: 3.59.0(@cloudflare/workers-types@4.20240603.0) zod: 3.23.8 transitivePeerDependencies: @@ -2925,6 +2947,49 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.9 + /@csstools/color-helpers@5.0.1: + resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} + engines: {node: '>=18'} + dev: true + + /@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3): + resolution: {integrity: sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + dev: true + + /@csstools/css-color-parser@3.0.7(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3): + resolution: {integrity: sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + dependencies: + '@csstools/color-helpers': 5.0.1 + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + dev: true + + /@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3): + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + dependencies: + '@csstools/css-tokenizer': 3.0.3 + dev: true + + /@csstools/css-tokenizer@3.0.3: + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + dev: true + /@dagger.io/dagger@0.14.0: resolution: {integrity: sha512-vPQRo70WzNQU12PGX2dI1yqxPXM0miYnsdu+Jn5UdAJMB1WpyY8pjMe489Bjgg/G5OVmekaeMDGsJYtlSYvbXg==} engines: {node: '>=18'} @@ -10318,6 +10383,66 @@ packages: zod: 3.23.8 dev: false + /@testing-library/dom@10.4.0: + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.26.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/react-hooks@8.0.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.26.0 + '@types/react': 18.3.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-error-boundary: 3.1.4(react@18.3.1) + dev: true + + /@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.26.0 + '@testing-library/dom': 10.4.0 + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: true + /@tootallnate/quickjs-emscripten@0.23.0: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} dev: true @@ -10490,6 +10615,10 @@ packages: dependencies: '@types/estree': 1.0.6 + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + /@types/body-parser@1.19.5: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: @@ -11291,7 +11420,7 @@ packages: pathe: 1.1.2 picocolors: 1.1.1 sirv: 2.0.4 - vitest: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0) + vitest: 1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0) dev: true /@vitest/utils@1.5.3: @@ -11855,6 +11984,12 @@ packages: dependencies: tslib: 2.8.1 + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + /aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -13189,6 +13324,14 @@ packages: engines: {node: '>=4'} hasBin: true + /cssstyle@4.2.1: + resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} + engines: {node: '>=18'} + dependencies: + '@asamuzakjp/css-color': 2.8.3 + rrweb-cssom: 0.8.0 + dev: true + /csstype@3.1.1: resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} @@ -13366,6 +13509,14 @@ packages: engines: {node: '>= 14'} dev: true + /data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + dev: true + /data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -13476,6 +13627,10 @@ packages: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} dev: false + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + /decircular@0.1.1: resolution: {integrity: sha512-V2Vy+QYSXdgxRPmOZKQWCDf1KQNTUP/Eqswv/3W20gz7+6GB1HTosNrWqK3PqstVpFw/Dd/cGTmXSTKPeOiGVg==} engines: {node: '>=18'} @@ -13755,6 +13910,10 @@ packages: - supports-color dev: true + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: @@ -16733,6 +16892,13 @@ packages: lru-cache: 10.4.3 dev: true + /html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + dependencies: + whatwg-encoding: 3.1.1 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: false @@ -17286,6 +17452,10 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -17577,6 +17747,42 @@ packages: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} dev: true + /jsdom@26.0.0: + resolution: {integrity: sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + cssstyle: 4.2.1 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.1 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.16 + parse5: 7.2.1 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -18079,6 +18285,11 @@ packages: engines: {node: '>=12'} dev: true + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + /magic-string@0.16.0: resolution: {integrity: sha512-c4BEos3y6G2qO0B9X7K0FVLOPT9uGrjYwYRLFmDqyl5YMboUviyecnXWp94fJTSMwPw2/sf+CEYt5AGpmklkkQ==} dependencies: @@ -20006,6 +20217,10 @@ packages: next: 14.2.15(@babel/core@7.26.0)(@opentelemetry/api@1.4.1)(react-dom@18.3.1)(react@18.3.1) dev: false + /nwsapi@2.2.16: + resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -20821,6 +21036,15 @@ packages: hasBin: true dev: false + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -21272,6 +21496,16 @@ packages: - webpack-cli dev: false + /react-error-boundary@3.1.4(react@18.3.1): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.26.0 + react: 18.3.1 + dev: true + /react-hook-form@7.51.3(react@18.3.1): resolution: {integrity: sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==} engines: {node: '>=12.22.0'} @@ -21293,6 +21527,10 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-is@18.1.0: resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} dev: false @@ -22312,6 +22550,10 @@ packages: fsevents: 2.3.3 dev: true + /rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + dev: true + /rss@1.2.2: resolution: {integrity: sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==} dependencies: @@ -22402,6 +22644,13 @@ packages: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} dev: true + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: @@ -23471,6 +23720,10 @@ packages: vue: 3.5.13(typescript@5.5.3) dev: false + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /tailwind-merge@2.2.0: resolution: {integrity: sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==} dependencies: @@ -23774,6 +24027,17 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tldts-core@6.1.73: + resolution: {integrity: sha512-k1g5eX87vxu3g//6XMn62y4qjayu4cYby/PF7Ksnh4F4uUK1Z1ze/mJ4a+y5OjdJ+cXRp+YTInZhH+FGdUWy1w==} + dev: true + + /tldts@6.1.73: + resolution: {integrity: sha512-/h4bVmuEMm57c2uCiAf1Q9mlQk7cA22m+1Bu0K92vUUtTVT9D4mOFWD9r4WQuTULcG9eeZtNKhLl0Il1LdKGog==} + hasBin: true + dependencies: + tldts-core: 6.1.73 + dev: true + /tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -23825,6 +24089,13 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + /tough-cookie@5.1.0: + resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} + engines: {node: '>=16'} + dependencies: + tldts: 6.1.73 + dev: true + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -23834,6 +24105,13 @@ packages: punycode: 2.3.1 dev: true + /tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + dependencies: + punycode: 2.3.1 + dev: true + /traverse@0.6.10: resolution: {integrity: sha512-hN4uFRxbK+PX56DxYiGHsTn2dME3TVr9vbNqlQGcGcPhJAn+tdP126iA+TArMpI4YSgnTkMWyoLl5bf81Hi5TA==} engines: {node: '>= 0.4'} @@ -24871,7 +25149,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0): + /vitest@1.6.0(@types/node@20.14.9)(@vitest/ui@1.6.0)(jsdom@26.0.0): resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -24907,6 +25185,7 @@ packages: chai: 4.5.0 debug: 4.4.0(supports-color@8.1.1) execa: 8.0.1 + jsdom: 26.0.0 local-pkg: 0.5.1 magic-string: 0.30.15 pathe: 1.1.2 @@ -24957,6 +25236,13 @@ packages: typescript: 5.5.3 dev: false + /w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + dependencies: + xml-name-validator: 5.0.0 + dev: true + /watchpack@2.4.2: resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} engines: {node: '>=10.13.0'} @@ -25004,6 +25290,11 @@ packages: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} dev: true + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + /webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} @@ -25075,9 +25366,29 @@ packages: - uglify-js dev: false + /whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + dependencies: + iconv-lite: 0.6.3 + dev: true + /whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + /whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + dev: true + + /whatwg-url@14.1.0: + resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} + engines: {node: '>=18'} + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: @@ -25439,6 +25750,11 @@ packages: utf-8-validate: optional: true + /xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + dev: true + /xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -25456,6 +25772,10 @@ packages: engines: {node: '>=4.0'} dev: true + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /xmlhttprequest-ssl@2.0.0: resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} engines: {node: '>=0.4.0'} From 3fb6835c0064dcd03a442a7099c0e637feb3d329 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 20 Jan 2025 17:09:04 +0300 Subject: [PATCH 19/68] feat: fetch paths for logs page --- .../components/hooks/use-checkbox-state.ts | 80 ++++++------ .../logs-filters/components/paths-filter.tsx | 114 +++++------------- apps/dashboard/lib/trpc/routers/index.ts | 2 + .../trpc/routers/logs/query-distinct-paths.ts | 39 ++++++ 4 files changed, 111 insertions(+), 124 deletions(-) create mode 100644 apps/dashboard/lib/trpc/routers/logs/query-distinct-paths.ts diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts index 939bf9644..33fa3c371 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts @@ -1,11 +1,12 @@ import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; -import { useCallback, useState } from "react"; +import { useEffect, useState } from "react"; type UseCheckboxStateProps = { options: Array<{ id: number } & TItem>; filters: FilterValue[]; filterField: string; checkPath: keyof TItem; // Specify which field to get from checkbox item + shouldSyncWithOptions?: boolean; }; export const useCheckboxState = >({ @@ -13,6 +14,7 @@ export const useCheckboxState = >({ filters, filterField, checkPath, + shouldSyncWithOptions = false, }: UseCheckboxStateProps) => { const [checkboxes, setCheckboxes] = useState(() => { const activeFilters = filters @@ -25,6 +27,12 @@ export const useCheckboxState = >({ })); }); + useEffect(() => { + if (shouldSyncWithOptions && options.length > 0) { + setCheckboxes(options); + } + }, [options, shouldSyncWithOptions]); + const handleCheckboxChange = (index: number): void => { setCheckboxes((prevCheckboxes) => { const newCheckboxes = [...prevCheckboxes]; @@ -46,48 +54,42 @@ export const useCheckboxState = >({ }); }; - const handleToggle = useCallback( - (index?: number) => { - if (typeof index === "number") { - handleCheckboxChange(index); - } else { - handleSelectAll(); - } - }, - [handleCheckboxChange, handleSelectAll], - ); - - const handleKeyDown = useCallback( - (event: React.KeyboardEvent, index?: number) => { - // Handle checkbox toggle - if (event.key === " " || event.key === "Enter" || event.key === "h" || event.key === "l") { - event.preventDefault(); - handleToggle(index); - } + const handleToggle = (index?: number) => { + if (typeof index === "number") { + handleCheckboxChange(index); + } else { + handleSelectAll(); + } + }; - // Handle navigation - if ( - event.key === "ArrowDown" || - event.key === "ArrowUp" || - event.key === "j" || - event.key === "k" - ) { - event.preventDefault(); - const elements = document.querySelectorAll('label[role="checkbox"]'); - const currentIndex = Array.from(elements).findIndex((el) => el === event.currentTarget); + const handleKeyDown = (event: React.KeyboardEvent, index?: number) => { + // Handle checkbox toggle + if (event.key === " " || event.key === "Enter" || event.key === "h" || event.key === "l") { + event.preventDefault(); + handleToggle(index); + } - let nextIndex: number; - if (event.key === "ArrowDown" || event.key === "j") { - nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0; - } else { - nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1; - } + // Handle navigation + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "j" || + event.key === "k" + ) { + event.preventDefault(); + const elements = document.querySelectorAll('label[role="checkbox"]'); + const currentIndex = Array.from(elements).findIndex((el) => el === event.currentTarget); - (elements[nextIndex] as HTMLElement).focus(); + let nextIndex: number; + if (event.key === "ArrowDown" || event.key === "j") { + nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0; + } else { + nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1; } - }, - [handleToggle], - ); + + (elements[nextIndex] as HTMLElement).focus(); + } + }; return { checkboxes, diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx index 0e01de495..f15a10a16 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx @@ -1,104 +1,33 @@ import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { Checkbox } from "@/components/ui/checkbox"; +import { trpc } from "@/lib/trpc/client"; import { Button } from "@unkey/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { useCheckboxState } from "./hooks/use-checkbox-state"; -interface CheckboxOption { - id: number; - path: string; - checked: boolean; -} - -const options: CheckboxOption[] = [ - { - id: 1, - path: "/v1/analytics.export", - checked: false, - }, - { - id: 2, - path: "/v1/analytics.getDetails", - checked: false, - }, - { - id: 3, - path: "/v1/analytics.getOverview", - checked: false, - }, - { - id: 4, - path: "/v1/auth.login", - checked: false, - }, - { - id: 5, - path: "/v1/auth.logout", - checked: false, - }, - { - id: 6, - path: "/v1/auth.refreshToken", - checked: false, - }, - { - id: 7, - path: "/v1/data.delete", - checked: false, - }, - { - id: 8, - path: "/v1/data.fetch", - checked: false, - }, - { - id: 9, - path: "/v1/data.submit", - checked: false, - }, - { - id: 10, - path: "/v1/auth.login", - checked: false, - }, - { - id: 11, - path: "/v1/auth.logout", - checked: false, - }, - { - id: 12, - path: "/v1/auth.refreshToken", - checked: false, - }, - { - id: 13, - path: "/v1/data.delete", - checked: false, - }, - { - id: 14, - path: "/v1/data.fetch", - checked: false, - }, - { - id: 15, - path: "/v1/data.submit", - checked: false, - }, -] as const; - export const PathsFilter = () => { + const { data: paths, isLoading } = trpc.logs.queryDistinctPaths.useQuery(undefined, { + select(paths) { + return paths + ? paths.map((path, index) => ({ + id: index + 1, + path, + checked: false, + })) + : []; + }, + }); const { filters, updateFilters } = useFilters(); const [isAtBottom, setIsAtBottom] = useState(false); const scrollContainerRef = useRef(null); const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ - options, + options: paths ?? [], filters, filterField: "paths", checkPath: "path", + shouldSyncWithOptions: true, }); const handleScroll = useCallback(() => { if (scrollContainerRef.current) { @@ -134,6 +63,21 @@ export const PathsFilter = () => { updateFilters([...otherFilters, ...pathFilters]); }, [checkboxes, filters, updateFilters]); + if (isLoading) { + return ( +
+
+
+ Loading paths... +
+
+ ); + } + return (