diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx b/apps/dashboard/app/(app)/audit/components/filters/datepicker-with-range.tsx similarity index 99% rename from apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx rename to apps/dashboard/app/(app)/audit/components/filters/datepicker-with-range.tsx index 67e9486d85..65a629e82f 100644 --- a/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx +++ b/apps/dashboard/app/(app)/audit/components/filters/datepicker-with-range.tsx @@ -8,7 +8,7 @@ import { ArrowRight, Calendar as CalendarIcon } from "lucide-react"; import { parseAsInteger, useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; import type { DateRange } from "react-day-picker"; -import TimeSplitInput from "./time-split"; +import { TimeSplitInput } from "./timesplit-input"; interface DatePickerWithRangeProps extends React.HTMLAttributes { initialParams: { diff --git a/apps/dashboard/app/(app)/audit/components/filters/index.tsx b/apps/dashboard/app/(app)/audit/components/filters/index.tsx index 39ae9c9813..8d63ee3222 100644 --- a/apps/dashboard/app/(app)/audit/components/filters/index.tsx +++ b/apps/dashboard/app/(app)/audit/components/filters/index.tsx @@ -1,4 +1,3 @@ -import { DatePickerWithRange } from "@/app/(app)/logs/components/filters/components/custom-date-filter"; import { DEFAULT_BUCKET_NAME } from "@/lib/trpc/routers/audit/fetch"; import type { auditLogBucket, workspaces } from "@unkey/db/src/schema"; import { unkeyAuditLogEvents } from "@unkey/schema/src/auditlog"; @@ -7,6 +6,7 @@ import { Suspense } from "react"; import type { ParsedParams } from "../../actions"; import { BucketSelect } from "./bucket-select"; import { ClearButton } from "./clear-button"; +import { DatePickerWithRange } from "./datepicker-with-range"; import { Filter } from "./filter"; import { RootKeyFilter } from "./root-key-filter"; import { UserFilter } from "./user-filter"; diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx b/apps/dashboard/app/(app)/audit/components/filters/timesplit-input.tsx similarity index 99% rename from apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx rename to apps/dashboard/app/(app)/audit/components/filters/timesplit-input.tsx index e5129844ee..7402c6a458 100644 --- a/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx +++ b/apps/dashboard/app/(app)/audit/components/filters/timesplit-input.tsx @@ -24,7 +24,7 @@ export interface TimeSplitInputProps { endDate: Date; } -const TimeSplitInput = ({ +export const TimeSplitInput = ({ type, time, setTime, @@ -272,5 +272,3 @@ const TimeSplitInput = ({ ); }; - -export default TimeSplitInput; diff --git a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx b/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx index 0263fbbf17..fea542f386 100644 --- a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx @@ -12,6 +12,32 @@ import { LogDetails } from "./table-details"; import type { Data } from "./types"; import { getEventType } from "./utils"; +const STATUS_STYLES: Record< + "create" | "update" | "delete" | "other", + { base: string; hover: string; selected: string } +> = { + create: { + base: "text-accent-11 ", + hover: "hover:bg-accent-3", + selected: "bg-accent-3", + }, + other: { + base: "text-accent-11 ", + hover: "hover:bg-accent-3", + selected: "bg-accent-3", + }, + update: { + base: "text-warning-11 ", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + }, + delete: { + base: "text-error-11", + hover: "hover:bg-error-3", + selected: "bg-error-3", + }, +}; + export const AuditLogTableClient = () => { const [selectedLog, setSelectedLog] = useState(null); const { setCursor, searchParams } = useAuditLogParams(); @@ -28,9 +54,7 @@ export const AuditLogTableClient = () => { endTime: searchParams.endTime, }, { - getNextPageParam: (lastPage) => { - return lastPage.nextCursor; - }, + getNextPageParam: (lastPage) => lastPage.nextCursor, initialCursor: searchParams.cursor, staleTime: Number.POSITIVE_INFINITY, refetchOnMount: false, @@ -42,38 +66,37 @@ export const AuditLogTableClient = () => { const handleLoadMore = () => { if (hasNextPage && !isFetchingNextPage && data?.pages.length) { - // Get the current last page before fetching next const currentLastPage = data.pages[data.pages.length - 1]; - fetchNextPage().then(() => { - // Set the cursor to the last page we had before fetching if (currentLastPage.nextCursor) { setCursor(currentLastPage.nextCursor); } }); } }; + const getRowClassName = (item: Data) => { const eventType = getEventType(item.auditLog.event); - return cn({ - "hover:bg-error-3": eventType === "delete", - "hover:bg-warning-3": eventType === "update", - "hover:bg-success-3": eventType === "create", - }); + const style = STATUS_STYLES[eventType]; + + return cn( + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40 px-1", + selectedLog && { + "opacity-50 z-0": selectedLog.auditLog.id !== item.auditLog.id, + "opacity-100 z-10": selectedLog.auditLog.id === item.auditLog.id, + }, + ); }; const getSelectedClassName = (item: Data, isSelected: boolean) => { if (!isSelected) { return ""; } - - const eventType = getEventType(item.auditLog.event); - return cn({ - "bg-error-3": eventType === "delete", - "bg-warning-3": eventType === "update", - "bg-success-3": eventType === "create", - "bg-accent-3": eventType === "other", - }); + const style = STATUS_STYLES[getEventType(item.auditLog.event)]; + return style.selected; }; if (isError) { diff --git a/apps/dashboard/app/(app)/audit/components/table/columns.tsx b/apps/dashboard/app/(app)/audit/components/table/columns.tsx index f8f9df6c16..5ff67689f5 100644 --- a/apps/dashboard/app/(app)/audit/components/table/columns.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/columns.tsx @@ -10,10 +10,11 @@ export const columns: Column[] = [ { key: "time", header: "Time", - headerClassName: "pl-3", width: "150px", + headerClassName: "pl-3", + noTruncate: true, render: (log) => ( -
+
[] = [ { key: "actor", header: "Actor", + width: "15%", headerClassName: "pl-3", - width: "7%", + noTruncate: true, render: (log) => ( -
+
{log.auditLog.actor.type === "user" && log.user ? (
- {`${log.user.firstName ?? ""} ${ - log.user.lastName ?? "" - }`} + + {`${log.user.firstName ?? ""} ${log.user.lastName ?? ""}`} +
) : log.auditLog.actor.type === "key" ? (
- {log.auditLog.actor.id} + {log.auditLog.actor.id}
) : (
- {log.auditLog.actor.id} + {log.auditLog.actor.id}
)}
@@ -51,8 +53,9 @@ export const columns: Column[] = [ { key: "action", header: "Action", + width: "15%", headerClassName: "pl-3", - width: "7%", + noTruncate: true, render: (log) => { const eventType = getEventType(log.auditLog.event); const badgeClassName = cn("font-mono capitalize", { @@ -62,7 +65,7 @@ export const columns: Column[] = [ "bg-accent-3 text-accent-11 hover:bg-accent-4": eventType === "other", }); return ( -
+
{eventType}
); @@ -71,10 +74,9 @@ export const columns: Column[] = [ { key: "event", header: "Event", - headerClassName: "pl-2", width: "20%", render: (log) => ( -
+
{log.auditLog.event}
), @@ -82,10 +84,11 @@ export const columns: Column[] = [ { key: "event-description", header: "Description", - headerClassName: "pl-1", width: "auto", render: (log) => ( -
{log.auditLog.description}
+
+ {log.auditLog.description} +
), }, ]; diff --git a/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx b/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx index 11097a0bea..ac2540eea7 100644 --- a/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx @@ -81,7 +81,7 @@ export const LogFooter = ({ log }: Props) => { { label: "Description", description: (content) => ( - {content} + {content} ), content: log.auditLog.description, tooltipContent: "Copy Description", diff --git a/apps/dashboard/app/(app)/audit/components/table/log-header.tsx b/apps/dashboard/app/(app)/audit/components/table/log-header.tsx index e056d086a3..05d31374f1 100644 --- a/apps/dashboard/app/(app)/audit/components/table/log-header.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/log-header.tsx @@ -1,6 +1,6 @@ import { Badge } from "@/components/ui/badge"; +import { XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { X } from "lucide-react"; import type { Data } from "./types"; type Props = { @@ -10,16 +10,19 @@ type Props = { export const LogHeader = ({ onClose, log }: Props) => { return ( -
-
- +
+
+ {log.auditLog.event}
-
- + +
+
+ +
); diff --git a/apps/dashboard/app/(app)/audit/components/table/table-details.tsx b/apps/dashboard/app/(app)/audit/components/table/table-details.tsx index 6f3ad97f98..29e91188c1 100644 --- a/apps/dashboard/app/(app)/audit/components/table/table-details.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/table-details.tsx @@ -1,9 +1,8 @@ "use client"; import { LogSection } from "@/app/(app)/logs/components/table/log-details/components/log-section"; -import { memo, useMemo, useState } from "react"; -import { useDebounceCallback } from "usehooks-ts"; -import ResizablePanel from "../../../logs/components/table/log-details/resizable-panel"; +import { ResizablePanel } from "@/app/(app)/logs/components/table/log-details/resizable-panel"; +import { useMemo } from "react"; import { LogFooter } from "./log-footer"; import { LogHeader } from "./log-header"; import type { Data } from "./types"; @@ -14,25 +13,18 @@ type Props = { distanceToTop: number; }; -const DEFAULT_DRAGGABLE_WIDTH = 450; -const PANEL_WIDTH_SET_DELAY = 150; +const PANEL_MAX_WIDTH = 600; +const PANEL_MIN_WIDTH = 400; -const _LogDetails = ({ log, onClose, distanceToTop }: Props) => { - const [panelWidth, setPanelWidth] = useState(DEFAULT_DRAGGABLE_WIDTH); +const createPanelStyle = (distanceToTop: number) => ({ + top: `${distanceToTop}px`, + width: "500px", + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", +}); - const debouncedSetPanelWidth = useDebounceCallback((newWidth) => { - setPanelWidth(newWidth); - }, PANEL_WIDTH_SET_DELAY); - - const panelStyle = useMemo( - () => ({ - top: `${distanceToTop}px`, - width: `${panelWidth}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", - }), - [distanceToTop, panelWidth], - ); +export const LogDetails = ({ log, onClose, distanceToTop }: Props) => { + const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); if (!log) { return null; @@ -40,9 +32,10 @@ const _LogDetails = ({ log, onClose, distanceToTop }: Props) => { return ( @@ -61,8 +54,3 @@ const _LogDetails = ({ log, onClose, distanceToTop }: Props) => { ); }; - -export const LogDetails = memo( - _LogDetails, - (prev, next) => prev.log?.auditLog.id === next.log?.auditLog.id, -); diff --git a/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx deleted file mode 100644 index ab5a675205..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/charts/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { Grid } from "@unkey/icons"; -import { addMinutes, format } from "date-fns"; -import { useEffect, useRef } from "react"; -import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; -import { generateMockLogsData } from "./util"; - -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; - -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"); -}; - -const formatTimestampLabel = (timestamp: string | number | Date) => { - const date = new Date(timestamp); - return format(date, "MMM dd, h:mma").toUpperCase(); -}; - -type Timeseries = { - x: number; - displayX: string; - originalTimestamp: string; - success: number; - error: number; - warning: number; - total: number; -}; - -const calculateTimePoints = (timeseries: Timeseries[]) => { - const startTime = timeseries[0].x; - const endTime = timeseries.at(-1)?.x; - const timeRange = endTime ?? 0 - startTime; - const timePoints = Array.from({ length: 5 }, (_, i) => { - return new Date(startTime + (timeRange * i) / 5); - }); - return timePoints; -}; - -const timeseries = generateMockLogsData(24, 10); - -export function LogsChart({ - onMount, -}: { - onMount: (distanceToTop: number) => void; -}) { - const chartRef = useRef(null); - - useEffect(() => { - const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; - onMount(distanceToTop); - }, [onMount]); - - return ( -
-
- {calculateTimePoints(timeseries).map((time, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. -
- {formatTimestampLabel(time)} -
- ))} -
- - - - dataMax * 1.5]} hide /> - { - if (!active || !payload?.length) { - 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", "error", "warning"].map((key) => ( - - ))} - - - -
- ); -} diff --git a/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts b/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts deleted file mode 100644 index e8d0c2545f..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/charts/util.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Generates mock time series data for logs visualization - * @param hours Number of hours of data to generate - * @param intervalMinutes Interval between data points in minutes - * @returns Array of LogsTimeseriesDataPoint - */ -export function generateMockLogsData(hours = 24, intervalMinutes = 5) { - const now = new Date(); - const points = Math.floor((hours * 60) / intervalMinutes); - const data = []; - - for (let i = points - 1; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * intervalMinutes * 60 * 1000); - - const success = Math.floor(Math.random() * 50) + 20; - const error = Math.floor(Math.random() * 30); - const warning = Math.floor(Math.random() * 25); - - data.push({ - x: Math.floor(timestamp.getTime()), - displayX: timestamp.toISOString(), - originalTimestamp: timestamp.toISOString(), - success, - error, - warning, - total: success + error + warning, - }); - } - - return data; -} 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 deleted file mode 100644 index 939bf9644c..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; -import { useCallback, 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 -}; - -export const useCheckboxState = >({ - options, - filters, - filterField, - checkPath, -}: 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])), - })); - }); - - 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 = 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); - } - - // 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(); - } - }, - [handleToggle], - ); - - return { - checkboxes, - handleCheckboxChange, - handleSelectAll, - handleKeyDown, - }; -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx deleted file mode 100644 index 99d8544aef..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-footer.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; -import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs-v2/constants"; -import { extractResponseField, getRequestHeader } from "@/app/(app)/logs-v2/utils"; -import { TimestampInfo } from "@/components/timestamp-info"; -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; -import type { Log } from "@unkey/clickhouse/src/logs"; -import { RequestResponseDetails } from "./request-response-details"; - -type Props = { - log: Log; -}; - -const DEFAULT_OUTCOME = "VALID"; -export const LogFooter = ({ log }: Props) => { - return ( - ( - - ), - content: log.time, - tooltipContent: "Copy Time", - tooltipSuccessMessage: "Time copied to clipboard", - skipTooltip: true, - }, - { - label: "Host", - description: (content) => {content}, - content: log.host, - tooltipContent: "Copy Host", - tooltipSuccessMessage: "Host copied to clipboard", - }, - { - label: "Request Path", - description: (content) => {content}, - content: log.path, - tooltipContent: "Copy Request Path", - tooltipSuccessMessage: "Request path copied to clipboard", - }, - { - label: "Request ID", - description: (content) => {content}, - content: log.request_id, - tooltipContent: "Copy Request ID", - tooltipSuccessMessage: "Request ID copied to clipboard", - }, - { - label: "Request User Agent", - description: (content) => {content}, - content: getRequestHeader(log, "user-agent") ?? "", - tooltipContent: "Copy Request User Agent", - tooltipSuccessMessage: "Request user agent copied to clipboard", - }, - { - label: "Outcome", - description: (content) => { - let contentCopy = content; - if (contentCopy == null) { - contentCopy = DEFAULT_OUTCOME; - } - return ( - - {content} - - ); - }, - content: extractResponseField(log, "code"), - tooltipContent: "Copy Outcome", - tooltipSuccessMessage: "Outcome copied to clipboard", - }, - { - label: "Permissions", - description: (content) => ( - - {content.map((permission) => ( - - {permission} - - ))} - - ), - content: extractResponseField(log, "permissions"), - tooltipContent: "Copy Permissions", - tooltipSuccessMessage: "Permissions copied to clipboard", - }, - ]} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx deleted file mode 100644 index 006bbaff72..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-header.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; -import type { Log } from "@unkey/clickhouse/src/logs"; -import { XMark } from "@unkey/icons"; -import { Button } from "@unkey/ui"; - -type Props = { - log: Log; - onClose: () => void; -}; - -export const LogHeader = ({ onClose, log }: Props) => { - return ( -
-
- - {log.method} - -

{log.path}

-
- -
-
- = 200 && log.response_status < 300, - "bg-warning-3 text-warning-11 hover:bg-warning-4": - log.response_status >= 400 && log.response_status < 500, - "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, - })} - > - {log.response_status} - - | - -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx deleted file mode 100644 index 7c2a533a76..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-meta.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { toast } from "@/components/ui/toaster"; -import { Button } from "@unkey/ui"; -import { Copy } from "lucide-react"; - -export const LogMetaSection = ({ content }: { content: string }) => { - const handleClick = () => { - navigator.clipboard - .writeText(content) - .then(() => { - toast.success("Meta copied to clipboard"); - }) - .catch((error) => { - console.error("Failed to copy to clipboard:", error); - toast.error("Failed to copy to clipboard"); - }); - }; - - return ( -
-
Meta
- - -
{content}
- -
-
-
- ); -}; 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 deleted file mode 100644 index d51eee5539..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/log-section.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Card, CardContent } from "@/components/ui/card"; -import { toast } from "@/components/ui/toaster"; -import { Button } from "@unkey/ui"; -import { Copy } from "lucide-react"; - -export const LogSection = ({ - details, - title, -}: { - details: string | string[]; - title: string; -}) => { - const handleClick = () => { - navigator.clipboard - .writeText(getFormattedContent(details)) - .then(() => { - toast.success(`${title} copied to clipboard`); - }) - .catch((error) => { - console.error("Failed to copy to clipboard:", error); - toast.error("Failed to copy to clipboard"); - }); - }; - - return ( -
-
- {title} -
- - -
-            {Array.isArray(details)
-              ? details.map((header) => {
-                  const [key, ...valueParts] = header.split(":");
-                  const value = valueParts.join(":").trim();
-                  return (
-                    
- {key}: - {value} -
- ); - }) - : details} -
- -
-
-
- ); -}; - -const getFormattedContent = (details: string | string[]) => { - if (Array.isArray(details)) { - return details - .map((header) => { - const [key, ...valueParts] = header.split(":"); - const value = valueParts.join(":").trim(); - return `${key}: ${value}`; - }) - .join("\n"); - } - return details; -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx deleted file mode 100644 index 15869e7d80..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/components/request-response-details.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { ReactNode } from "react"; - -type Field = { - label: string; - description: (content: NonNullable) => ReactNode; - content: T | null; - tooltipContent?: ReactNode; - tooltipSuccessMessage?: string; - className?: string; - skipTooltip?: boolean; -}; - -type Props = { - fields: { [K in keyof T]: Field }; - className?: string; -}; - -const isNonEmpty = (content: unknown): boolean => { - if (content === undefined || content === null) { - return false; - } - - if (Array.isArray(content)) { - return content.some((item) => item !== null && item !== undefined); - } - - if (typeof content === "object" && content !== null) { - return Object.values(content).some((value) => value !== null && value !== undefined); - } - - if (typeof content === "string") { - return content.trim().length > 0; - } - - return Boolean(content); -}; - -export const RequestResponseDetails = ({ fields, className }: Props) => { - const handleClick = (field: Field) => { - try { - const text = - typeof field.content === "object" ? JSON.stringify(field.content) : String(field.content); - navigator.clipboard - .writeText(text) - .then(() => { - if (field.tooltipSuccessMessage) { - toast.success(field.tooltipSuccessMessage); - } - }) - .catch((error) => { - console.error("Failed to copy to clipboard:", error); - toast.error("Failed to copy to clipboard"); - }); - } catch (error) { - console.error("Error preparing content for clipboard:", error); - toast.error("Failed to prepare content for clipboard"); - } - }; - - const renderField = (field: Field, index: number) => { - const baseContent = ( - // biome-ignore lint/a11y/useKeyWithClickEvents: no need -
handleClick(field) : undefined} - > - {field.label} - - {field.description(field.content as NonNullable)} - -
- ); - - if (field.skipTooltip) { - return baseContent; - } - - return ( - - - {baseContent} - {field.tooltipContent} - - - ); - }; - - return ( -
- {fields.map( - (field, index) => - isNonEmpty(field.content) && ( -
{renderField(field, index)}
- ), - )} -
- ); -}; 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 deleted file mode 100644 index 5049d1d563..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -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"; -import { LogMetaSection } from "./components/log-meta"; -import { LogSection } from "./components/log-section"; -import ResizablePanel from "./resizable-panel"; - -const PANEL_MAX_WIDTH = 600; -const PANEL_MIN_WIDTH = 400; - -const createPanelStyle = (distanceToTop: number) => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); - -type Props = { - distanceToTop: number; -}; - -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 ( - - - - - - - -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx deleted file mode 100644 index a79d1063a1..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/log-details/resizable-panel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type React from "react"; -import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; -import { useOnClickOutside } from "usehooks-ts"; -import { MAX_DRAGGABLE_WIDTH, MIN_DRAGGABLE_WIDTH } from "../../../constants"; - -const ResizablePanel = ({ - children, - onResize, - onClose, - className, - style, - minW = MIN_DRAGGABLE_WIDTH, - maxW = MAX_DRAGGABLE_WIDTH, -}: PropsWithChildren<{ - onResize?: (newWidth: number) => void; - onClose: () => void; - className: string; - style: Record; - minW?: number; - maxW?: number; -}>) => { - const [isDragging, setIsDragging] = useState(false); - const [width, setWidth] = useState(String(style?.width)); - const panelRef = useRef(null); - - useOnClickOutside(panelRef, onClose); - - const handleMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleMouseUp = useCallback(() => { - setIsDragging(false); - }, []); - - const handleMouseMove = useCallback( - (e: MouseEvent) => { - if (!isDragging || !panelRef.current) { - return; - } - - const containerRect = panelRef.current.getBoundingClientRect(); - const newWidth = Math.min(Math.max(containerRect.right - e.clientX, minW), maxW); - setWidth(`${newWidth}px`); - onResize?.(newWidth); - }, - [isDragging, minW, maxW, onResize], - ); - - useEffect(() => { - if (isDragging) { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - } else { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - } - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isDragging, handleMouseMove, handleMouseUp]); - - return ( -
-
- {children} -
- ); -}; - -export default ResizablePanel; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx deleted file mode 100644 index 1e5e92a2e8..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/logs-table.tsx +++ /dev/null @@ -1,240 +0,0 @@ -"use client"; - -import { TimestampInfo } from "@/components/timestamp-info"; -import { Badge } from "@/components/ui/badge"; -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 { useMemo } from "react"; -import { isDisplayProperty, useLogsContext } from "../../context/logs"; -import { generateMockLogs } from "./utils"; - -const logs = generateMockLogs(50); - -type StatusStyle = { - base: string; - hover: string; - selected: string; - badge: { - default: string; - selected: string; - }; - focusRing: string; -}; - -const STATUS_STYLES = { - success: { - base: "text-accent-9", - hover: "hover:text-accent-11 dark:hover:text-accent-12", - selected: "text-accent-11 bg-accent-3 dark:text-accent-12", - badge: { - default: "bg-accent-4 text-accent-11 group-hover:bg-accent-5", - selected: "bg-accent-5 text-accent-12 hover:bg-hover-5", - }, - focusRing: "focus:ring-accent-7", - }, - warning: { - base: "text-warning-11 bg-warning-2", - hover: "hover:bg-warning-3", - selected: "bg-warning-3", - badge: { - default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", - selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", - }, - focusRing: "focus:ring-warning-7", - }, - error: { - base: "text-error-11 bg-error-2", - hover: "hover:bg-error-3", - selected: "bg-error-3", - badge: { - default: "bg-error-4 text-error-11 group-hover:bg-error-5", - selected: "bg-error-5 text-error-11 hover:bg-error-5", - }, - focusRing: "focus:ring-error-7", - }, -}; - -const METHOD_BADGE = { - base: "uppercase px-[6px] rounded-md font-mono bg-accent-4 text-accent-11 group-hover:bg-accent-5 group-hover:text-accent-12", - selected: "bg-accent-5 text-accent-12", -}; - -const getStatusStyle = (status: number): StatusStyle => { - if (status >= 500) { - return STATUS_STYLES.error; - } - if (status >= 400) { - return STATUS_STYLES.warning; - } - return STATUS_STYLES.success; -}; - -const WARNING_ICON_STYLES = { - base: "size-3", - warning: "text-warning-11", - error: "text-error-11", -}; - -const getSelectedClassName = (log: Log, isSelected: boolean) => { - if (!isSelected) { - return ""; - } - const style = getStatusStyle(log.response_status); - return style.selected; -}; - -const WarningIcon = ({ status }: { status: number }) => ( - = 400 && status < 500 && WARNING_ICON_STYLES.warning, - status >= 500 && WARNING_ICON_STYLES.error, - )} - /> -); - -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); - const isSelected = selectedLog?.request_id === log.request_id; - - return cn( - style.base, - style.hover, - "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( - () => [ - { - key: "time", - header: "Time", - width: "165px", - headerClassName: "pl-9", - noTruncate: true, - render: (log) => ( -
- -
- ), - }, - { - key: "response_status", - header: "Status", - width: "78px", - noTruncate: true, - render: (log) => { - const style = getStatusStyle(log.response_status); - const isSelected = selectedLog?.request_id === log.request_id; - return ( - - {log.response_status} - - ); - }, - }, - { - key: "method", - header: "Method", - width: "78px", - noTruncate: true, - render: (log) => { - const isSelected = selectedLog?.request_id === log.request_id; - return ( - - {log.method} - - ); - }, - }, - { - key: "path", - header: "Path", - width: "15%", - render: (log) =>
{log.path}
, - }, - ], - [selectedLog?.request_id], - ); - - 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, displayProperties]); - - return ( - log.request_id} - rowClassName={getRowClassName} - selectedClassName={getSelectedClassName} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts b/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts deleted file mode 100644 index 6f737f806a..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/components/table/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -function generateMockLog(overrides = {}) { - // Helper function to generate random string - const generateRandomString = (length = 10) => { - return Math.random() - .toString(36) - .substring(2, length + 2); - }; - - // Helper function to generate random HTTP method - const generateRandomMethod = () => { - const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; - return methods[Math.floor(Math.random() * methods.length)]; - }; - - // Helper function to generate random path - const generateRandomPath = () => { - const paths = [ - "/api/v1/users", - "/api/v1/workspaces", - "/api/v1/logs", - "/api/v1/settings", - "/health", - "/metrics", - ]; - return paths[Math.floor(Math.random() * paths.length)]; - }; - - // Helper function to generate random headers - const generateRandomHeaders = () => { - const possibleHeaders = [ - "Content-Type: application/json", - "Authorization: Bearer token123", - "X-Request-ID: req123", - "Accept: application/json", - "User-Agent: Mozilla/5.0", - ]; - const numHeaders = Math.floor(Math.random() * 3) + 1; // 1-3 headers - return possibleHeaders.slice(0, numHeaders); - }; - - // Generate base mock log - const mockLog = { - request_id: `req_${generateRandomString(8)}`, - time: Math.floor(Date.now() / 1000), - workspace_id: `ws_${generateRandomString(8)}`, - host: `${generateRandomString(5)}.example.com`, - method: generateRandomMethod(), - path: generateRandomPath(), - request_headers: generateRandomHeaders(), - request_body: JSON.stringify({ data: generateRandomString() }), - response_status: Math.random() < 0.6 ? 200 : Math.random() < 0.5 ? 500 : 400, // 80% success rate - response_headers: generateRandomHeaders(), - response_body: JSON.stringify({ - keyId: "key_2Krf19pCiGx5UE29qJeBu7JpTzHk", - valid: true, - meta: { - hello: "world", - }, - enabled: true, - }), - error: Math.random() < 0.8 ? "" : "Internal Server Error", - service_latency: Math.floor(Math.random() * 1000), // 0-1000ms - }; - - // Apply any overrides - return { - ...mockLog, - ...overrides, - }; -} - -// Generate multiple logs -export const generateMockLogs = (count: number, overrides = {}) => { - return Array.from({ length: count }, () => generateMockLog(overrides)); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/constants.ts b/apps/dashboard/app/(app)/logs-v2/constants.ts deleted file mode 100644 index 81a1b65d7b..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const DEFAULT_DRAGGABLE_WIDTH = 500; -export const MAX_DRAGGABLE_WIDTH = 800; -export const MIN_DRAGGABLE_WIDTH = 300; - -export const ONE_DAY_MS = 24 * 60 * 60 * 1000; -export const DEFAULT_LOGS_FETCH_COUNT = 100; - -export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; -export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; - -export const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; -export const STATUSES = [200, 400, 500] as const; diff --git a/apps/dashboard/app/(app)/logs-v2/page.tsx b/apps/dashboard/app/(app)/logs-v2/page.tsx deleted file mode 100644 index 050b19d039..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use server"; - -import { Navbar } from "@/components/navbar"; -import { getTenantId } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { Layers3 } from "lucide-react"; -import { notFound } from "next/navigation"; -import { LogsClient } from "./components/logs-client"; - -export default async function Page() { - const tenantId = getTenantId(); - - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), - }); - - if (!workspace) { - return notFound(); - } - - return ; -} - -const LogsContainerPage = () => { - return ( -
- - }> - Logs - - - -
- ); -}; diff --git a/apps/dashboard/app/(app)/logs-v2/utils.ts b/apps/dashboard/app/(app)/logs-v2/utils.ts deleted file mode 100644 index f7713598f3..0000000000 --- a/apps/dashboard/app/(app)/logs-v2/utils.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { Log } from "@unkey/clickhouse/src/logs"; -import type { ResponseBody } from "../logs/types"; - -class ResponseBodyParseError extends Error { - constructor( - message: string, - public readonly context?: unknown, - ) { - super(message); - this.name = "ResponseBodyParseError"; - } -} - -export const extractResponseField = ( - log: Log, - fieldName: K, -): ResponseBody[K] | null => { - if (!log?.response_body) { - console.error("Invalid log or missing response_body"); - return null; - } - - try { - const parsedBody = JSON.parse(log.response_body) as ResponseBody; - - if (typeof parsedBody !== "object" || parsedBody === null) { - throw new ResponseBodyParseError("Parsed response body is not an object", parsedBody); - } - - if (!(fieldName in parsedBody)) { - throw new ResponseBodyParseError(`Field "${String(fieldName)}" not found in response body`, { - availableFields: Object.keys(parsedBody), - }); - } - - return parsedBody[fieldName]; - } catch (error) { - if (error instanceof ResponseBodyParseError) { - console.error(`Error parsing response body or accessing field: ${error.message}`, { - context: error.context, - fieldName, - logId: log.request_id, - }); - } else { - console.error("An unknown error occurred while parsing response body"); - } - return null; - } -}; - -export const getRequestHeader = (log: Log, headerName: string): string | null => { - if (!headerName.trim()) { - console.error("Invalid header name provided"); - return null; - } - - if (!Array.isArray(log.request_headers)) { - console.error("request_headers is not an array"); - return null; - } - - const lowerHeaderName = headerName.toLowerCase(); - const header = log.request_headers.find((h) => h.toLowerCase().startsWith(`${lowerHeaderName}:`)); - - if (!header) { - console.warn(`Header "${headerName}" not found in request headers`); - return null; - } - - const [, value] = header.split(":", 2); - return value ? value.trim() : null; -}; - -export const safeParseJson = (jsonString?: string | null) => { - if (!jsonString) { - return null; - } - - try { - return JSON.parse(jsonString); - } catch { - console.error("Cannot parse JSON:", jsonString); - return "Invalid JSON format"; - } -}; - -export const HOUR_IN_MS = 60 * 60 * 1000; -const DAY_IN_MS = 24 * HOUR_IN_MS; -const WEEK_IN_MS = 7 * DAY_IN_MS; - -export type TimeseriesGranularity = "perMinute" | "perHour" | "perDay"; -type TimeseriesConfig = { - granularity: TimeseriesGranularity; - startTime: number; - endTime: number; -}; - -export const getTimeseriesGranularity = ( - startTime?: number | null, - endTime?: number | null, -): TimeseriesConfig => { - const now = Date.now(); - - // If both of them are missing fallback to perMinute and fetch lastHour to show latest - if (!startTime && !endTime) { - return { - granularity: "perMinute", - startTime: now - HOUR_IN_MS, - endTime: now, - }; - } - - // Set default end time if missing - const effectiveEndTime = endTime ?? now; - // Set default start time if missing (last hour) - const effectiveStartTime = startTime ?? effectiveEndTime - HOUR_IN_MS; - const timeRange = effectiveEndTime - effectiveStartTime; - let granularity: TimeseriesGranularity; - - if (timeRange > WEEK_IN_MS) { - // > 7 days - granularity = "perDay"; - } else if (timeRange > HOUR_IN_MS) { - // > 1 hour - granularity = "perHour"; - } else { - // <= 1 hour - granularity = "perMinute"; - } - - return { - granularity, - startTime: effectiveStartTime, - endTime: effectiveEndTime, - }; -}; diff --git a/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-error.tsx new file mode 100644 index 0000000000..b394b2df85 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-error.tsx @@ -0,0 +1,23 @@ +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)/logs/components/charts/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-loading.tsx new file mode 100644 index 0000000000..a5ad4d70a5 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/components/logs-chart-loading.tsx @@ -0,0 +1,28 @@ +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)/logs/components/charts/hooks.ts b/apps/dashboard/app/(app)/logs/components/charts/hooks.ts deleted file mode 100644 index 953864d3ac..0000000000 --- a/apps/dashboard/app/(app)/logs/components/charts/hooks.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { trpc } from "@/lib/trpc/client"; -import type { LogsTimeseriesDataPoint } from "@unkey/clickhouse/src/logs"; -import { addMinutes, format } from "date-fns"; -import { useLogSearchParams } from "../../query-state"; -import { type TimeseriesGranularity, getTimeseriesGranularity } from "../../utils"; - -const roundToSecond = (timestamp: number) => Math.floor(timestamp / 1000) * 1000; - -const formatTimestamp = (value: string | number, granularity: TimeseriesGranularity) => { - const date = new Date(value); - const offset = new Date().getTimezoneOffset() * -1; - const localDate = addMinutes(date, offset); - - switch (granularity) { - case "perMinute": - return format(localDate, "HH:mm:ss"); - case "perHour": - return format(localDate, "MMM d, HH:mm"); - case "perDay": - return format(localDate, "MMM d"); - default: - return format(localDate, "Pp"); - } -}; - -export const useFetchTimeseries = (initialTimeseries: LogsTimeseriesDataPoint[]) => { - const { searchParams } = useLogSearchParams(); - - const filters = { - host: searchParams.host, - path: searchParams.path, - method: searchParams.method, - responseStatus: searchParams.responseStatus, - }; - - const { - startTime: rawStartTime, - endTime: rawEndTime, - granularity, - } = getTimeseriesGranularity(searchParams.startTime, searchParams.endTime); - - const { data, isLoading } = trpc.logs.queryTimeseries.useQuery( - { - startTime: roundToSecond(rawStartTime), - endTime: roundToSecond(rawEndTime), - ...filters, - }, - { - refetchInterval: searchParams.endTime ? false : 10_000, - initialData: initialTimeseries, - }, - ); - - const timeseries = data.map((data) => ({ - displayX: formatTimestamp(data.x, granularity), - originalTimestamp: data.x, - ...data.y, - })); - - return { timeseries, isLoading }; -}; diff --git a/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts new file mode 100644 index 0000000000..8394cc1401 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts @@ -0,0 +1,124 @@ +import { trpc } from "@/lib/trpc/client"; +import type { TimeseriesGranularity } from "@/lib/trpc/routers/logs/query-timeseries/utils"; +import { addMinutes, format } from "date-fns"; +import { useMemo } from "react"; +import type { z } from "zod"; +import { useFilters } from "../../../hooks/use-filters"; +import type { queryTimeseriesPayload } from "../query-timeseries.schema"; + +// Duration in milliseconds for historical data fetch window (1 hours) +const TIMESERIES_DATA_WINDOW = 60 * 60 * 1000; + +const formatTimestamp = (value: string | number, granularity: TimeseriesGranularity) => { + const date = new Date(value); + const offset = new Date().getTimezoneOffset() * -1; + const localDate = addMinutes(date, offset); + + switch (granularity) { + case "perMinute": + return format(localDate, "HH:mm:ss"); + case "perHour": + return format(localDate, "MMM d, HH:mm"); + case "perDay": + return format(localDate, "MMM d"); + default: + return format(localDate, "Pp"); + } +}; + +export const useFetchTimeseries = () => { + const { filters } = useFilters(); + + const dateNow = useMemo(() => Date.now(), []); + const queryParams = useMemo(() => { + const params: z.infer = { + startTime: dateNow - TIMESERIES_DATA_WINDOW, + endTime: dateNow, + host: { filters: [] }, + method: { filters: [] }, + path: { filters: [] }, + status: { filters: [] }, + since: "", + }; + + filters.forEach((filter) => { + switch (filter.field) { + case "status": { + params.status?.filters.push({ + operator: "is", + value: Number.parseInt(filter.value as string), + }); + break; + } + + case "methods": { + if (typeof filter.value !== "string") { + console.error("Method filter value type has to be 'string'"); + return; + } + params.method?.filters.push({ + operator: "is", + value: filter.value, + }); + break; + } + + case "paths": { + if (typeof filter.value !== "string") { + console.error("Path filter value type has to be 'string'"); + return; + } + params.path?.filters.push({ + operator: filter.operator, + value: filter.value, + }); + break; + } + + case "host": { + if (typeof filter.value !== "string") { + console.error("Host filter value type has to be 'string'"); + return; + } + params.host?.filters.push({ + operator: "is", + value: filter.value, + }); + break; + } + + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value type has to be 'number'`); + return; + } + params[filter.field] = filter.value; + break; + } + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value type has to be 'string'"); + return; + } + params.since = filter.value; + break; + } + } + }); + + return params; + }, [filters, dateNow]); + + const { data, isLoading, isError } = trpc.logs.queryTimeseries.useQuery(queryParams, { + refetchInterval: queryParams.endTime ? false : 10_000, + }); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestamp(ts.x, data.granularity), + originalTimestamp: ts.x, + ...ts.y, + })); + + return { timeseries, isLoading, isError }; +}; diff --git a/apps/dashboard/app/(app)/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/logs/components/charts/index.tsx index 7c3ae5f8e0..7ebe1740b5 100644 --- a/apps/dashboard/app/(app)/logs/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/charts/index.tsx @@ -5,123 +5,138 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { LogsTimeseriesDataPoint } from "@unkey/clickhouse/src/logs"; -import { addMinutes, format } from "date-fns"; -import { useEffect, useState } from "react"; -import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from "recharts"; -import { useFetchTimeseries } from "./hooks"; +import { Grid } from "@unkey/icons"; +import { useEffect, useRef } from "react"; +import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; +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 { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp"; const chartConfig = { success: { label: "Success", - color: "hsl(var(--chart-3))", + subLabel: "2xx", + color: "hsl(var(--accent-4))", }, warning: { label: "Warning", - color: "hsl(var(--chart-4))", + subLabel: "4xx", + color: "hsl(var(--warning-9))", }, error: { label: "Error", - color: "hsl(var(--chart-1))", + subLabel: "5xx", + color: "hsl(var(--error-9))", }, } satisfies ChartConfig; -const formatTimestampTooltip = (value: string | number) => { - const date = new Date(value); - const offset = new Date().getTimezoneOffset() * -1; - const localDate = addMinutes(date, offset); - return format(localDate, "dd MMM HH:mm:ss"); -}; - -const calculateTickInterval = (dataLength: number, containerWidth: number) => { - const pixelsPerTick = 80; // Adjust this value to control density - const suggestedInterval = Math.ceil((dataLength * pixelsPerTick) / containerWidth); - - const intervals = [1, 2, 5, 10, 15, 30, 60]; - return intervals.find((i) => i >= suggestedInterval) || intervals[intervals.length - 1]; -}; - export function LogsChart({ - initialTimeseries, + onMount, }: { - initialTimeseries: LogsTimeseriesDataPoint[]; + onMount: (distanceToTop: number) => void; }) { - const { timeseries } = useFetchTimeseries(initialTimeseries); - const [tickInterval, setTickInterval] = useState(5); - const [containerWidth, setContainerWidth] = useState(0); + const chartRef = useRef(null); + const { timeseries, isLoading, isError } = useFetchTimeseries(); + // biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation useEffect(() => { - if (containerWidth > 0 && timeseries.length > 0) { - const newInterval = calculateTickInterval(timeseries.length, containerWidth); - setTickInterval(newInterval); - } - }, [timeseries.length, containerWidth]); + const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; + onMount(distanceToTop); + }, [onMount, isLoading, isError]); - const maxValue = Math.max( - ...timeseries.map((item) => (item.success || 0) + (item.warning || 0) + (item.error || 0)), - ); - const yAxisMax = Math.ceil(maxValue * 1.1); + if (isError) { + return ; + } + + if (isLoading) { + return ; + } return ( -
- setContainerWidth(width)} - > +
+
+ {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 /> { - const originalTimestamp = payload[0]?.payload?.originalTimestamp; - return originalTimestamp ? ( - - {formatTimestampTooltip(originalTimestamp)} - - ) : ( - "" - ); - }} - /> - } + position={{ y: 50 }} + isAnimationActive + wrapperStyle={{ zIndex: 1000 }} + cursor={{ + fill: "hsl(var(--accent-3))", + strokeWidth: 1, + strokeDasharray: "5 5", + strokeOpacity: 0.7, + }} + content={({ active, payload, label }) => { + 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, index) => ( - + {["success", "warning", "error"].map((key) => ( + ))} diff --git a/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts new file mode 100644 index 0000000000..f0598efa8a --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { filterOperatorEnum } from "../../filters.schema"; + +export const queryTimeseriesPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + since: z.string(), + 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(), + status: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.number(), + }), + ), + }) + .nullable(), +}); diff --git a/apps/dashboard/app/(app)/logs/components/charts/utils/calculate-timepoints.ts b/apps/dashboard/app/(app)/logs/components/charts/utils/calculate-timepoints.ts new file mode 100644 index 0000000000..bd5de03495 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/utils/calculate-timepoints.ts @@ -0,0 +1,9 @@ +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)/logs/components/charts/utils/format-timestamp.ts b/apps/dashboard/app/(app)/logs/components/charts/utils/format-timestamp.ts new file mode 100644 index 0000000000..ea649d2307 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/charts/utils/format-timestamp.ts @@ -0,0 +1,13 @@ +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)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx similarity index 78% rename from apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx rename to apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx index 7c5bbd1c26..d261ea78e8 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx @@ -1,7 +1,9 @@ import { KeyboardButton } from "@/components/keyboard-button"; +import { TimestampInfo } from "@/components/timestamp-info"; 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 { FilterValue } from "../../filters.type"; import { useFilters } from "../../hooks/use-filters"; @@ -9,6 +11,10 @@ import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; const formatFieldName = (field: string): string => { switch (field) { + case "startTime": + return "Start time"; + case "endTime": + return "End time"; case "status": return "Status"; case "paths": @@ -17,12 +23,20 @@ const formatFieldName = (field: string): string => { return "Method"; case "requestId": return "Request ID"; + case "since": + return ""; default: 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): string => { +const formatValue = (value: string | number, field: string): string => { if (typeof value === "string" && /^\d+$/.test(value)) { const statusFamily = Math.floor(Number.parseInt(value) / 100); switch (statusFamily) { @@ -36,6 +50,9 @@ const formatValue = (value: string | number): string => { return `${statusFamily}xx`; } } + if (typeof value === "number" && (field === "startTime" || field === "endTime")) { + return format(value, "MMM d, yyyy HH:mm:ss"); + } return String(value); }; @@ -66,19 +83,30 @@ const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPil }; return ( -
-
- {formatFieldName(field)} -
+
+ {formatFieldName(field) === "" ? null : ( +
+ {formatFieldName(field)} +
+ )} +
- {operator} + {formatOperator(operator, field)}
{metadata?.colorClass && (
)} {metadata?.icon} - {formatValue(value)} + + {field === "endTime" || field === "startTime" ? ( + + ) : ( + {formatValue(value, field)} + )}
+ + + + + ); +}; + +const PopoverHeader = () => { + return ( +
+ Filter by time range + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/suggestions.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/suggestions.tsx new file mode 100644 index 0000000000..3464935e88 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/suggestions.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { useRef, useState } from "react"; +import type { KeyboardEvent, PropsWithChildren } from "react"; + +type SuggestionOption = { + id: number; + value: string | number | undefined; + display: string; + checked: boolean; +}; + +export type OptionsType = SuggestionOption[]; + +interface SuggestionsProps extends PropsWithChildren { + className?: string; + options: Array; + onChange: (id: number) => void; +} + +export const DateTimeSuggestions = ({ className, options, onChange }: SuggestionsProps) => { + // Set initial focused index to checked item or first item + const initialIndex = options.findIndex((option) => option.checked) ?? 0; + const [focusedIndex, setFocusedIndex] = useState(initialIndex); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex((prev) => { + const newIndex = (prev + 1) % options.length; + itemRefs.current[newIndex]?.focus(); + return newIndex; + }); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex((prev) => { + const newIndex = (prev - 1 + options.length) % options.length; + itemRefs.current[newIndex]?.focus(); + return newIndex; + }); + break; + case "Enter": + case " ": + e.preventDefault(); + onChange(options[focusedIndex].id); + break; + } + }; + + return ( +
+ {options.map(({ id, display, checked }, index) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..dd8a5bec8e --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useState } from "react"; +import { DatetimePopover } from "./components/datetime-popover"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState("Last 12 hours"); + const [isSelected, setIsSelected] = useState(false); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/utils/process-time.ts b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/utils/process-time.ts new file mode 100644 index 0000000000..e7b4711efc --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/utils/process-time.ts @@ -0,0 +1,18 @@ +type TimeUnit = { + HH?: string; + mm?: string; + ss?: string; +}; + +//Process new Date and time filters to be added to the filters as time since epoch +export const processTimeFilters = (date?: Date, newTime?: TimeUnit) => { + if (date) { + const hours = newTime?.HH ? Number.parseInt(newTime.HH) : 0; + const minutes = newTime?.mm ? Number.parseInt(newTime.mm) : 0; + const seconds = newTime?.ss ? Number.parseInt(newTime.ss) : 0; + date.setHours(hours, minutes, seconds, 0); + return date; + } + const now = new Date(); + return now; +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-display/components/display-popover.tsx similarity index 97% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-display/components/display-popover.tsx index db6e699603..f7b802ef01 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/components/display-popover.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-display/components/display-popover.tsx @@ -1,5 +1,5 @@ -import { isDisplayProperty, useLogsContext } from "@/app/(app)/logs-v2/context/logs"; -import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; +import { isDisplayProperty, useLogsContext } from "@/app/(app)/logs/context/logs"; +import { useKeyboardShortcut } from "@/app/(app)/logs/hooks/use-keyboard-shortcut"; import { KeyboardButton } from "@/components/keyboard-button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-display/index.tsx similarity index 100% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-display/index.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-display/index.tsx diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx similarity index 96% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx index 97edf2abf5..85eeb1b80c 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx @@ -1,5 +1,5 @@ -import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; -import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; +import type { FilterValue } from "@/app/(app)/logs/filters.type"; +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Button } from "@unkey/ui"; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx similarity index 97% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx index 619cb271f6..88b6783872 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/filters-popover.tsx @@ -1,5 +1,5 @@ -import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; -import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { useKeyboardShortcut } from "@/app/(app)/logs/hooks/use-keyboard-shortcut"; import { KeyboardButton } from "@/components/keyboard-button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CaretRight } from "@unkey/icons"; diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts new file mode 100644 index 0000000000..334cfface7 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts @@ -0,0 +1,100 @@ +import type { FilterValue } from "@/app/(app)/logs/filters.type"; +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 = >({ + 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)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx similarity index 91% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/methods-filter.tsx index a7419e240e..103bcc7212 100644 --- a/apps/dashboard/app/(app)/logs-v2/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 type { HttpMethod } from "@/app/(app)/logs-v2/filters.type"; +import type { HttpMethod } from "@/app/(app)/logs/filters.type"; import { FilterCheckbox } from "./filter-checkbox"; type MethodOption = { diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx similarity index 75% rename from apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx rename to apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx index 0e01de4954..29d6d8448e 100644 --- a/apps/dashboard/app/(app)/logs-v2/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,104 +1,33 @@ -import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; -import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; +import type { FilterValue } from "@/app/(app)/logs/filters.type"; +import { useFilters } from "@/app/(app)/logs/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 (