diff --git a/apps/dashboard/app/(app)/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/logs/components/charts/index.tsx index a4d969f77a..8bf155bd24 100644 --- a/apps/dashboard/app/(app)/logs/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/charts/index.tsx @@ -1,6 +1,5 @@ "use client"; import { LogsTimeseriesBarChart } from "@/components/logs/chart"; -import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; import { useFilters } from "../../hooks/use-filters"; import { useFetchTimeseries } from "./hooks/use-fetch-timeseries"; @@ -27,13 +26,13 @@ export function LogsChart({ ...activeFilters, { field: "startTime", - value: convertDateToLocal(start), + value: start, id: crypto.randomUUID(), operator: "is", }, { field: "endTime", - value: convertDateToLocal(end), + value: end, id: crypto.randomUUID(), operator: "is", }, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx new file mode 100644 index 0000000000..b7015d431f --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import type { PropsWithChildren } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + identifier: z + .string() + // biome-ignore lint/suspicious/noSelfCompare: + .refine((v) => v === v, "Please confirm the identifier"), +}); + +type FormValues = z.infer; + +type Props = PropsWithChildren<{ + isModalOpen: boolean; + onOpenChange: (value: boolean) => void; + overrideId: string; + identifier: string; +}>; + +export const DeleteDialog = ({ isModalOpen, onOpenChange, overrideId, identifier }: Props) => { + const { ratelimit } = trpc.useUtils(); + + const { + register, + handleSubmit, + watch, + formState: { isSubmitting }, + } = useForm({ + mode: "onChange", + resolver: zodResolver(formSchema), + defaultValues: { + identifier: "", + }, + }); + + const isValid = watch("identifier") === identifier; + + const deleteOverride = trpc.ratelimit.override.delete.useMutation({ + onSuccess() { + toast.success("Override has been deleted", { + description: "Changes may take up to 60s to propagate globally", + }); + onOpenChange(false); + ratelimit.overview.logs.query.invalidate(); + ratelimit.logs.queryRatelimitTimeseries.invalidate(); + }, + onError(err) { + toast.error("Failed to delete override", { + description: err.message, + }); + }, + }); + + const onSubmit = async () => { + try { + await deleteOverride.mutateAsync({ id: overrideId }); + } catch (error) { + console.error("Delete error:", error); + } + }; + + return ( + + { + e.preventDefault(); + }} + > + + + Delete Override + + + +
+
+

+ Warning: + Are you sure you want to delete this override? The identifier associated with this + override will now use the default limits. +

+ +
+

+ Type {identifier} to confirm +

+ + +
+
+ + +
+ +
+ This action cannot be undone – proceed with caution +
+
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx index 6aa2eddbc1..b7f9507c0d 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx @@ -1,7 +1,7 @@ import { Label } from "@/components/ui/label"; import { CircleInfo } from "@unkey/icons"; import type { ReactNode } from "react"; -import { InputTooltip } from "../_overview/components/table/components/logs-actions/components/input-tooltip"; +import { InputTooltip } from "./input-tooltip"; type FormFieldProps = { label: string; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx similarity index 97% rename from apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx rename to apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx index 0723efb749..6d0aeb25b9 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx @@ -25,7 +25,7 @@ import { Button } from "@unkey/ui"; import type { PropsWithChildren, ReactNode } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import type { OverrideDetails } from "../../../logs-table"; +import type { OverrideDetails } from "../types"; import { InputTooltip } from "./input-tooltip"; const overrideValidationSchema = z.object({ @@ -183,7 +183,7 @@ export const IdentifierDialog = ({
-
+
- +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-popover.tsx new file mode 100644 index 0000000000..5e9bf9ca12 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/table-action-popover.tsx @@ -0,0 +1,179 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { TableActionButton } from "./table-action-button"; + +export type MenuItem = { + id: string; + label: string; + icon: React.ReactNode; + onClick: (e: React.MouseEvent | React.KeyboardEvent) => void; + className?: string; + disabled?: boolean; +}; + +type BaseTableActionPopoverProps = PropsWithChildren<{ + items: MenuItem[]; + align?: "start" | "end"; + headerContent?: React.ReactNode; +}>; + +export const TableActionPopover = ({ + items, + align = "end", + headerContent, + children, +}: BaseTableActionPopoverProps) => { + const [open, setOpen] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const menuItems = useRef([]); + + useEffect(() => { + if (open) { + const firstEnabledIndex = items.findIndex((item) => !item.disabled); + setFocusIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : 0); + if (firstEnabledIndex >= 0) { + menuItems.current[firstEnabledIndex]?.focus(); + } + } + }, [open, items]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + + const activeElement = document.activeElement; + const currentIndex = menuItems.current.findIndex((item) => item === activeElement); + const itemCount = items.length; + + const findNextEnabledIndex = (startIndex: number, direction: 1 | -1) => { + let index = startIndex; + for (let i = 0; i < itemCount; i++) { + index = (index + direction + itemCount) % itemCount; + if (!items[index].disabled) { + return index; + } + } + return startIndex; + }; + + switch (e.key) { + case "Tab": { + e.preventDefault(); + const nextIndex = findNextEnabledIndex(currentIndex, e.shiftKey ? -1 : 1); + setFocusIndex(nextIndex); + menuItems.current[nextIndex]?.focus(); + break; + } + + case "j": + case "ArrowDown": { + e.preventDefault(); + const nextDownIndex = findNextEnabledIndex(currentIndex, 1); + setFocusIndex(nextDownIndex); + menuItems.current[nextDownIndex]?.focus(); + break; + } + + case "k": + case "ArrowUp": { + e.preventDefault(); + const nextUpIndex = findNextEnabledIndex(currentIndex, -1); + setFocusIndex(nextUpIndex); + menuItems.current[nextUpIndex]?.focus(); + break; + } + + case "Escape": + e.preventDefault(); + setOpen(false); + break; + + case "Enter": + case "ArrowRight": + case "l": + case " ": + e.preventDefault(); + if (activeElement === menuItems.current[currentIndex] && !items[currentIndex].disabled) { + items[currentIndex].onClick(e); + } + break; + } + }; + + return ( + + e.stopPropagation()}> + {children ? children : } + + + { + e.preventDefault(); + const firstEnabledIndex = items.findIndex((item) => !item.disabled); + if (firstEnabledIndex >= 0) { + menuItems.current[firstEnabledIndex]?.focus(); + } + }} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => { + e.preventDefault(); + setOpen(false); + }} + onInteractOutside={(e) => { + e.preventDefault(); + setOpen(false); + }} + > +
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {headerContent ?? } + + {items.map((item, index) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
{ + if (el) { + menuItems.current[index] = el; + } + }} + role="menuitem" + aria-disabled={item.disabled} + tabIndex={!item.disabled && focusIndex === index ? 0 : -1} + className={cn( + "flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group", + !item.disabled && + "cursor-pointer hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3", + item.disabled && "cursor-not-allowed opacity-50", + item.className, + )} + onClick={(e) => { + if (!item.disabled) { + item.onClick(e); + setOpen(false); + } + }} + > + {item.icon} + {item.label} +
+ ))} +
+
+
+ ); +}; + +const PopoverHeader = () => { + return ( +
+ Actions... +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx index 0e2a7e4764..feb8d9b9c1 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx @@ -73,7 +73,7 @@ export function LogsTimeseriesBarChart({ setSelection((prev) => ({ ...prev, end: e.activeLabel, - startTimestamp: timestamp, + endTimestamp: timestamp, })); } }; @@ -87,6 +87,7 @@ export function LogsTimeseriesBarChart({ return; } const [start, end] = [selection.startTimestamp, selection.endTimestamp].sort((a, b) => a - b); + onSelectionChange({ start, end }); } setSelection({ @@ -219,8 +220,16 @@ export function LogsTimeseriesBarChart({ {enableSelection && selection.start && selection.end && ( diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx index f37b9e2fd5..78b4242e8f 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx @@ -1,4 +1,3 @@ -import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; import { useFilters } from "../../hooks/use-filters"; import { LogsTimeseriesBarChart } from "./bar-chart"; import { useFetchRatelimitOverviewTimeseries } from "./bar-chart/hooks/use-fetch-timeseries"; @@ -28,13 +27,13 @@ export const RatelimitOverviewLogsCharts = ({ ...activeFilters, { field: "startTime", - value: convertDateToLocal(start), + value: start, id: crypto.randomUUID(), operator: "is", }, { field: "endTime", - value: convertDateToLocal(end), + value: end, id: crypto.randomUUID(), operator: "is", }, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx deleted file mode 100644 index fe58004bf1..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"use client"; - -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { toast } from "@/components/ui/toaster"; -import { Clone, Layers3, PenWriting3 } from "@unkey/icons"; -import Link from "next/link"; -import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; -import { useFilters } from "../../../../../hooks/use-filters"; -import type { OverrideDetails } from "../../../logs-table"; -import { IdentifierDialog } from "./identifier-dialog"; - -type Props = { - identifier: string; - namespaceId: string; - overrideDetails?: OverrideDetails | null; -}; - -export const TableActionPopover = ({ - children, - identifier, - namespaceId, - overrideDetails, -}: PropsWithChildren) => { - const [isModalOpen, setIsModalOpen] = useState(false); - - const [open, setOpen] = useState(false); - const [focusIndex, setFocusIndex] = useState(0); - const menuItems = useRef([]); - const { filters, updateFilters } = useFilters(); - - const timeFilters = filters.filter((f) => ["startTime", "endTime", "since"].includes(f.field)); - - const getTimeParams = () => { - const params = new URLSearchParams({ - identifier: `contains:${identifier}`, - }); - - // Only add time parameters if they exist - const timeMap = { - startTime: timeFilters.find((f) => f.field === "startTime")?.value, - endTime: timeFilters.find((f) => f.field === "endTime")?.value, - since: timeFilters.find((f) => f.field === "since")?.value, - }; - - Object.entries(timeMap).forEach(([key, value]) => { - if (value) { - params.append(key, value.toString()); - } - }); - - return params.toString(); - }; - - useEffect(() => { - if (open) { - setFocusIndex(0); - menuItems.current[0]?.focus(); - } - }, [open]); - - const handleEditClick = (e: React.MouseEvent | KeyboardEvent) => { - e.stopPropagation(); - setOpen(false); - setIsModalOpen(true); - }; - - const handleFilterClick = (e: React.MouseEvent | KeyboardEvent) => { - e.stopPropagation(); - const newFilter = { - id: crypto.randomUUID(), - field: "identifiers" as const, - operator: "is" as const, - value: identifier, - }; - const existingFilters = filters.filter( - (f) => !(f.field === "identifiers" && f.value === identifier), - ); - updateFilters([...existingFilters, newFilter]); - setOpen(false); - }; - - const handleCopy = (e: React.MouseEvent | KeyboardEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(identifier); - toast.success("Copied to clipboard", { - description: identifier, - }); - setOpen(false); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - e.stopPropagation(); - - const activeElement = document.activeElement; - const currentIndex = menuItems.current.findIndex((item) => item === activeElement); - - switch (e.key) { - case "Tab": - e.preventDefault(); - if (!e.shiftKey) { - setFocusIndex((currentIndex + 1) % 3); - menuItems.current[(currentIndex + 1) % 3]?.focus(); - } else { - setFocusIndex((currentIndex - 1 + 3) % 3); - menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); - } - break; - - case "j": - case "ArrowDown": - e.preventDefault(); - setFocusIndex((currentIndex + 1) % 3); - menuItems.current[(currentIndex + 1) % 3]?.focus(); - break; - - case "k": - case "ArrowUp": - e.preventDefault(); - setFocusIndex((currentIndex - 1 + 3) % 3); - menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); - break; - case "Escape": - e.preventDefault(); - setOpen(false); - break; - case "Enter": - case "ArrowRight": - case "l": - case " ": - e.preventDefault(); - if (activeElement === menuItems.current[0]) { - handleCopy(e); - } else if (activeElement === menuItems.current[2]) { - handleFilterClick(e); - } - break; - } - }; - - return ( - <> - - e.stopPropagation()} asChild> -
{children}
-
- { - e.preventDefault(); - menuItems.current[0]?.focus(); - }} - onCloseAutoFocus={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => { - e.preventDefault(); - setOpen(false); - }} - onInteractOutside={(e) => { - e.preventDefault(); - setOpen(false); - }} - > -
e.stopPropagation()} - onKeyDown={handleKeyDown} - > - e.stopPropagation()} - > -
{ - if (el) { - menuItems.current[0] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 0 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer - hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" - > - - Go to logs -
- - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
{ - if (el) { - menuItems.current[1] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 1 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer - hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" - onClick={handleCopy} - > - - Copy identifier -
- - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
{ - if (el) { - menuItems.current[2] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 2 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer - hover:bg-orange-3 data-[state=open]:bg-orange-3 focus:outline-none focus:bg-orange-3 text-orange-11" - onClick={handleEditClick} - > - - Override Identifier -
-
-
-
- - - ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx index 69532bdbfa..6c7905e193 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx @@ -1,8 +1,15 @@ -import { Dots } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import type { OverrideDetails } from "../../logs-table"; -import { TableActionPopover } from "./components/table-action-popover"; +import { DeleteDialog } from "@/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog"; +import { IdentifierDialog } from "@/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog"; +import { + type MenuItem, + TableActionPopover, +} from "@/app/(app)/ratelimits/[namespaceId]/_components/table-action-popover"; +import type { OverrideDetails } from "@/app/(app)/ratelimits/[namespaceId]/types"; +import { toast } from "@/components/ui/toaster"; +import { Clone, Layers3, PenWriting3, Trash } from "@unkey/icons"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; export const LogsTableAction = ({ identifier, @@ -13,20 +20,99 @@ export const LogsTableAction = ({ namespaceId: string; overrideDetails?: OverrideDetails | null; }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const { filters } = useFilters(); + + const timeFilters = filters.filter((f) => ["startTime", "endTime", "since"].includes(f.field)); + + const getTimeParams = () => { + const params = new URLSearchParams({ + identifiers: `contains:${identifier}`, + }); + const timeMap = { + startTime: timeFilters.find((f) => f.field === "startTime")?.value, + endTime: timeFilters.find((f) => f.field === "endTime")?.value, + since: timeFilters.find((f) => f.field === "since")?.value, + }; + Object.entries(timeMap).forEach(([key, value]) => { + if (value) { + params.append(key, value.toString()); + } + }); + return params.toString(); + }; + + const items: MenuItem[] = [ + { + id: "logs", + label: "Go to logs", + icon: , + onClick(e) { + e.stopPropagation(); + router.push(`/ratelimits/${namespaceId}/logs?${getTimeParams()}`); + }, + }, + { + id: "copy", + label: "Copy identifier", + icon: , + onClick: (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(identifier); + toast.success("Copied to clipboard", { + description: identifier, + }); + }, + }, + { + id: "override", + label: overrideDetails ? "Update Override" : "Override Identifier", + icon: , + className: "text-orange-11 hover:bg-orange-2 focus:bg-orange-3", + onClick: (e) => { + e.stopPropagation(); + setIsModalOpen(true); + }, + }, + { + id: "delete", + label: "Delete Override", + icon: , + className: `${ + overrideDetails?.overrideId + ? "text-error-10 hover:bg-error-3 focus:bg-error-3" + : "text-error-10 cursor-not-allowed bg-error-3" + }`, + disabled: !overrideDetails?.overrideId, + onClick: (e) => { + e.stopPropagation(); + if (overrideDetails?.overrideId) { + setIsDeleteModalOpen(true); + } + }, + }, + ]; + return ( - - - + <> + + + {overrideDetails?.overrideId && ( + + )} + ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx index 1eef5f83f5..6867474510 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx @@ -17,13 +17,6 @@ import { useSort } from "./hooks/use-sort"; import { STATUS_STYLES, getRowClassName, getStatusStyle } from "./utils/get-row-class"; // const MAX_LATENCY = 10; -export type OverrideDetails = { - overrideId?: string; - limit: number; - duration: number; - async?: boolean | null; -}; - export const RatelimitOverviewLogsTable = ({ namespaceId, }: { @@ -168,13 +161,20 @@ export const RatelimitOverviewLogsTable = ({ value={log.time} className={cn("font-mono group-hover:underline decoration-dotted")} /> -
- -
+
+ ), + }, + { + key: "actions", + header: "", + width: "7.5%", + render: (log) => ( +
+
), }, 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 cfa5b5b300..e7adf522a5 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,6 +1,5 @@ "use client"; import { LogsTimeseriesBarChart } from "@/components/logs/chart"; -import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; import { useRatelimitLogsContext } from "../../context/logs"; import { useFilters } from "../../hooks/use-filters"; import { useFetchRatelimitTimeseries } from "./hooks/use-fetch-timeseries"; @@ -29,13 +28,13 @@ export function RatelimitLogsChart({ ...activeFilters, { field: "startTime", - value: convertDateToLocal(start), + value: start, id: crypto.randomUUID(), operator: "is", }, { field: "endTime", - value: convertDateToLocal(end), + value: end, id: crypto.randomUUID(), operator: "is", }, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx deleted file mode 100644 index 36e7347801..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx +++ /dev/null @@ -1,200 +0,0 @@ -"use client"; - -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { toast } from "@/components/ui/toaster"; -import { Clipboard, ClipboardCheck, InputSearch, PenWriting3 } from "@unkey/icons"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; -import { useRatelimitLogsContext } from "../../../../context/logs"; -import { useFilters } from "../../../../hooks/use-filters"; - -type Props = { - identifier: string; -}; - -export const TableActionPopover = ({ children, identifier }: PropsWithChildren) => { - const { push } = useRouter(); - const [open, setOpen] = useState(false); - const [copied, setCopied] = useState(false); - const [focusIndex, setFocusIndex] = useState(0); - const menuItems = useRef([]); - const { filters, updateFilters } = useFilters(); - const { namespaceId } = useRatelimitLogsContext(); - - useEffect(() => { - if (open) { - setFocusIndex(0); - menuItems.current[0]?.focus(); - } - }, [open]); - - const handleFilterClick = (e: React.MouseEvent | KeyboardEvent) => { - e.stopPropagation(); - const newFilter = { - id: crypto.randomUUID(), - field: "identifiers" as const, - operator: "is" as const, - value: identifier, - }; - const existingFilters = filters.filter( - (f) => !(f.field === "identifiers" && f.value === identifier), - ); - updateFilters([...existingFilters, newFilter]); - setOpen(false); - }; - - const handleCopy = (e: React.MouseEvent | KeyboardEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(identifier); - toast.success("Copied to clipboard", { - description: identifier, - }); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - setOpen(false); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - e.stopPropagation(); - - const activeElement = document.activeElement; - const currentIndex = menuItems.current.findIndex((item) => item === activeElement); - - switch (e.key) { - case "Tab": - e.preventDefault(); - if (!e.shiftKey) { - setFocusIndex((currentIndex + 1) % 3); - menuItems.current[(currentIndex + 1) % 3]?.focus(); - } else { - setFocusIndex((currentIndex - 1 + 3) % 3); - menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); - } - break; - case "j": - case "ArrowDown": - e.preventDefault(); - setFocusIndex((currentIndex + 1) % 3); - menuItems.current[(currentIndex + 1) % 3]?.focus(); - break; - case "k": - case "ArrowUp": - e.preventDefault(); - setFocusIndex((currentIndex - 1 + 3) % 3); - menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); - break; - case "Escape": - e.preventDefault(); - setOpen(false); - break; - case "Enter": - case "ArrowRight": - case "l": - case " ": - e.preventDefault(); - if (activeElement === menuItems.current[0]) { - handleCopy(e); - } else if (activeElement === menuItems.current[1]) { - push(`/ratelimits/${namespaceId}/overrides?identifier=${identifier}`); - } else if (activeElement === menuItems.current[2]) { - handleFilterClick(e); - } - break; - } - }; - - return ( - - e.stopPropagation()} asChild> -
{children}
-
- { - e.preventDefault(); - menuItems.current[0]?.focus(); - }} - onCloseAutoFocus={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => { - e.preventDefault(); - setOpen(false); - }} - onInteractOutside={(e) => { - e.preventDefault(); - setOpen(false); - }} - > -
e.stopPropagation()} - onKeyDown={handleKeyDown} - > - - {/* biome-ignore lint/a11y/useKeyWithClickEvents: it's okay */} -
{ - if (el) { - menuItems.current[0] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 0 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 justify-between rounded-lg group cursor-pointer - hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" - onClick={handleCopy} - > - Copy identifier - {copied ? : } -
- e.stopPropagation()} - > -
{ - if (el) { - menuItems.current[1] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 1 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 justify-between rounded-lg group cursor-pointer - hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" - > - Override - -
- - {/* biome-ignore lint/a11y/useKeyWithClickEvents: it's okay */} -
{ - if (el) { - menuItems.current[2] = el; - } - }} - role="menuitem" - tabIndex={focusIndex === 2 ? 0 : -1} - className="flex w-full items-center px-2 py-1.5 justify-between rounded-lg group cursor-pointer - hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" - onClick={handleFilterClick} - > - Filter for identifier - -
-
-
-
- ); -}; - -const PopoverHeader = () => { - return ( -
- Actions -
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx index 21d0db6560..cac9f9fb32 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx @@ -1,14 +1,43 @@ -import { Dots } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { TableActionPopover } from "./components/table-action-popover"; +import { toast } from "@/components/ui/toaster"; +import { Clone, InputSearch } from "@unkey/icons"; +import { type MenuItem, TableActionPopover } from "../../../../_components/table-action-popover"; +import { useFilters } from "../../../hooks/use-filters"; export const LogsTableAction = ({ identifier }: { identifier: string }) => { - return ( - - - - ); + const { filters, updateFilters } = useFilters(); + + const items: MenuItem[] = [ + { + id: "copy", + label: "Copy identifier", + icon: , + onClick: (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(identifier); + toast.success("Copied to clipboard", { + description: identifier, + }); + }, + }, + { + id: "filter", + label: "Filter for identifier", + icon: , + onClick: (e) => { + e.stopPropagation(); + const newFilter = { + id: crypto.randomUUID(), + field: "identifiers" as const, + operator: "is" as const, + value: identifier, + }; + const existingFilters = filters.filter( + (f) => !(f.field === "identifiers" && f.value === identifier), + ); + updateFilters([...existingFilters, newFilter]); + }, + }, + ]; + + return ; }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx index 1f24e34de3..4b029c1c57 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx @@ -51,7 +51,7 @@ export const NamespaceNavbar = ({ label: ns.name, href: `/ratelimits/${ns.id}`, }))} - shortcutKey="R" + shortcutKey="N" >
{namespace.name}
diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/create-new-override.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/create-new-override.tsx deleted file mode 100644 index e5f0d28b86..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/create-new-override.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; -import { Loading } from "@/components/dashboard/loading"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { useRouter, useSearchParams } from "next/navigation"; -import type React from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z.object({ - identifier: z - .string() - .trim() - .min(3, "Name is required and should be at least 3 characters") - .max(250), - limit: z.coerce.number().int().min(1).max(10_000), - duration: z.coerce - .number() - .int() - .min(1_000) - .max(24 * 60 * 60 * 1000), - async: z.enum(["unset", "sync", "async"]), -}); - -type Props = { - namespaceId: string; -}; - -export const CreateNewOverride: React.FC = ({ namespaceId }) => { - const searchParams = useSearchParams(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - reValidateMode: "onChange", - defaultValues: { - limit: 10, - duration: 60_000, - async: "unset", - identifier: searchParams?.get("identifier") ?? undefined, - }, - }); - - const create = trpc.ratelimit.override.create.useMutation({ - onSuccess() { - toast.success("New override has been created", { - description: "Changes may take up to 60s to propagate globally", - }); - router.refresh(); - }, - onError(err) { - toast.error(err.message); - }, - }); - async function onSubmit(values: z.infer) { - create.mutate({ - namespaceId, - identifier: values.identifier, - limit: values.limit, - duration: values.duration, - async: { - unset: undefined, - sync: false, - async: true, - }[values.async], - }); - } - const router = useRouter(); - - return ( - - - New Override - - - - - - ( - - Identifier - - - - The identifier you use when ratelimiting. - - - )} - /> - ( - - Limit - - - - - How many request can be made within a given window. - - - - )} - /> - ( - - Duration - - - - Duration of each window in milliseconds. - - - )} - /> - ( - - Async - - - Override the mode, async is faster but slightly less accurate. - - - - )} - /> - - - - - - - - ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/logs-actions/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/logs-actions/index.tsx new file mode 100644 index 0000000000..ddd02782ea --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/logs-actions/index.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { toast } from "@/components/ui/toaster"; +import { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { useState } from "react"; +import { DeleteDialog } from "../../_components/delete-dialog"; +import { IdentifierDialog } from "../../_components/identifier-dialog"; +import { type MenuItem, TableActionPopover } from "../../_components/table-action-popover"; +import type { OverrideDetails } from "../../types"; + +export const OverridesTableAction = ({ + identifier, + namespaceId, + overrideDetails, +}: { + identifier: string; + namespaceId: string; + overrideDetails?: OverrideDetails | null; +}) => { + const [isIdentifierModalOpen, setIsIdentifierModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const items: MenuItem[] = [ + { + id: "copy", + label: "Copy identifier", + icon: , + onClick: (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(identifier); + toast.success("Copied to clipboard", { + description: identifier, + }); + }, + }, + { + id: "override", + label: "Override Identifier", + icon: , + className: "text-orange-11 hover:bg-orange-2 focus:bg-orange-3", + onClick: (e) => { + e.stopPropagation(); + setIsIdentifierModalOpen(true); + }, + }, + { + id: "delete", + label: "Delete Override", + icon: , + className: "text-error-11 hover:bg-error-3 focus:bg-error-3", + onClick: (e) => { + e.stopPropagation(); + setIsDeleteModalOpen(true); + }, + }, + ]; + + return ( + <> + + + + {overrideDetails?.overrideId && ( + + )} + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/overrides-table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/overrides-table.tsx new file mode 100644 index 0000000000..125b424c22 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/overrides-table.tsx @@ -0,0 +1,167 @@ +"use client"; +import { Badge } from "@/components/ui/badge"; +import { VirtualTable } from "@/components/virtual-table"; +import type { Column } from "@/components/virtual-table/types"; +import { cn } from "@/lib/utils"; +import { Empty } from "@unkey/ui"; +import ms from "ms"; +import { OverridesTableAction } from "./logs-actions"; + +type Override = { + id: string; + identifier: string; + limit: number; + duration: number; + async: boolean | null; +}; + +type Props = { + namespaceId: string; + ratelimits: Override[]; + lastUsedTimes: Record; +}; + +const STATUS_STYLES = { + default: { + base: "text-accent-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-accent-3", + selected: "text-accent-11 bg-accent-3 dark:text-accent-12", + badge: { + default: "bg-accent-4 text-accent-11 group-hover:bg-accent-5", + selected: "bg-accent-5 text-accent-12 hover:bg-hover-5", + }, + focusRing: "focus:ring-accent-7", + }, +}; + +const getRowClassName = () => { + const style = STATUS_STYLES.default; + + return cn( + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + ); +}; + +export const OverridesTable = ({ namespaceId, ratelimits, lastUsedTimes }: Props) => { + const columns: Column[] = [ + { + key: "identifier", + header: "Identifier", + headerClassName: "pl-2", + width: "25%", + render: (override) => ( +
+ + {override.identifier} + +
{override.id}
+
+ ), + }, + { + key: "limits", + header: "Limits", + width: "25%", + render: (override) => ( +
+
+ + {Intl.NumberFormat().format(override.limit)} Requests + +
+ / +
+ + {ms(override.duration)} + +
+
+ ), + }, + { + key: "async", + header: "Async", + width: "15%", + render: (override) => + override.async === null ? ( +
+ ) : ( + + {override.async ? "async" : "sync"} + + ), + }, + { + key: "lastUsed", + header: "Last used", + width: "20%", + render: (override) => { + const lastUsed = lastUsedTimes[override.identifier]; + if (lastUsed) { + return ( + + {ms(Date.now() - lastUsed)} ago + + ); + } + return
; + }, + }, + { + key: "actions", + header: "", + width: "15%", + render: (override) => ( + + ), + }, + ]; + + return ( + override.id} + rowClassName={getRowClassName} + emptyState={ +
+ + + No overrides found + + No custom ratelimits found. Create your first override to get started. + + +
+ } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx index 991924fa57..6d3405b392 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx @@ -1,16 +1,11 @@ -import { PageHeader } from "@/components/dashboard/page-header"; -import { Badge } from "@/components/ui/badge"; -import { Empty } from "@unkey/ui"; -import { CreateNewOverride } from "./create-new-override"; -import { Overrides } from "./table"; +import { clickhouse } from "@/lib/clickhouse"; +import { NamespaceNavbar } from "../namespace-navbar"; +import { getWorkspaceDetailsWithOverrides } from "../namespace.actions"; +import { OverridesTable } from "./overrides-table"; export const dynamic = "force-dynamic"; export const runtime = "edge"; -import { PageContent } from "@/components/page-content"; -import { NamespaceNavbar } from "../namespace-navbar"; -import { getWorkspaceDetailsWithOverrides } from "../namespace.actions"; - export default async function OverridePage({ params: { namespaceId }, }: { @@ -18,6 +13,14 @@ export default async function OverridePage({ }) { const { namespace, workspace } = await getWorkspaceDetailsWithOverrides(namespaceId); + const lastUsedTimes = namespace.overrides?.length + ? await getLastUsedTimes( + namespace.workspaceId, + namespace.id, + namespace.overrides.map((o) => o.identifier), + ) + : {}; + return (
- - - {Intl.NumberFormat().format(namespace.overrides?.length)} /{" "} - {Intl.NumberFormat().format(workspace.features.ratelimitOverrides ?? 5)} used{" "} - , - ]} - /> - - {namespace.overrides?.length === 0 ? ( - - - No custom ratelimits found - Create your first override below - - ) : ( - - )} - +
); } + +async function getLastUsedTimes(workspaceId: string, namespaceId: string, identifiers: string[]) { + const results = await Promise.all( + identifiers.map(async (identifier) => { + const lastUsed = await clickhouse.ratelimits.latest({ + workspaceId, + namespaceId, + identifier: [identifier], + limit: 1, + }); + return { + identifier, + lastUsed: lastUsed.val?.at(0)?.time ?? null, + }; + }), + ); + + return Object.fromEntries(results.map(({ identifier, lastUsed }) => [identifier, lastUsed])); +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx deleted file mode 100644 index f14eedba86..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { clickhouse } from "@/lib/clickhouse"; -import { Button } from "@unkey/ui"; -import { ChevronRight, Minus } from "lucide-react"; -import ms from "ms"; -import Link from "next/link"; - -type Props = { - workspaceId: string; - namespaceId: string; - ratelimits: { - id: string; - identifier: string; - limit: number; - duration: number; - async: boolean | null; - }[]; -}; - -export const Overrides: React.FC = async ({ workspaceId, namespaceId, ratelimits }) => { - return ( - - - - Identifier - Limits - Async - Last used - - - - - {ratelimits.map((rl) => ( - - -
- {rl.identifier} -
{rl.id}
-
-
- - - {Intl.NumberFormat().format(rl.limit)} requests - / - {ms(rl.duration)} - - - {rl.async === null ? ( - - ) : ( - {rl.async ? "async" : "sync"} - )} - - - - - - - - - - {/* */} -
- ))} -
-
- ); -}; - -const LastUsed: React.FC<{ - workspaceId: string; - namespaceId: string; - identifier: string; -}> = async ({ workspaceId, namespaceId, identifier }) => { - const lastUsed = await clickhouse.ratelimits.latest({ - workspaceId, - namespaceId, - identifier: [identifier], - limit: 1, - }); - - const unixMilli = lastUsed.val?.at(0)?.time; - if (unixMilli) { - return {ms(Date.now() - unixMilli)} ago; - } - return ; -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/types.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/types.ts new file mode 100644 index 0000000000..c6b2f950fe --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/types.ts @@ -0,0 +1,6 @@ +export type OverrideDetails = { + overrideId?: string; + limit: number; + duration: number; + async?: boolean | null; +}; diff --git a/apps/dashboard/components/logs/chart/components/logs-chart-error.tsx b/apps/dashboard/components/logs/chart/components/logs-chart-error.tsx index b394b2df85..865fa41d22 100644 --- a/apps/dashboard/components/logs/chart/components/logs-chart-error.tsx +++ b/apps/dashboard/components/logs/chart/components/logs-chart-error.tsx @@ -14,8 +14,10 @@ export const LogsChartError = () => { ))}
-
- Could not retrieve logs +
+
+ Could not retrieve logs +
diff --git a/apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx b/apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx index a5ad4d70a5..dc3922c6c3 100644 --- a/apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx +++ b/apps/dashboard/components/logs/chart/components/logs-chart-loading.tsx @@ -1,17 +1,38 @@ +import { useEffect, useState } from "react"; 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 - })); + const [mockData, setMockData] = useState(generateInitialData()); + + function generateInitialData() { + return Array.from({ length: 100 }).map(() => ({ + success: Math.random() * 0.5 + 0.5, + timestamp: Date.now(), + })); + } + + useEffect(() => { + const interval = setInterval(() => { + setMockData((prevData) => + prevData.map((item) => ({ + ...item, + success: Math.random() * 0.5 + 0.5, + })), + ); + }, 600); // Update every 600ms for smooth animation + + return () => clearInterval(interval); + }, []); + + const currentTime = Date.now(); return (
- {calculateTimePoints(Date.now(), Date.now()).map((time, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: it's safe to use index here + {calculateTimePoints(currentTime, currentTime).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey:
{formatTimestampLabel(time)}
@@ -20,9 +41,16 @@ export const LogsChartLoading = () => { dataMax * 2]} hide /> - +
); }; + +export default LogsChartLoading; diff --git a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts index 31ead9967f..7379cd0636 100644 --- a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts +++ b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts @@ -1,12 +1,5 @@ import type { TimeseriesGranularity } from "@/lib/trpc/routers/utils/granularity"; -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"); -}; +import { addMinutes, format, fromUnixTime } from "date-fns"; export const formatTimestampLabel = (timestamp: string | number | Date) => { const date = new Date(timestamp); @@ -39,3 +32,18 @@ export const formatTimestampForChart = ( return format(localDate, "Pp"); } }; + +const unixMicroToDate = (unix: string | number): Date => { + return fromUnixTime(Number(unix) / 1000 / 1000); +}; + +const isUnixMicro = (unix: string | number): boolean => { + const digitLength = String(unix).length === 16; + const isNum = !Number.isNaN(Number(unix)); + return isNum && digitLength; +}; + +export const formatTimestampTooltip = (value: string | number) => { + const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); + return format(date, "MMM dd HH:mm:ss"); +}; diff --git a/apps/dashboard/components/logs/checkbox/filters-popover.tsx b/apps/dashboard/components/logs/checkbox/filters-popover.tsx index a99787d77b..eb510891f0 100644 --- a/apps/dashboard/components/logs/checkbox/filters-popover.tsx +++ b/apps/dashboard/components/logs/checkbox/filters-popover.tsx @@ -44,7 +44,7 @@ export const FiltersPopover = ({ } if (activeFilter) { - if (e.key === "ArrowLeft" || e.key === "h") { + if (e.key === "ArrowLeft") { e.preventDefault(); setActiveFilter(null); } @@ -53,19 +53,16 @@ export const FiltersPopover = ({ switch (e.key) { case "ArrowDown": - case "j": e.preventDefault(); setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % items.length)); break; case "ArrowUp": - case "k": e.preventDefault(); setFocusedIndex((prev) => prev === null ? items.length - 1 : (prev - 1 + items.length) % items.length, ); break; case "Enter": - case "l": case "ArrowRight": e.preventDefault(); if (focusedIndex !== null) { @@ -129,7 +126,7 @@ const FilterItem = ({ const contentRef = useRef(null); const handleKeyDown = (e: KeyboardEvent) => { - if ((e.key === "ArrowLeft" || e.key === "h") && open) { + if (e.key === "ArrowLeft" && open) { e.preventDefault(); setOpen(false); itemRef.current?.focus(); diff --git a/apps/dashboard/components/logs/refresh-button/index.tsx b/apps/dashboard/components/logs/refresh-button/index.tsx index fc8e143a6c..561277ebcf 100644 --- a/apps/dashboard/components/logs/refresh-button/index.tsx +++ b/apps/dashboard/components/logs/refresh-button/index.tsx @@ -1,3 +1,4 @@ +import { KeyboardButton } from "@/components/keyboard-button"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { Refresh3 } from "@unkey/icons"; import { Button } from "@unkey/ui"; @@ -17,7 +18,7 @@ export const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: Refr const [isLoading, setIsLoading] = useState(false); const [refreshTimeout, setRefreshTimeout] = useState(null); - useKeyboardShortcut("r", () => { + useKeyboardShortcut({ ctrl: true, key: "r" }, () => { isEnabled && handleRefresh(); }); @@ -60,6 +61,7 @@ export const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: Refr {isLoading &&
} Refresh + ); }; diff --git a/apps/dashboard/components/navbar-popover.tsx b/apps/dashboard/components/navbar-popover.tsx index 0438984318..3d28073a9a 100644 --- a/apps/dashboard/components/navbar-popover.tsx +++ b/apps/dashboard/components/navbar-popover.tsx @@ -37,7 +37,7 @@ export const QuickNavPopover = ({ const [open, setOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(null); - useKeyboardShortcut(shortcutKey, () => { + useKeyboardShortcut({ key: shortcutKey, ctrl: true }, () => { setOpen((prev) => !prev); }); @@ -62,19 +62,16 @@ export const QuickNavPopover = ({ } switch (e.key) { case "ArrowDown": - case "j": e.preventDefault(); setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % items.length)); break; case "ArrowUp": - case "k": e.preventDefault(); setFocusedIndex((prev) => prev === null ? items.length - 1 : (prev - 1 + items.length) % items.length, ); break; case "Enter": - case "l": case "ArrowRight": e.preventDefault(); if (focusedIndex !== null) { @@ -118,7 +115,7 @@ const PopoverHeader = ({ }) => (
{title} - +
); diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts b/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts index e002a9d330..5a2b59e09f 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { insertAuditLogs } from "@/lib/audit"; -import { and, db, eq, isNull, schema, sql } from "@/lib/db"; +import { db, schema, sql } from "@/lib/db"; import { newId } from "@unkey/id"; import { auth, t } from "../../trpc"; export const createOverride = t.procedure @@ -44,45 +44,42 @@ export const createOverride = t.procedure const id = newId("ratelimitOverride"); await db .transaction(async (tx) => { - const existing = await tx - .select({ count: sql`count(*)` }) - .from(schema.ratelimitOverrides) - .where( - and( - eq(schema.ratelimitOverrides.namespaceId, namespace.id), - isNull(schema.ratelimitOverrides.deletedAt), - ), - ) - .then((res) => Number(res.at(0)?.count ?? 0)); - const max = - typeof ctx.workspace.features.ratelimitOverrides === "number" - ? ctx.workspace.features.ratelimitOverrides - : 5; - if (existing >= max) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `A plan Upgrade is required, you can only override ${max} identifiers.`, + const existing = await tx.query.ratelimitOverrides.findFirst({ + where: (table, { and, eq }) => + and(eq(table.namespaceId, namespace.id), eq(table.identifier, input.identifier)), + }); + + if (existing) { + await tx + .update(schema.ratelimitOverrides) + .set({ + limit: input.limit, + duration: input.duration, + async: input.async ?? false, + updatedAt: new Date(), + deletedAt: null, + }) + .where(sql`namespace_id = ${namespace.id} AND identifier = ${input.identifier}`); + } else { + await tx.insert(schema.ratelimitOverrides).values({ + id, + workspaceId: ctx.workspace.id, + namespaceId: namespace.id, + identifier: input.identifier, + limit: input.limit, + duration: input.duration, + async: input.async ?? false, + createdAt: new Date(), }); } - - await tx.insert(schema.ratelimitOverrides).values({ - workspaceId: ctx.workspace.id, - namespaceId: namespace.id, - identifier: input.identifier, - id, - limit: input.limit, - duration: input.duration, - createdAt: new Date(), - async: input.async, - }); await insertAuditLogs(tx, ctx.workspace.auditLogBucket.id, { workspaceId: ctx.workspace.id, actor: { type: "user", id: ctx.user.id, }, - event: "ratelimit.set_override", - description: `Created ${input.identifier}`, + event: existing ? "ratelimit.update" : "ratelimit.set_override", + description: existing ? `Updated ${input.identifier}` : `Created ${input.identifier}`, resources: [ { type: "ratelimitNamespace", @@ -90,7 +87,7 @@ export const createOverride = t.procedure }, { type: "ratelimitOverride", - id, + id: existing ? existing.id : id, }, ], context: { diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index 1af422d190..dda45ea913 100644 --- a/internal/clickhouse/src/logs.ts +++ b/internal/clickhouse/src/logs.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import type { Querier } from "./client/interface"; -import { dateTimeToUnix } from "./util"; export const getLogsClickhousePayload = z.object({ workspaceId: z.string(), @@ -190,7 +189,7 @@ export const logsTimeseriesParams = z.object({ }); export const logsTimeseriesDataPoint = z.object({ - x: dateTimeToUnix, + x: z.number().int(), y: z.object({ success: z.number().int().default(0), error: z.number().int().default(0), @@ -257,18 +256,31 @@ const INTERVALS: Record = { } as const; function createTimeseriesQuery(interval: TimeInterval, whereClause: string) { - // Map step to ClickHouse interval unit + // For SQL interval definitions const intervalUnit = { MINUTE: "minute", MINUTES: "minute", HOUR: "hour", HOURS: "hour", DAY: "day", + MONTH: "month", }[interval.step]; + // For millisecond step calculation + const msPerUnit = { + MINUTE: 60_000, + MINUTES: 60_000, + HOUR: 3600_000, + HOURS: 3600_000, + DAY: 86400_000, + MONTH: 2592000_000, + }[interval.step]; + + const stepMs = msPerUnit! * interval.stepSize; + return ` SELECT - toStartOfInterval(time, INTERVAL ${interval.stepSize} ${intervalUnit}) as x, + toUnixTimestamp64Milli(CAST(toStartOfInterval(time, INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) as x, map( 'success', SUM(IF(response_status >= 200 AND response_status < 300, count, 0)), 'warning', SUM(IF(response_status >= 400 AND response_status < 500, count, 0)), @@ -280,9 +292,9 @@ function createTimeseriesQuery(interval: TimeInterval, whereClause: string) { GROUP BY x ORDER BY x ASC WITH FILL - FROM toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({startTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) - TO toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({endTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) - STEP INTERVAL ${interval.stepSize} ${intervalUnit}`; + FROM toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({startTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) + TO toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({endTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) + STEP ${stepMs}`; } function getLogsTimeseriesWhereClause( diff --git a/internal/clickhouse/src/ratelimits.ts b/internal/clickhouse/src/ratelimits.ts index 7f49a9f256..d63e23bd1f 100644 --- a/internal/clickhouse/src/ratelimits.ts +++ b/internal/clickhouse/src/ratelimits.ts @@ -32,7 +32,7 @@ export const ratelimitLogsTimeseriesParams = z.object({ }); export const ratelimitLogsTimeseriesDataPoint = z.object({ - x: dateTimeToUnix, + x: z.number().int(), y: z.object({ passed: z.number().int().default(0), total: z.number().int().default(0), @@ -102,19 +102,20 @@ const INTERVALS: Record = { } as const; function createTimeseriesQuery(interval: TimeInterval, whereClause: string) { - // Map step to ClickHouse interval unit const intervalUnit = { - MINUTE: "minute", - MINUTES: "minute", - HOUR: "hour", - HOURS: "hour", - DAY: "day", - MONTH: "month", + MINUTE: 60_000, // milliseconds in a minute + MINUTES: 60_000, + HOUR: 3600_000, // milliseconds in an hour + HOURS: 3600_000, + DAY: 86400_000, // milliseconds in a day + MONTH: 2592000_000, // approximate milliseconds in a month (30 days) }[interval.step]; + const stepMs = intervalUnit! * interval.stepSize; + return ` SELECT - toStartOfInterval(time, INTERVAL ${interval.stepSize} ${intervalUnit}) as x, + toUnixTimestamp64Milli(CAST(toStartOfInterval(time, INTERVAL ${interval.stepSize} ${interval.step}) AS DateTime64(3))) as x, map( 'passed', sum(passed), 'total', sum(total) @@ -124,9 +125,9 @@ function createTimeseriesQuery(interval: TimeInterval, whereClause: string) { GROUP BY x ORDER BY x ASC WITH FILL - FROM toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({startTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) - TO toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({endTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) - STEP INTERVAL ${interval.stepSize} ${intervalUnit}`; + FROM toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({startTime: Int64})), INTERVAL ${interval.stepSize} ${interval.step}) AS DateTime64(3))) + TO toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({endTime: Int64})), INTERVAL ${interval.stepSize} ${interval.step}) AS DateTime64(3))) + STEP ${stepMs}`; } function getRatelimitLogsTimeseriesWhereClause( @@ -547,7 +548,6 @@ export function getRatelimitOverviewLogs(ch: Querier) { ].join(", ") || "last_request_time DESC, request_id DESC"; // Fallback if empty const extendedParamsSchema = ratelimitOverviewLogsParams.extend(paramSchemaExtension); - const query = ch.query({ query: `WITH filtered_ratelimits AS ( SELECT diff --git a/internal/icons/src/icons/dots.tsx b/internal/icons/src/icons/dots.tsx index aeb5955c87..e08af9e634 100644 --- a/internal/icons/src/icons/dots.tsx +++ b/internal/icons/src/icons/dots.tsx @@ -10,11 +10,19 @@ * https://nucleoapp.com/license */ import type React from "react"; -import type { IconProps } from "../props"; +import { type IconProps, sizeMap } from "../props"; + +export const Dots: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; -export const Dots: React.FC = (props) => { return ( - + = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> diff --git a/internal/icons/src/icons/trash.tsx b/internal/icons/src/icons/trash.tsx index 18d81a94ef..6655acf2df 100644 --- a/internal/icons/src/icons/trash.tsx +++ b/internal/icons/src/icons/trash.tsx @@ -12,10 +12,17 @@ import type React from "react"; -import type { IconProps } from "../props"; -export const Trash: React.FC = (props) => { +import { type IconProps, sizeMap } from "../props"; +export const Trash: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; return ( - + = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> =2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' dependencies: - tailwindcss: 3.4.15(ts-node@10.9.2) + tailwindcss: 3.4.15 dev: true /@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.15): @@ -10498,7 +10498,7 @@ packages: peerDependencies: tailwindcss: '>=3.2.0' dependencies: - tailwindcss: 3.4.15(ts-node@10.9.2) + tailwindcss: 3.4.15 dev: false /@tailwindcss/typography@0.5.12(tailwindcss@3.4.15): @@ -10510,7 +10510,7 @@ packages: lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.15(ts-node@10.9.2) + tailwindcss: 3.4.15 /@tailwindcss/typography@0.5.16(tailwindcss@3.4.15): resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} @@ -13834,6 +13834,18 @@ packages: dependencies: ms: 2.1.3 + /debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + /debug@4.4.0(supports-color@8.1.1): resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -16325,7 +16337,7 @@ packages: peerDependencies: next: '>=13.2.0' dependencies: - next: 14.2.15(@babel/core@7.26.8)(@opentelemetry/api@1.4.1)(react-dom@18.3.1)(react@18.3.1) + next: 14.2.15(react-dom@18.3.1)(react@18.3.1) dev: false /generate-function@2.3.1: @@ -17242,7 +17254,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: true @@ -17260,7 +17272,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: true @@ -20311,6 +20323,48 @@ packages: - '@babel/core' - babel-plugin-macros + /next@14.2.15(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.2.15 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001699 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.15 + '@next/swc-darwin-x64': 14.2.15 + '@next/swc-linux-arm64-gnu': 14.2.15 + '@next/swc-linux-arm64-musl': 14.2.15 + '@next/swc-linux-x64-gnu': 14.2.15 + '@next/swc-linux-x64-musl': 14.2.15 + '@next/swc-win32-arm64-msvc': 14.2.15 + '@next/swc-win32-ia32-msvc': 14.2.15 + '@next/swc-win32-x64-msvc': 14.2.15 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /nlcst-to-string@3.1.1: resolution: {integrity: sha512-63mVyqaqt0cmn2VcI2aH6kxe1rLAmSROqHMA0i4qqg1tidkfExgpb0FGMikMCn86mw5dFtBtEANfmSSK7TjNHw==} dependencies: @@ -20518,7 +20572,7 @@ packages: next: '>=13.4 <14.0.2 || ^14.0.3' dependencies: mitt: 3.0.1 - next: 14.2.15(@babel/core@7.26.8)(@opentelemetry/api@1.4.1)(react-dom@18.3.1)(react@18.3.1) + next: 14.2.15(react-dom@18.3.1)(react@18.3.1) dev: false /nwsapi@2.2.16: @@ -21208,6 +21262,22 @@ packages: camelcase-css: 2.0.1 postcss: 8.5.1 + /postcss-load-config@4.0.2(postcss@8.5.1): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.1.3 + postcss: 8.5.1 + yaml: 2.7.0 + /postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} @@ -23894,6 +23964,23 @@ packages: client-only: 0.0.1 react: 18.3.1 + /styled-jsx@5.1.1(react@18.3.1): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.3.1 + dev: false + /stylis@4.3.2: resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} dev: false @@ -24073,7 +24160,7 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' dependencies: - tailwindcss: 3.4.15(ts-node@10.9.2) + tailwindcss: 3.4.15 dev: false /tailwindcss@3.4.0(ts-node@10.9.2): @@ -24107,6 +24194,36 @@ packages: - ts-node dev: false + /tailwindcss@3.4.15: + resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.1 + postcss-import: 15.1.0(postcss@8.5.1) + postcss-js: 4.0.1(postcss@8.5.1) + postcss-load-config: 4.0.2(postcss@8.5.1) + postcss-nested: 6.2.0(postcss@8.5.1) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + /tailwindcss@3.4.15(ts-node@10.9.2): resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==} engines: {node: '>=14.0.0'} @@ -25397,7 +25514,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 pathe: 1.1.2 picocolors: 1.1.1 vite: 5.4.14(@types/node@20.14.9) @@ -25511,6 +25628,64 @@ packages: - terser dev: true + /vitest@1.6.0(@types/node@20.14.9)(jsdom@26.0.0): + resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.0 + '@vitest/ui': 1.6.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.14.9 + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.0 + execa: 8.0.1 + jsdom: 26.0.0 + local-pkg: 0.5.1 + magic-string: 0.30.17 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.8.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.14(@types/node@20.14.9) + vite-node: 1.6.0(@types/node@20.14.9) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + /vlq@0.2.3: resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} dev: true