diff --git a/apps/dashboard/app/(app)/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/logs/components/charts/index.tsx index 096bcb95c1..a4d969f77a 100644 --- a/apps/dashboard/app/(app)/logs/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/charts/index.tsx @@ -1,45 +1,8 @@ "use client"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { Grid } from "@unkey/icons"; -import { useEffect, useRef, useState } from "react"; -import { Bar, BarChart, ReferenceArea, ResponsiveContainer, YAxis } from "recharts"; +import { LogsTimeseriesBarChart } from "@/components/logs/chart"; +import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; import { useFilters } from "../../hooks/use-filters"; -import { LogsChartError } from "./components/logs-chart-error"; -import { LogsChartLoading } from "./components/logs-chart-loading"; import { useFetchTimeseries } from "./hooks/use-fetch-timeseries"; -import { calculateTimePoints } from "./utils/calculate-timepoints"; -import { convertDateToLocal } from "./utils/convert-date-to-local"; -import { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp"; - -const chartConfig = { - success: { - label: "Success", - subLabel: "2xx", - color: "hsl(var(--accent-4))", - }, - warning: { - label: "Warning", - subLabel: "4xx", - color: "hsl(var(--warning-9))", - }, - error: { - label: "Error", - subLabel: "5xx", - color: "hsl(var(--error-9))", - }, -} satisfies ChartConfig; - -type Selection = { - start: string | number; - end: string | number; - startTimestamp?: number; - endTimestamp?: number; -}; export function LogsChart({ onMount, @@ -47,182 +10,61 @@ export function LogsChart({ onMount: (distanceToTop: number) => void; }) { const { filters, updateFilters } = useFilters(); - const chartRef = useRef(null); - const [selection, setSelection] = useState({ start: "", end: "" }); - const { timeseries, isLoading, isError } = useFetchTimeseries(); - // biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation - useEffect(() => { - const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; - onMount(distanceToTop); - }, [onMount, isLoading, isError]); - const handleMouseDown = (e: any) => { - const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; - setSelection({ - start: e.activeLabel, - end: e.activeLabel, - startTimestamp: timestamp, - endTimestamp: timestamp, - }); - }; + const handleSelectionChange = ({ + start, + end, + }: { + start: number; + end: number; + }) => { + const activeFilters = filters.filter( + (f) => !["startTime", "endTime", "since"].includes(f.field), + ); - const handleMouseMove = (e: any) => { - if (selection.start) { - const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; - setSelection((prev) => ({ - ...prev, - end: e.activeLabel, - startTimestamp: timestamp, - })); - } + updateFilters([ + ...activeFilters, + { + field: "startTime", + value: convertDateToLocal(start), + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "endTime", + value: convertDateToLocal(end), + id: crypto.randomUUID(), + operator: "is", + }, + ]); }; - const handleMouseUp = () => { - if (selection.start && selection.end) { - const activeFilters = filters.filter( - (f) => !["startTime", "endTime", "since"].includes(f.field), - ); - - if (!selection.startTimestamp || !selection.endTimestamp) { - return; - } - - // Ensure startTime is smaller than endTime - const [start, end] = [selection.startTimestamp, selection.endTimestamp].sort((a, b) => a - b); - - updateFilters([ - ...activeFilters, - { - field: "startTime", - value: convertDateToLocal(start), - id: crypto.randomUUID(), - operator: "is", + return ( + ; - } - - if (isLoading) { - return ; - } - - return ( -
-
- {timeseries - ? calculateTimePoints( - timeseries[0]?.originalTimestamp ?? Date.now(), - timeseries.at(-1)?.originalTimestamp ?? Date.now(), - ).map((time, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. -
- {formatTimestampLabel(time)} -
- )) - : null} -
- - - - dataMax * 1.5]} hide /> - { - if (!active || !payload?.length || payload?.[0]?.payload.total === 0) { - return null; - } - - return ( - -
- -
-
- - All - - Total -
-
- - {payload[0]?.payload?.total} - -
-
-
-
- } - className="rounded-lg shadow-lg border border-gray-4" - labelFormatter={(_, tooltipPayload) => { - const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; - return originalTimestamp ? ( -
- - {formatTimestampTooltip(originalTimestamp)} - -
- ) : ( - "" - ); - }} - /> - ); - }} - /> - {["success", "warning", "error"].map((key) => ( - - ))} - {selection.start && selection.end && ( - - )} - - - - + error: { + label: "Error", + subLabel: "5xx", + color: "hsl(var(--error-9))", + }, + }} + onMount={onMount} + onSelectionChange={handleSelectionChange} + isLoading={isLoading} + isError={isError} + enableSelection={true} + /> ); } diff --git a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx index 411ef5521f..1d7c0f1aef 100644 --- a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx @@ -1,12 +1,6 @@ -import { KeyboardButton } from "@/components/keyboard-button"; -import { TimestampInfo } from "@/components/timestamp-info"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { cn } from "@/lib/utils"; -import { XMark } from "@unkey/icons"; -import { Button } from "@unkey/ui"; +import { ControlCloud } from "@/components/logs/control-cloud"; import { format } from "date-fns"; -import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import type { LogsFilterValue } from "../../filters.schema"; +import { HISTORICAL_DATA_WINDOW } from "../../constants"; import { useFilters } from "../../hooks/use-filters"; const formatFieldName = (field: string): string => { @@ -29,12 +23,6 @@ const formatFieldName = (field: string): string => { return field.charAt(0).toUpperCase() + field.slice(1); } }; -const formatOperator = (operator: string, field: string): string => { - if (field === "since" && operator === "is") { - return "Last"; - } - return operator; -}; const formatValue = (value: string | number, field: string): string => { if (typeof value === "string" && /^\d+$/.test(value)) { @@ -56,208 +44,16 @@ const formatValue = (value: string | number, field: string): string => { return String(value); }; -type ControlPillProps = { - filter: LogsFilterValue; - onRemove: (id: string) => void; - isFocused?: boolean; - onFocus?: () => void; - index: number; -}; - -const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPillProps) => { - const { field, operator, value, metadata } = filter; - const pillRef = useRef(null); - - useEffect(() => { - if (isFocused && pillRef.current) { - const button = pillRef.current.querySelector("button"); - button?.focus(); - } - }, [isFocused]); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Backspace" || e.key === "Delete") { - e.preventDefault(); - onRemove(filter.id); - } - }; - - return ( -
- {formatFieldName(field) === "" ? null : ( -
- {formatFieldName(field)} -
- )} - -
- {formatOperator(operator, field)} -
-
- {metadata?.colorClass && ( -
- )} - {metadata?.icon} - - {field === "endTime" || field === "startTime" ? ( - - ) : ( - {formatValue(value, field)} - )} -
-
- -
-
- ); -}; - export const LogsControlCloud = () => { - const { filters, removeFilter, updateFilters } = useFilters(); - const [focusedIndex, setFocusedIndex] = useState(null); - - useKeyboardShortcut({ key: "d", meta: true }, () => { - const timestamp = Date.now(); - updateFilters([ - { - field: "endTime", - value: timestamp, - id: crypto.randomUUID(), - operator: "is", - }, - { - field: "startTime", - value: timestamp - 24 * 60 * 60 * 1000, - id: crypto.randomUUID(), - operator: "is", - }, - ]); - }); - - useKeyboardShortcut({ key: "c", meta: true }, () => { - setFocusedIndex(0); - }); - - const handleRemoveFilter = useCallback( - (id: string) => { - removeFilter(id); - // Adjust focus after removal - if (focusedIndex !== null) { - if (focusedIndex >= filters.length - 1) { - setFocusedIndex(Math.max(filters.length - 2, 0)); - } - } - }, - [removeFilter, filters.length, focusedIndex], - ); - - const handleKeyDown = (e: KeyboardEvent) => { - if (filters.length === 0) { - return; - } - - const findNextInDirection = (direction: "up" | "down") => { - if (focusedIndex === null) { - return 0; - } - - // Get all buttons - const buttons = document.querySelectorAll("[data-pill-index] button"); - const currentButton = buttons[focusedIndex] as HTMLElement; - if (!currentButton) { - return focusedIndex; - } - - const currentRect = currentButton.getBoundingClientRect(); - let closestDistance = Number.POSITIVE_INFINITY; - let closestIndex = focusedIndex; - - buttons.forEach((button, index) => { - const rect = button.getBoundingClientRect(); - - // Check if item is in the row above/below - const isAbove = direction === "up" && rect.bottom < currentRect.top; - const isBelow = direction === "down" && rect.top > currentRect.bottom; - - if (isAbove || isBelow) { - // Calculate horizontal distance - const horizontalDistance = Math.abs(rect.left - currentRect.left); - if (horizontalDistance < closestDistance) { - closestDistance = horizontalDistance; - closestIndex = index; - } - } - }); - - return closestIndex; - }; - - switch (e.key) { - case "ArrowRight": - case "l": - e.preventDefault(); - setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % filters.length)); - break; - case "ArrowLeft": - case "h": - e.preventDefault(); - setFocusedIndex((prev) => - prev === null ? filters.length - 1 : (prev - 1 + filters.length) % filters.length, - ); - break; - case "ArrowDown": - case "j": - e.preventDefault(); - setFocusedIndex(findNextInDirection("down")); - break; - case "ArrowUp": - case "k": - e.preventDefault(); - setFocusedIndex(findNextInDirection("up")); - break; - } - }; - - if (filters.length === 0) { - return null; - } - + const { filters, updateFilters, removeFilter } = useFilters(); return ( -
- {filters.map((filter, index) => ( - setFocusedIndex(index)} - index={index} - /> - ))} -
- Clear filters - -
- Focus filters - -
-
+ ); }; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx deleted file mode 100644 index 3675c21df4..0000000000 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; -import { KeyboardButton } from "@/components/keyboard-button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { CaretRight } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -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"; - -type FilterItemConfig = { - id: string; - label: string; - shortcut?: string; - component: React.ReactNode; -}; - -const FILTER_ITEMS: FilterItemConfig[] = [ - { - id: "status", - label: "Status", - shortcut: "e", - component: , - }, - { - id: "methods", - label: "Method", - shortcut: "m", - component: , - }, - { - id: "paths", - label: "Path", - shortcut: "p", - component: , - }, -]; - -export const FiltersPopover = ({ children }: PropsWithChildren) => { - const [open, setOpen] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(null); - 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); - }; - }, [open]); - - useKeyboardShortcut("f", () => { - setOpen((prev) => !prev); - }); - - const handleKeyDown = (e: KeyboardEvent) => { - if (!open) { - return; - } - - // 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; - } - - switch (e.key) { - case "ArrowDown": - case "j": - e.preventDefault(); - setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length)); - break; - case "ArrowUp": - case "k": - e.preventDefault(); - setFocusedIndex((prev) => - prev === null - ? FILTER_ITEMS.length - 1 - : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, - ); - break; - case "Enter": - case "l": - case "ArrowRight": - e.preventDefault(); - if (focusedIndex !== null) { - const selectedFilter = FILTER_ITEMS[focusedIndex]; - if (selectedFilter) { - setActiveFilter(selectedFilter.id); - } - } - break; - case "h": - case "ArrowLeft": - // Don't handle left arrow in main popover - let it bubble to FilterItem - break; - } - }; - - return ( - - {children} - -
- - {FILTER_ITEMS.map((item, index) => ( - - ))} -
-
-
- ); -}; - -const PopoverHeader = () => { - return ( -
- Filters... - -
- ); -}; - -type FilterItemProps = FilterItemConfig & { - isFocused?: boolean; - isActive?: boolean; -}; - -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) { - e.preventDefault(); - setOpen(false); - itemRef.current?.focus(); - } - }; - - useKeyboardShortcut( - { key: shortcut || "", meta: true }, - () => { - setOpen(true); - }, - { preventDefault: true }, - ); - - // Focus the element when isFocused changes - useEffect(() => { - if (isFocused && itemRef.current) { - itemRef.current.focus(); - } - }, [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 ( - - -
-
- {shortcut && ( - - )} - {label} -
-
- {filters.filter((filter) => filter.field === id).length > 0 && ( -
- {filters.filter((filter) => filter.field === id).length} -
- )} - -
-
-
- - {component} - -
- ); -}; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx index 7475fb9be8..4cbde8fd01 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx @@ -1,4 +1,4 @@ -import { FilterCheckbox } from "./filter-checkbox"; +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; type MethodOption = { id: number; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx index 39c429e66b..d2ab32ed35 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx @@ -1,10 +1,10 @@ import type { LogsFilterValue } from "@/app/(app)/logs/filters.schema"; import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { useCheckboxState } from "@/components/logs/checkbox/hooks"; import { Checkbox } from "@/components/ui/checkbox"; import { trpc } from "@/lib/trpc/client"; import { Button } from "@unkey/ui"; import { useCallback } from "react"; -import { useCheckboxState } from "./hooks/use-checkbox-state"; export const PathsFilter = () => { const { data: paths, isLoading } = trpc.logs.queryDistinctPaths.useQuery(undefined, { diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/status-filter.tsx index 4837f04b06..f85e7bcdb2 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/status-filter.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/status-filter.tsx @@ -1,5 +1,5 @@ import type { ResponseStatus } from "@/app/(app)/logs/types"; -import { FilterCheckbox } from "./filter-checkbox"; +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; type StatusOption = { id: number; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx index dfab5b1df5..e3a5cff19b 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx @@ -1,13 +1,37 @@ import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { type FilterItemConfig, FiltersPopover } from "@/components/logs/checkbox/filters-popover"; import { BarsFilter } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import { FiltersPopover } from "./components/filters-popover"; +import { MethodsFilter } from "./components/methods-filter"; +import { PathsFilter } from "./components/paths-filter"; +import { StatusFilter } from "./components/status-filter"; + +const FILTER_ITEMS: FilterItemConfig[] = [ + { + id: "status", + label: "Status", + shortcut: "e", + component: , + }, + { + id: "methods", + label: "Method", + shortcut: "m", + component: , + }, + { + id: "paths", + label: "Path", + shortcut: "p", + component: , + }, +]; export const LogsFilters = () => { const { filters } = useFilters(); return ( - +
- ); + return ; }; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx index 6dca128418..e9e2f483bf 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx @@ -1,9 +1,5 @@ -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; -import { Refresh3 } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; import { useLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; @@ -11,43 +7,19 @@ export const LogsRefresh = () => { const { toggleLive, isLive } = useLogsContext(); const { filters } = useFilters(); const { logs } = trpc.useUtils(); - const [isLoading, setIsLoading] = useState(false); - const hasRelativeFilter = filters.find((f) => f.field === "since"); - useKeyboardShortcut("r", () => { - hasRelativeFilter && handleSwitch(); - }); - const handleSwitch = () => { - const isLiveBefore = Boolean(isLive); - setIsLoading(true); - toggleLive(false); + const handleRefresh = () => { logs.queryLogs.invalidate(); logs.queryTimeseries.invalidate(); - - setTimeout(() => { - setIsLoading(false); - if (isLiveBefore) { - toggleLive(true); - } - }, 1000); }; return ( - + ); }; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx index d063fd7d9d..e4b18bb9d6 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx @@ -1,178 +1,56 @@ import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { LogsLLMSearch } from "@/components/logs/llm-search"; import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; import { toast } from "@/components/ui/toaster"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { trpc } from "@/lib/trpc/client"; -import { cn } from "@/lib/utils"; -import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3, XMark } from "@unkey/icons"; -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) { - 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", + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, }, - }); + ); + return; } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); }, onError(error) { - toast.error(error.message, { + const errorMessage = `Unable to process your search request${ + error.message ? "' ${error.message} '" : "." + } Please try again or refine your search criteria.`; + + toast.error(errorMessage, { duration: 8000, important: true, position: "top-right", style: { whiteSpace: "pre-line", }, + className: "font-medium", }); }, }); - const [searchText, setSearchText] = useState(""); - const inputRef = useRef(null); - - useKeyboardShortcut("s", () => { - inputRef.current?.click(); - inputRef.current?.focus(); - }); - - const handleSearch = async (search: string) => { - const query = search.trim(); - if (query) { - try { - await queryLLMForStructuredOutput.mutateAsync(query); - } catch (error) { - console.error("Search failed:", error); - } - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - (document.activeElement as HTMLElement)?.blur(); - } - if (e.key === "Enter") { - e.preventDefault(); - handleSearch(searchText); - } - }; - - const handlePresetQuery = (query: string) => { - setSearchText(query); - handleSearch(query); - }; - - const isLoading = queryLLMForStructuredOutput.isLoading; - return ( -
-
0 ? "bg-gray-4" : "", - isLoading ? "bg-gray-4" : "", - )} - > -
-
- {isLoading ? ( - - ) : ( - - )} -
- -
- {isLoading ? ( -
- AI consults the Palantír... -
- ) : ( - setSearchText(e.target.value)} - placeholder="Search and filter with AI…" - className="text-accent-12 font-medium text-[13px] bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12 selection:bg-gray-6 w-full" - disabled={isLoading} - /> - )} -
-
{" "} - - - {searchText.length > 0 && !isLoading && ( - - )} - - {searchText.length === 0 && !isLoading && ( -
- -
- )} -
- -
-
- Try queries like: - (click to use) -
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
-
-
-
-
-
-
+ + queryLLMForStructuredOutput.mutateAsync({ + query, + timestamp: Date.now(), + }) + } + /> ); }; diff --git a/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts index 99f750febc..079f2b59f0 100644 --- a/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts @@ -2,11 +2,11 @@ import { trpc } from "@/lib/trpc/client"; import type { Log } from "@unkey/clickhouse/src/logs"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { z } from "zod"; +import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useFilters } from "../../../hooks/use-filters"; import type { queryLogsPayload } from "../query-logs.schema"; // Duration in milliseconds for historical data fetch window (12 hours) -export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; type UseLogsQueryParams = { limit?: number; pollIntervalMs?: number; diff --git a/apps/dashboard/app/(app)/logs/constants.ts b/apps/dashboard/app/(app)/logs/constants.ts index be02968cef..5e12113788 100644 --- a/apps/dashboard/app/(app)/logs/constants.ts +++ b/apps/dashboard/app/(app)/logs/constants.ts @@ -7,3 +7,5 @@ export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; export const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; export const STATUSES = [200, 400, 500] as const; + +export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx deleted file mode 100644 index b394b2df85..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ResponsiveContainer } from "recharts"; - -export const LogsChartError = () => { - return ( -
-
- {Array(5) - .fill(0) - .map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: it's okay -
- --:-- -
- ))} -
- -
- Could not retrieve logs -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx deleted file mode 100644 index a5ad4d70a5..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; -import { calculateTimePoints } from "../utils/calculate-timepoints"; -import { formatTimestampLabel } from "../utils/format-timestamp"; - -export const LogsChartLoading = () => { - const mockData = Array.from({ length: 100 }).map(() => ({ - success: Math.random() * 0.5 + 0.5, // Random values between 0.5 and 1 - })); - - return ( -
-
- {calculateTimePoints(Date.now(), Date.now()).map((time, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: it's safe to use index here -
- {formatTimestampLabel(time)} -
- ))} -
- - - dataMax * 2]} hide /> - - - -
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx index f07a159780..cfa5b5b300 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx @@ -1,142 +1,67 @@ "use client"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { Grid } from "@unkey/icons"; -import { useEffect, useRef } from "react"; -import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; +import { LogsTimeseriesBarChart } from "@/components/logs/chart"; +import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; import { useRatelimitLogsContext } from "../../context/logs"; -import { LogsChartError } from "./components/logs-chart-error"; -import { LogsChartLoading } from "./components/logs-chart-loading"; +import { useFilters } from "../../hooks/use-filters"; import { useFetchRatelimitTimeseries } from "./hooks/use-fetch-timeseries"; -import { calculateTimePoints } from "./utils/calculate-timepoints"; -import { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp"; - -const chartConfig = { - success: { - label: "Passed", - subLabel: "Passed", - color: "hsl(var(--accent-4))", - }, - error: { - label: "Blocked", - subLabel: "Blocked", - color: "hsl(var(--warning-9))", - }, -} satisfies ChartConfig; export function RatelimitLogsChart({ onMount, }: { onMount: (distanceToTop: number) => void; }) { - const chartRef = useRef(null); - const { namespaceId } = useRatelimitLogsContext(); + const { filters, updateFilters } = useFilters(); const { timeseries, isLoading, isError } = useFetchRatelimitTimeseries(namespaceId); - // biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation - useEffect(() => { - const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; - onMount(distanceToTop); - }, [onMount, isLoading, isError]); - if (isError) { - return ; - } + const handleSelectionChange = ({ + start, + end, + }: { + start: number; + end: number; + }) => { + const activeFilters = filters.filter( + (f) => !["startTime", "endTime", "since"].includes(f.field), + ); - if (isLoading) { - return ; - } + updateFilters([ + ...activeFilters, + { + field: "startTime", + value: convertDateToLocal(start), + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "endTime", + value: convertDateToLocal(end), + id: crypto.randomUUID(), + operator: "is", + }, + ]); + }; return ( -
-
- {timeseries - ? calculateTimePoints( - timeseries[0]?.originalTimestamp ?? Date.now(), - timeseries.at(-1)?.originalTimestamp ?? Date.now(), - ).map((time, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. -
- {formatTimestampLabel(time)} -
- )) - : null} -
- - - - dataMax * 1.5]} hide /> - { - if (!active || !payload?.length || payload?.[0]?.payload.total === 0) { - return null; - } - - return ( - -
- -
-
- - All - - Total -
-
- - {payload[0]?.payload?.total} - -
-
-
-
- } - className="rounded-lg shadow-lg border border-gray-4" - labelFormatter={(_, tooltipPayload) => { - const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; - return originalTimestamp ? ( -
- - {formatTimestampTooltip(originalTimestamp)} - -
- ) : ( - "" - ); - }} - /> - ); - }} - /> - - - - - -
+ ); } diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts deleted file mode 100644 index bd5de03495..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const calculateTimePoints = (startTime: number, endTime: number) => { - const points = 6; - const timeRange = endTime - startTime; - const step = Math.floor(timeRange / (points - 1)); - - return Array.from({ length: points }, (_, i) => new Date(startTime + step * i)).filter( - (date) => date.getTime() <= endTime, - ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts deleted file mode 100644 index ea649d2307..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { addMinutes, format } from "date-fns"; - -export const formatTimestampTooltip = (value: string | number) => { - const date = new Date(value); - const offset = new Date().getTimezoneOffset() * -1; - const localDate = addMinutes(date, offset); - return format(localDate, "MMM dd HH:mm aa"); -}; - -export const formatTimestampLabel = (timestamp: string | number | Date) => { - const date = new Date(timestamp); - return format(date, "MMM dd, h:mma").toUpperCase(); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx index 2d1df0acda..3f297564f8 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx @@ -1,14 +1,6 @@ -import { KeyboardButton } from "@/components/keyboard-button"; -import { TimestampInfo } from "@/components/timestamp-info"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { cn } from "@/lib/utils"; -import { XMark } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { format } from "date-fns"; -import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import type { RatelimitFilterValue } from "../../filters.schema"; +import { ControlCloud } from "@/components/logs/control-cloud"; +import { HISTORICAL_DATA_WINDOW } from "../../constants"; import { useFilters } from "../../hooks/use-filters"; -import { HISTORICAL_DATA_WINDOW } from "../table/hooks/use-logs-query"; const formatFieldName = (field: string): string => { switch (field) { @@ -29,219 +21,15 @@ const formatFieldName = (field: string): string => { } }; -const formatValue = (value: string | number, field: string): string => { - if (typeof value === "number" && (field === "startTime" || field === "endTime")) { - return format(value, "MMM d, yyyy HH:mm:ss"); - } - return String(value); -}; - -const formatOperator = (operator: string, field: string): string => { - if (field === "since" && operator === "is") { - return "Last"; - } - return operator; -}; - -type ControlPillProps = { - filter: RatelimitFilterValue; - onRemove: (id: string) => void; - isFocused?: boolean; - onFocus?: () => void; - index: number; -}; - -const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPillProps) => { - const { field, operator, value, metadata } = filter; - const pillRef = useRef(null); - - useEffect(() => { - if (isFocused && pillRef.current) { - const button = pillRef.current.querySelector("button"); - button?.focus(); - } - }, [isFocused]); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Backspace" || e.key === "Delete") { - e.preventDefault(); - onRemove(filter.id); - } - }; - - return ( -
- {formatFieldName(field) === "" ? null : ( -
- {formatFieldName(field)} -
- )} -
- {formatOperator(operator, field)} -
-
- {metadata?.colorClass && ( -
- )} - {field === "endTime" || field === "startTime" ? ( - - ) : ( - {formatValue(value, field)} - )} -
-
- -
-
- ); -}; - export const RatelimitLogsControlCloud = () => { - const { filters, removeFilter, updateFilters } = useFilters(); - const [focusedIndex, setFocusedIndex] = useState(null); - - useKeyboardShortcut({ key: "d", meta: true }, () => { - const timestamp = Date.now(); - updateFilters([ - { - field: "endTime", - value: timestamp, - id: crypto.randomUUID(), - operator: "is", - }, - { - field: "startTime", - value: timestamp - HISTORICAL_DATA_WINDOW, - id: crypto.randomUUID(), - operator: "is", - }, - ]); - }); - - useKeyboardShortcut({ key: "c", meta: true }, () => { - setFocusedIndex(0); - }); - - const handleRemoveFilter = useCallback( - (id: string) => { - removeFilter(id); - // Adjust focus after removal - if (focusedIndex !== null) { - if (focusedIndex >= filters.length - 1) { - setFocusedIndex(Math.max(filters.length - 2, 0)); - } - } - }, - [removeFilter, filters.length, focusedIndex], - ); - - const handleKeyDown = (e: KeyboardEvent) => { - if (filters.length === 0) { - return; - } - - const findNextInDirection = (direction: "up" | "down") => { - if (focusedIndex === null) { - return 0; - } - - // Get all buttons - const buttons = document.querySelectorAll("[data-pill-index] button"); - const currentButton = buttons[focusedIndex] as HTMLElement; - if (!currentButton) { - return focusedIndex; - } - - const currentRect = currentButton.getBoundingClientRect(); - let closestDistance = Number.POSITIVE_INFINITY; - let closestIndex = focusedIndex; - - buttons.forEach((button, index) => { - const rect = button.getBoundingClientRect(); - - // Check if item is in the row above/below - const isAbove = direction === "up" && rect.bottom < currentRect.top; - const isBelow = direction === "down" && rect.top > currentRect.bottom; - - if (isAbove || isBelow) { - // Calculate horizontal distance - const horizontalDistance = Math.abs(rect.left - currentRect.left); - if (horizontalDistance < closestDistance) { - closestDistance = horizontalDistance; - closestIndex = index; - } - } - }); - - return closestIndex; - }; - - switch (e.key) { - case "ArrowRight": - case "l": - e.preventDefault(); - setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % filters.length)); - break; - case "ArrowLeft": - case "h": - e.preventDefault(); - setFocusedIndex((prev) => - prev === null ? filters.length - 1 : (prev - 1 + filters.length) % filters.length, - ); - break; - case "ArrowDown": - case "j": - e.preventDefault(); - setFocusedIndex(findNextInDirection("down")); - break; - case "ArrowUp": - case "k": - e.preventDefault(); - setFocusedIndex(findNextInDirection("up")); - break; - } - }; - - if (filters.length === 0) { - return null; - } - + const { filters, updateFilters, removeFilter } = useFilters(); return ( -
- {filters.map((filter, index) => ( - setFocusedIndex(index)} - index={index} - /> - ))} -
- Clear filters - -
- Focus filters - -
-
+ ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx deleted file mode 100644 index b95f720d5a..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { Button } from "@unkey/ui"; -import { useCallback } from "react"; -import type { RatelimitFilterValue } from "../../../../../filters.schema"; -import { useFilters } from "../../../../../hooks/use-filters"; -import { useCheckboxState } from "./hooks/use-checkbox-state"; - -export type BaseCheckboxOption = { - id: number; - checked: boolean; - [key: string]: any; -}; - -interface BaseCheckboxFilterProps { - options: TCheckbox[]; - filterField: "identifiers" | "status"; - checkPath: string; - className?: string; - showScroll?: boolean; - scrollContainerRef?: React.RefObject; - renderBottomGradient?: () => React.ReactNode; - renderOptionContent?: (option: TCheckbox) => React.ReactNode; - createFilterValue: (option: TCheckbox) => Pick; -} - -export const FilterCheckbox = ({ - options, - filterField, - checkPath, - className, - showScroll = false, - renderOptionContent, - createFilterValue, - scrollContainerRef, - renderBottomGradient, -}: BaseCheckboxFilterProps) => { - const { filters, updateFilters } = useFilters(); - 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 otherFilters = filters.filter((f) => f.field !== filterField); - const newFilters: RatelimitFilterValue[] = selectedValues.map((filterValue) => ({ - id: crypto.randomUUID(), - field: filterField, - operator: "is", - ...filterValue, - })); - - updateFilters([...otherFilters, ...newFilters]); - }, [checkboxes, filterField, filters, updateFilters, createFilterValue]); - - return ( -
-
-
- -
- {checkboxes.map((checkbox, index) => ( - - ))} -
- - {renderBottomGradient?.()} - - {renderBottomGradient &&
} - -
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts deleted file mode 100644 index f85fb11110..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { RatelimitFilterValue } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; -import { useEffect, useState } from "react"; - -type UseCheckboxStateProps = { - options: Array<{ id: number } & TItem>; - filters: RatelimitFilterValue[]; - filterField: string; - checkPath: keyof TItem; // Specify which field to get from checkbox item - shouldSyncWithOptions?: boolean; -}; - -export const useCheckboxState = >({ - options, - filters, - filterField, - checkPath, - shouldSyncWithOptions = false, -}: UseCheckboxStateProps) => { - const [checkboxes, setCheckboxes] = useState(() => { - const activeFilters = filters - .filter((f) => f.field === filterField) - .map((f) => String(f.value)); - - return options.map((checkbox) => ({ - ...checkbox, - checked: activeFilters.includes(String(checkbox[checkPath])), - })); - }); - - useEffect(() => { - if (shouldSyncWithOptions && options.length > 0) { - setCheckboxes(options); - } - }, [options, shouldSyncWithOptions]); - - const handleCheckboxChange = (index: number): void => { - setCheckboxes((prevCheckboxes) => { - const newCheckboxes = [...prevCheckboxes]; - newCheckboxes[index] = { - ...newCheckboxes[index], - checked: !newCheckboxes[index].checked, - }; - return newCheckboxes; - }); - }; - - const handleSelectAll = (): void => { - setCheckboxes((prevCheckboxes) => { - const allChecked = prevCheckboxes.every((checkbox) => checkbox.checked); - return prevCheckboxes.map((checkbox) => ({ - ...checkbox, - checked: !allChecked, - })); - }); - }; - - const handleToggle = (index?: number) => { - if (typeof index === "number") { - handleCheckboxChange(index); - } else { - handleSelectAll(); - } - }; - - 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); - } - - // 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); - - 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; - } - - (elements[nextIndex] as HTMLElement).focus(); - } - }; - - return { - checkboxes, - handleCheckboxChange, - handleSelectAll, - handleKeyDown, - }; -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx index 69bcba0cbe..d9f7980b0b 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx @@ -1,3 +1,4 @@ +import { useCheckboxState } from "@/components/logs/checkbox/hooks"; import { Checkbox } from "@/components/ui/checkbox"; import { trpc } from "@/lib/trpc/client"; import { Button } from "@unkey/ui"; @@ -5,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRatelimitLogsContext } from "../../../../../context/logs"; import type { RatelimitFilterValue } from "../../../../../filters.schema"; import { useFilters } from "../../../../../hooks/use-filters"; -import { useCheckboxState } from "./hooks/use-checkbox-state"; export const IdentifiersFilter = () => { const { namespaceId } = useRatelimitLogsContext(); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx index 0dd7d50e2a..4ee7abc042 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx @@ -1,4 +1,4 @@ -import { FilterCheckbox } from "./filter-checkbox"; +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; type StatusOption = { id: number; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx index 3416d73848..f066113bdf 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx @@ -1,13 +1,30 @@ +import { type FilterItemConfig, FiltersPopover } from "@/components/logs/checkbox/filters-popover"; import { BarsFilter } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useFilters } from "../../../../hooks/use-filters"; -import { FiltersPopover } from "./components/filters-popover"; +import { IdentifiersFilter } from "./components/identifiers-filter"; +import { StatusFilter } from "./components/status-filter"; + +const FILTER_ITEMS: FilterItemConfig[] = [ + { + id: "status", + label: "Status", + shortcut: "e", + component: , + }, + { + id: "identifiers", + label: "Identifier", + shortcut: "p", + component: , + }, +]; export const LogsFilters = () => { const { filters } = useFilters(); return ( - +
- ); + return ; }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx index f1261f1bcc..ba114836ad 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx @@ -1,64 +1,25 @@ -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; -import { Refresh3 } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; import { useRatelimitLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; -const REFRESH_TIMEOUT_MS = 1000; - export const LogsRefresh = () => { - const { isLive, toggleLive } = useRatelimitLogsContext(); + const { toggleLive, isLive } = useRatelimitLogsContext(); const { filters } = useFilters(); const { ratelimit } = trpc.useUtils(); - const [isLoading, setIsLoading] = useState(false); - const [refreshTimeout, setRefreshTimeout] = useState(null); - const hasRelativeFilter = filters.find((f) => f.field === "since"); - useKeyboardShortcut("r", () => { - hasRelativeFilter && handleSwitch(); - }); - const handleSwitch = () => { - if (isLoading) { - return; - } - - const isLiveBefore = Boolean(isLive); - setIsLoading(true); - toggleLive(false); + const handleRefresh = () => { ratelimit.logs.query.invalidate(); ratelimit.logs.queryRatelimitTimeseries.invalidate(); - - if (refreshTimeout) { - clearTimeout(refreshTimeout); - } - const timeout = setTimeout(() => { - setIsLoading(false); - if (isLiveBefore) { - toggleLive(true); - } - }, REFRESH_TIMEOUT_MS); - setRefreshTimeout(timeout); }; return ( - + ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx index f398df9132..581ccc2092 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx @@ -1,172 +1,56 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; import { toast } from "@/components/ui/toaster"; -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; 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 "components/ui/tooltip"; -import { useRef, useState } from "react"; import { useFilters } from "../../../../hooks/use-filters"; export const LogsSearch = () => { const { filters, updateFilters } = useFilters(); const queryLLMForStructuredOutput = trpc.ratelimit.logs.ratelimitLlmSearch.useMutation({ onSuccess(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", + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, }, - }); + ); + return; } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); }, onError(error) { - toast.error(error.message, { + const errorMessage = `Unable to process your search request${ + error.message ? "' ${error.message} '" : "." + } Please try again or refine your search criteria.`; + + toast.error(errorMessage, { duration: 8000, important: true, position: "top-right", style: { whiteSpace: "pre-line", }, + className: "font-medium", }); }, }); - const [searchText, setSearchText] = useState(""); - const inputRef = useRef(null); - - useKeyboardShortcut("s", () => { - inputRef.current?.click(); - inputRef.current?.focus(); - }); - - const handleSearch = async (search: string) => { - const query = search.trim(); - if (query) { - try { - await queryLLMForStructuredOutput.mutateAsync({ + return ( + + queryLLMForStructuredOutput.mutateAsync({ query, timestamp: Date.now(), - }); - } catch (error) { - console.error("Search failed:", error); + }) } - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - (document.activeElement as HTMLElement)?.blur(); - } - if (e.key === "Enter") { - e.preventDefault(); - handleSearch(searchText); - } - }; - - const handlePresetQuery = (query: string) => { - setSearchText(query); - handleSearch(query); - }; - - const isLoading = queryLLMForStructuredOutput.isLoading; - - return ( -
-
0 ? "bg-gray-4" : "", - isLoading ? "bg-gray-4" : "", - )} - > -
-
- {isLoading ? ( - - ) : ( - - )} -
- -
- {isLoading ? ( -
- AI consults the Palantír... -
- ) : ( - setSearchText(e.target.value)} - placeholder="Search and filter with AI…" - className="text-accent-12 font-medium text-[13px] bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12 selection:bg-gray-6 w-full" - disabled={isLoading} - /> - )} -
-
- - - -
- -
-
- -
-
- Try queries like: - (click to use) -
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
-
-
-
-
-
-
+ /> ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts index 0ef74f0235..b65854838c 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts @@ -1,11 +1,11 @@ import { trpc } from "@/lib/trpc/client"; import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useFilters } from "../../../hooks/use-filters"; import type { RatelimitQueryLogsPayload } from "../query-logs.schema"; // Duration in milliseconds for historical data fetch window (12 hours) -export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; type UseLogsQueryParams = { limit?: number; pollIntervalMs?: number; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts index 8be78bf31a..37a911278d 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts @@ -3,3 +3,5 @@ export const DEFAULT_STATUS_FLAG = 0; export const DEFAULT_DRAGGABLE_WIDTH = 500; export const MAX_DRAGGABLE_WIDTH = 800; export const MIN_DRAGGABLE_WIDTH = 300; + +export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; diff --git a/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-error.tsx b/apps/dashboard/components/logs/chart/components/logs-chart-error.tsx similarity index 100% rename from apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-error.tsx rename to apps/dashboard/components/logs/chart/components/logs-chart-error.tsx diff --git a/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-loading.tsx b/apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx similarity index 100% rename from apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-loading.tsx rename to apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx diff --git a/apps/dashboard/components/logs/chart/index.tsx b/apps/dashboard/components/logs/chart/index.tsx new file mode 100644 index 0000000000..75a43e9531 --- /dev/null +++ b/apps/dashboard/components/logs/chart/index.tsx @@ -0,0 +1,220 @@ +// GenericTimeseriesChart.tsx +"use client"; + +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Grid } from "@unkey/icons"; +import { useEffect, useRef, useState } from "react"; +import { Bar, BarChart, ReferenceArea, ResponsiveContainer, YAxis } from "recharts"; +import { LogsChartError } from "./components/logs-chart-error"; +import { LogsChartLoading } from "./components/logs-chart-loading"; +import { calculateTimePoints } from "./utils/calculate-timepoints"; +import { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp"; + +type Selection = { + start: string | number; + end: string | number; + startTimestamp?: number; + endTimestamp?: number; +}; + +type TimeseriesData = { + originalTimestamp: number; + total: number; + [key: string]: any; +}; + +type LogsTimeseriesBarChartProps = { + data?: TimeseriesData[]; + config: ChartConfig; + height?: number; + onMount?: (distanceToTop: number) => void; + onSelectionChange?: (selection: { start: number; end: number }) => void; + isLoading?: boolean; + isError?: boolean; + enableSelection?: boolean; +}; + +export function LogsTimeseriesBarChart({ + data, + config, + height = 50, + onMount, + onSelectionChange, + isLoading, + isError, + enableSelection = false, +}: LogsTimeseriesBarChartProps) { + const chartRef = useRef(null); + const [selection, setSelection] = useState({ start: "", end: "" }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation + useEffect(() => { + if (onMount) { + const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; + onMount(distanceToTop); + } + }, [onMount, isLoading, isError]); + + const handleMouseDown = (e: any) => { + if (!enableSelection) { + return; + } + const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; + setSelection({ + start: e.activeLabel, + end: e.activeLabel, + startTimestamp: timestamp, + endTimestamp: timestamp, + }); + }; + + const handleMouseMove = (e: any) => { + if (!enableSelection) { + return; + } + if (selection.start) { + const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; + setSelection((prev) => ({ + ...prev, + end: e.activeLabel, + startTimestamp: timestamp, + })); + } + }; + + const handleMouseUp = () => { + if (!enableSelection) { + return; + } + if (selection.start && selection.end && onSelectionChange) { + if (!selection.startTimestamp || !selection.endTimestamp) { + return; + } + + const [start, end] = [selection.startTimestamp, selection.endTimestamp].sort((a, b) => a - b); + onSelectionChange({ start, end }); + } + setSelection({ + start: "", + end: "", + startTimestamp: undefined, + endTimestamp: undefined, + }); + }; + + if (isError) { + return ; + } + + if (isLoading) { + return ; + } + + return ( +
+
+ {data + ? calculateTimePoints( + data[0]?.originalTimestamp ?? Date.now(), + data.at(-1)?.originalTimestamp ?? Date.now(), + ).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {formatTimestampLabel(time)} +
+ )) + : null} +
+ + + + dataMax * 1.5]} hide /> + { + if (!active || !payload?.length || payload?.[0]?.payload.total === 0) { + return null; + } + + return ( + +
+ +
+
+ + All + + Total +
+
+ + {payload[0]?.payload?.total} + +
+
+
+
+ } + className="rounded-lg shadow-lg border border-gray-4" + labelFormatter={(_, tooltipPayload) => { + const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; + return originalTimestamp ? ( +
+ + {formatTimestampTooltip(originalTimestamp)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + {Object.keys(config).map((key) => ( + + ))} + {enableSelection && selection.start && selection.end && ( + + )} + + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/logs/components/charts/utils/calculate-timepoints.ts b/apps/dashboard/components/logs/chart/utils/calculate-timepoints.ts similarity index 100% rename from apps/dashboard/app/(app)/logs/components/charts/utils/calculate-timepoints.ts rename to apps/dashboard/components/logs/chart/utils/calculate-timepoints.ts diff --git a/apps/dashboard/app/(app)/logs/components/charts/utils/convert-date-to-local.ts b/apps/dashboard/components/logs/chart/utils/convert-date-to-local.ts similarity index 100% rename from apps/dashboard/app/(app)/logs/components/charts/utils/convert-date-to-local.ts rename to apps/dashboard/components/logs/chart/utils/convert-date-to-local.ts diff --git a/apps/dashboard/app/(app)/logs/components/charts/utils/format-timestamp.ts b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts similarity index 100% rename from apps/dashboard/app/(app)/logs/components/charts/utils/format-timestamp.ts rename to apps/dashboard/components/logs/chart/utils/format-timestamp.ts diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx similarity index 91% rename from apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx rename to apps/dashboard/components/logs/checkbox/filter-checkbox.tsx index 7ca9faaada..508451c3be 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx +++ b/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx @@ -4,7 +4,8 @@ import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Button } from "@unkey/ui"; import { useCallback } from "react"; -import { useCheckboxState } from "./hooks/use-checkbox-state"; +import type { FilterValue } from "../validation/filter.types"; +import { useCheckboxState } from "./hooks"; export type BaseCheckboxOption = { id: number; @@ -12,7 +13,10 @@ export type BaseCheckboxOption = { [key: string]: any; }; -interface BaseCheckboxFilterProps { +interface BaseCheckboxFilterProps< + TCheckbox extends BaseCheckboxOption, + TFilter extends FilterValue, +> { options: TCheckbox[]; filterField: "methods" | "paths" | "status"; checkPath: string; @@ -21,10 +25,10 @@ interface BaseCheckboxFilterProps { scrollContainerRef?: React.RefObject; renderBottomGradient?: () => React.ReactNode; renderOptionContent?: (option: TCheckbox) => React.ReactNode; - createFilterValue: (option: TCheckbox) => Pick; + createFilterValue: (option: TCheckbox) => Pick; } -export const FilterCheckbox = ({ +export const FilterCheckbox = ({ options, filterField, checkPath, @@ -34,7 +38,7 @@ export const FilterCheckbox = ({ createFilterValue, scrollContainerRef, renderBottomGradient, -}: BaseCheckboxFilterProps) => { +}: BaseCheckboxFilterProps) => { const { filters, updateFilters } = useFilters(); const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ options, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/components/logs/checkbox/filters-popover.tsx similarity index 71% rename from apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx rename to apps/dashboard/components/logs/checkbox/filters-popover.tsx index 1e4eed217e..0f10144799 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx +++ b/apps/dashboard/components/logs/checkbox/filters-popover.tsx @@ -4,43 +4,34 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { CaretRight } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; -import { useFilters } from "../../../../../hooks/use-filters"; -import { IdentifiersFilter } from "./identifiers-filter"; -import { StatusFilter } from "./status-filter"; +import type { FilterValue } from "../validation/filter.types"; -type FilterItemConfig = { +export type FilterItemConfig = { id: string; label: string; shortcut?: string; component: React.ReactNode; }; -const FILTER_ITEMS: FilterItemConfig[] = [ - { - id: "status", - label: "Status", - shortcut: "e", - component: , - }, - { - id: "identifiers", - label: "Identifier", - shortcut: "p", - component: , - }, -]; - -export const FiltersPopover = ({ children }: PropsWithChildren) => { +type FiltersPopoverProps = { + items: FilterItemConfig[]; + activeFilters: FilterValue[]; + getFilterCount?: (field: string) => number; +}; + +export const FiltersPopover = ({ + children, + items, + activeFilters, + getFilterCount = (field) => activeFilters.filter((f) => f.field === field).length, +}: PropsWithChildren) => { const [open, setOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(null); 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 + // biome-ignore lint/correctness/useExhaustiveDependencies: no need useEffect(() => { - return () => { - setActiveFilter(null); - }; + return () => setActiveFilter(null); }, [open]); useKeyboardShortcut("f", () => { @@ -52,7 +43,6 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { return; } - // Don't handle navigation in main popover if we have an active filter if (activeFilter) { if (e.key === "ArrowLeft" || e.key === "h") { e.preventDefault(); @@ -65,15 +55,13 @@ 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) % items.length)); break; case "ArrowUp": case "k": e.preventDefault(); setFocusedIndex((prev) => - prev === null - ? FILTER_ITEMS.length - 1 - : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, + prev === null ? items.length - 1 : (prev - 1 + items.length) % items.length, ); break; case "Enter": @@ -81,16 +69,12 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { case "ArrowRight": e.preventDefault(); if (focusedIndex !== null) { - const selectedFilter = FILTER_ITEMS[focusedIndex]; + const selectedFilter = items[focusedIndex]; if (selectedFilter) { setActiveFilter(selectedFilter.id); } } break; - case "h": - case "ArrowLeft": - // Don't handle left arrow in main popover - let it bubble to FilterItem - break; } }; @@ -104,10 +88,11 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { >
- {FILTER_ITEMS.map((item, index) => ( + {items.map((item, index) => ( @@ -118,29 +103,27 @@ export const FiltersPopover = ({ children }: PropsWithChildren) => { ); }; -const PopoverHeader = () => { - return ( -
- Filters... - -
- ); -}; +const PopoverHeader = () => ( +
+ Filters... + +
+); type FilterItemProps = FilterItemConfig & { isFocused?: boolean; isActive?: boolean; + filterCount: number; }; -export const FilterItem = ({ +const FilterItem = ({ label, shortcut, - id, component, isFocused, isActive, + filterCount, }: FilterItemProps) => { - const { filters } = useFilters(); const [open, setOpen] = useState(false); const itemRef = useRef(null); const contentRef = useRef(null); @@ -153,28 +136,21 @@ export const FilterItem = ({ } }; - useKeyboardShortcut( - { key: shortcut || "", meta: true }, - () => { - setOpen(true); - }, - { preventDefault: true }, - ); + useKeyboardShortcut({ key: shortcut || "", meta: true }, () => setOpen(true), { + preventDefault: true, + }); - // Focus the element when isFocused changes useEffect(() => { if (isFocused && itemRef.current) { itemRef.current.focus(); } }, [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"])', ); @@ -210,9 +186,9 @@ export const FilterItem = ({ {label}
- {filters.filter((filter) => filter.field === id).length > 0 && ( + {filterCount > 0 && (
- {filters.filter((filter) => filter.field === id).length} + {filterCount}
)} +
+
+ ); +}; diff --git a/apps/dashboard/components/logs/control-cloud/index.tsx b/apps/dashboard/components/logs/control-cloud/index.tsx new file mode 100644 index 0000000000..9078482441 --- /dev/null +++ b/apps/dashboard/components/logs/control-cloud/index.tsx @@ -0,0 +1,154 @@ +import { KeyboardButton } from "@/components/keyboard-button"; +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { type KeyboardEvent, useCallback, useState } from "react"; +import type { FilterValue } from "../validation/filter.types"; +import { ControlPill } from "./control-pill"; +import { defaultFormatValue } from "./utils"; + +type ControlCloudProps = { + filters: TFilter[]; + removeFilter: (id: string) => void; + updateFilters: (filters: TFilter[]) => void; + formatFieldName: (field: string) => string; + formatValue?: (value: string | number, field: string) => string; + historicalWindow?: number; +}; + +export const ControlCloud = ({ + filters, + removeFilter, + updateFilters, + formatFieldName, + formatValue = defaultFormatValue, + historicalWindow = 12 * 60 * 60 * 1000, +}: ControlCloudProps) => { + const [focusedIndex, setFocusedIndex] = useState(null); + + useKeyboardShortcut({ key: "d", meta: true }, () => { + const timestamp = Date.now(); + updateFilters([ + { + field: "endTime", + value: timestamp, + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "startTime", + value: timestamp - historicalWindow, + id: crypto.randomUUID(), + operator: "is", + }, + ] as TFilter[]); + }); + + useKeyboardShortcut({ key: "c", meta: true }, () => { + setFocusedIndex(0); + }); + + const handleRemoveFilter = useCallback( + (id: string) => { + removeFilter(id); + if (focusedIndex !== null) { + if (focusedIndex >= filters.length - 1) { + setFocusedIndex(Math.max(filters.length - 2, 0)); + } + } + }, + [removeFilter, filters.length, focusedIndex], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (filters.length === 0) { + return; + } + + const findNextInDirection = (direction: "up" | "down") => { + if (focusedIndex === null) { + return 0; + } + + const buttons = document.querySelectorAll("[data-pill-index] button"); + const currentButton = buttons[focusedIndex] as HTMLElement; + if (!currentButton) { + return focusedIndex; + } + + const currentRect = currentButton.getBoundingClientRect(); + let closestDistance = Number.POSITIVE_INFINITY; + let closestIndex = focusedIndex; + + buttons.forEach((button, index) => { + const rect = button.getBoundingClientRect(); + const isAbove = direction === "up" && rect.bottom < currentRect.top; + const isBelow = direction === "down" && rect.top > currentRect.bottom; + + if (isAbove || isBelow) { + const horizontalDistance = Math.abs(rect.left - currentRect.left); + if (horizontalDistance < closestDistance) { + closestDistance = horizontalDistance; + closestIndex = index; + } + } + }); + + return closestIndex; + }; + + switch (e.key) { + case "ArrowRight": + case "l": + e.preventDefault(); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % filters.length)); + break; + case "ArrowLeft": + case "h": + e.preventDefault(); + setFocusedIndex((prev) => + prev === null ? filters.length - 1 : (prev - 1 + filters.length) % filters.length, + ); + break; + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex(findNextInDirection("down")); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex(findNextInDirection("up")); + break; + } + }; + + if (filters.length === 0) { + return null; + } + + return ( +
+ {filters.map((filter, index) => ( + setFocusedIndex(index)} + index={index} + formatFieldName={formatFieldName} + formatValue={formatValue} + /> + ))} +
+ Clear filters + +
+ Focus filters + +
+
+ ); +}; diff --git a/apps/dashboard/components/logs/control-cloud/utils.ts b/apps/dashboard/components/logs/control-cloud/utils.ts new file mode 100644 index 0000000000..b08d30c4dc --- /dev/null +++ b/apps/dashboard/components/logs/control-cloud/utils.ts @@ -0,0 +1,15 @@ +import { format } from "date-fns"; + +export const defaultFormatValue = (value: string | number, field: string): string => { + if (typeof value === "number" && (field === "startTime" || field === "endTime")) { + return format(value, "MMM d, yyyy HH:mm:ss"); + } + return String(value); +}; + +export const formatOperator = (operator: string, field: string): string => { + if (field === "since" && operator === "is") { + return "Last"; + } + return operator; +}; diff --git a/apps/dashboard/components/logs/live-switch-button/index.tsx b/apps/dashboard/components/logs/live-switch-button/index.tsx new file mode 100644 index 0000000000..e418ce7b4b --- /dev/null +++ b/apps/dashboard/components/logs/live-switch-button/index.tsx @@ -0,0 +1,35 @@ +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { CircleCarretRight } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; + +type LiveSwitchProps = { + isLive: boolean; + onToggle: () => void; +}; + +export const LiveSwitchButton = ({ isLive, onToggle }: LiveSwitchProps) => { + useKeyboardShortcut({ meta: true, key: "l" }, onToggle); + + return ( + + ); +}; diff --git a/apps/dashboard/components/logs/llm-search/index.tsx b/apps/dashboard/components/logs/llm-search/index.tsx new file mode 100644 index 0000000000..d6a8f20337 --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/index.tsx @@ -0,0 +1,147 @@ +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { cn } from "@/lib/utils"; +import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3, XMark } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; +import { useRef, useState } from "react"; + +type Props = { + onSearch: (query: string) => void; + isLoading: boolean; +}; +export const LogsLLMSearch = ({ onSearch, isLoading }: Props) => { + const [searchText, setSearchText] = useState(""); + const inputRef = useRef(null); + + useKeyboardShortcut("s", () => { + inputRef.current?.click(); + inputRef.current?.focus(); + }); + + const handleSearch = async (search: string) => { + const query = search.trim(); + if (query) { + try { + onSearch(query); + } catch (error) { + console.error("Search failed:", error); + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + (document.activeElement as HTMLElement)?.blur(); + } + if (e.key === "Enter") { + e.preventDefault(); + handleSearch(searchText); + } + }; + + const handlePresetQuery = (query: string) => { + setSearchText(query); + handleSearch(query); + }; + + return ( +
+
0 ? "bg-gray-4" : "", + isLoading ? "bg-gray-4" : "", + )} + > +
+
+ {isLoading ? ( + + ) : ( + + )} +
+ +
+ {isLoading ? ( +
+ AI consults the Palantír... +
+ ) : ( + setSearchText(e.target.value)} + placeholder="Search and filter with AI…" + className="text-accent-12 font-medium text-[13px] bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12 selection:bg-gray-6 w-full" + disabled={isLoading} + /> + )} +
+
{" "} + + + {searchText.length > 0 && !isLoading && ( + + )} + + {searchText.length === 0 && !isLoading && ( +
+ +
+ )} +
+ +
+
+ Try queries like: + (click to use) +
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/components/logs/refresh-button/index.tsx b/apps/dashboard/components/logs/refresh-button/index.tsx new file mode 100644 index 0000000000..d3b9176150 --- /dev/null +++ b/apps/dashboard/components/logs/refresh-button/index.tsx @@ -0,0 +1,65 @@ +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { Refresh3 } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useState } from "react"; + +type RefreshButtonProps = { + onRefresh: () => void; + isEnabled: boolean; + isLive: boolean; + toggleLive: (value: boolean) => void; +}; + +const REFRESH_TIMEOUT_MS = 1000; + +export const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: RefreshButtonProps) => { + const [isLoading, setIsLoading] = useState(false); + const [refreshTimeout, setRefreshTimeout] = useState(null); + + useKeyboardShortcut("r", () => { + isEnabled && handleRefresh(); + }); + + const handleRefresh = () => { + if (isLoading) { + return; + } + + const isLiveBefore = Boolean(isLive); + setIsLoading(true); + toggleLive(false); + onRefresh(); + + if (refreshTimeout) { + clearTimeout(refreshTimeout); + } + + const timeout = setTimeout(() => { + setIsLoading(false); + if (isLiveBefore) { + toggleLive(true); + } + }, REFRESH_TIMEOUT_MS); + setRefreshTimeout(timeout); + }; + + return ( + + ); +}; diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index c2f3c554ff..0600a94a4d 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -129,7 +129,10 @@ export function VirtualTable({ -
+
+ {/* inset-x-[-8px] removes padding from divider */} +
+
diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search.ts deleted file mode 100644 index ad16864baa..0000000000 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { METHODS } from "@/app/(app)/logs/constants"; -import { filterOutputSchema, logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; -import { env } from "@/lib/env"; -import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; -import { TRPCError } from "@trpc/server"; -import OpenAI from "openai"; -import { zodResponseFormat } from "openai/helpers/zod"; -import { z } from "zod"; - -const openai = env().OPENAI_API_KEY - ? new OpenAI({ - apiKey: env().OPENAI_API_KEY, - }) - : null; - -async function getStructuredSearchFromLLM(userSearchMsg: string) { - 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", - 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 - presence_penalty: 0.5, // Range -2 to 2, higher = more topic diversity - n: 1, // Number of completions to generate - messages: [ - { - role: "system", - content: getSystemPrompt(), - }, - { - role: "user", - content: userSearchMsg, - }, - ], - response_format: zodResponseFormat(filterOutputSchema, "searchQuery"), - }); - - if (!completion.choices[0].message.parsed) { - throw new TRPCError({ - code: "UNPROCESSABLE_CONTENT", - message: - "Try using phrases like:\n" + - "• 'find all POST requests'\n" + - "• 'show requests with status 404'\n" + - "• 'find requests to api/v1'\n" + - "• 'show requests from test.example.com'\n" + - "• 'find all GET and POST requests'\n" + - "For additional help, contact support@unkey.dev", - }); - } - - return completion.choices[0].message.parsed; - } catch (error) { - console.error( - `Something went wrong when querying OpenAI. Input: ${JSON.stringify( - userSearchMsg, - )}\n Output ${(error as Error).message}}`, - ); - if (error instanceof TRPCError) { - throw error; - } - - if ((error as any).response?.status === 429) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "Search rate limit exceeded. Please try again in a few minutes.", - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to process your search query. Please try again or contact support@unkey.dev if the issue persists.", - }); - } -} - -export const llmSearch = rateLimitedProcedure(ratelimit.update) - .input(z.string()) - .mutation(async ({ input }) => { - return await getStructuredSearchFromLLM(input); - }); - -// HELPERS - -const getSystemPrompt = () => { - const operatorsByField = Object.entries(logsFilterFieldConfig) - .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"; - } - - 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. Examples: - -Query: "path should start with /api/oz and method should be POST" -Result: [ - { - field: "paths", - filters: [{ operator: "startsWith", value: "/api/oz" }] - }, - { - field: "methods", - filters: [{ operator: "is", value: "POST" }] - } -] - -Query: "find POST and GET requests to api/v1" -Result: [ - { - field: "paths", - filters: [{ operator: "startsWith", value: "api/v1" }] - }, - { - field: "methods", - filters: [ - { operator: "is", value: "POST" }, - { operator: "is", value: "GET" } - ] - } -] - -Query: "show 404 requests from test.example.com" -Result: [ - { - field: "host", - filters: [{ operator: "is", value: "test.example.com" }] - }, - { - field: "status", - filters: [{ operator: "is", value: 404 }] - } -] - -Query: "find all POST requests" -Result: [ - { - field: "methods", - filters: [{ operator: "is", value: "POST" }] - } -] - -Remember: -${operatorsByField}`; -};