diff --git a/.github/workflows/job_test_go_api_local.yaml b/.github/workflows/job_test_go_api_local.yaml index 4f429bbec7..680c727d10 100644 --- a/.github/workflows/job_test_go_api_local.yaml +++ b/.github/workflows/job_test_go_api_local.yaml @@ -1,4 +1,4 @@ -name: Test GO API Local +name: Test Go API Local on: workflow_call: diff --git a/apps/dashboard/app/(app)/audit/components/table/types.ts b/apps/dashboard/app/(app)/audit/audit.type.ts similarity index 95% rename from apps/dashboard/app/(app)/audit/components/table/types.ts rename to apps/dashboard/app/(app)/audit/audit.type.ts index c316d5a143..7c6ade9aa1 100644 --- a/apps/dashboard/app/(app)/audit/components/table/types.ts +++ b/apps/dashboard/app/(app)/audit/audit.type.ts @@ -1,4 +1,4 @@ -export type Data = { +export type AuditData = { user: | { username?: string | null; diff --git a/apps/dashboard/app/(app)/audit/components/logs-client.tsx b/apps/dashboard/app/(app)/audit/components/logs-client.tsx new file mode 100644 index 0000000000..9abf72f588 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/components/logs-client.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useState } from "react"; +import type { AuditData } from "../audit.type"; +import { AuditLogsTable } from "./table/audit-logs-table"; +import { AuditLogDetails } from "./table/log-details"; + +// INFO: Hacky way to create distance from top. This will be fixed when this page gets a refactor. +const DISTANCE_TO_TOP = 9; +export const LogsClient = () => { + const [selectedLog, setSelectedLog] = useState(null); + + return ( + <> + + + + ); +}; diff --git a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx b/apps/dashboard/app/(app)/audit/components/table/audit-logs-table.tsx similarity index 85% rename from apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx rename to apps/dashboard/app/(app)/audit/components/table/audit-logs-table.tsx index fea542f386..f0d7b3f700 100644 --- a/apps/dashboard/app/(app)/audit/components/table/audit-log-table-client.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/audit-logs-table.tsx @@ -4,12 +4,10 @@ import { VirtualTable } from "@/components/virtual-table"; import { trpc } from "@/lib/trpc/client"; import { Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; +import type { AuditData } from "../../audit.type"; import { useAuditLogParams } from "../../query-state"; import { columns } from "./columns"; import { DEFAULT_FETCH_COUNT } from "./constants"; -import { LogDetails } from "./table-details"; -import type { Data } from "./types"; import { getEventType } from "./utils"; const STATUS_STYLES: Record< @@ -17,7 +15,7 @@ const STATUS_STYLES: Record< { base: string; hover: string; selected: string } > = { create: { - base: "text-accent-11 ", + base: "text-accent-11", hover: "hover:bg-accent-3", selected: "bg-accent-3", }, @@ -38,8 +36,12 @@ const STATUS_STYLES: Record< }, }; -export const AuditLogTableClient = () => { - const [selectedLog, setSelectedLog] = useState(null); +type Props = { + selectedLog: AuditData | null; + setSelectedLog: (log: AuditData | null) => void; +}; + +export const AuditLogsTable = ({ selectedLog, setSelectedLog }: Props) => { const { setCursor, searchParams } = useAuditLogParams(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = @@ -75,7 +77,7 @@ export const AuditLogTableClient = () => { } }; - const getRowClassName = (item: Data) => { + const getRowClassName = (item: AuditData) => { const eventType = getEventType(item.auditLog.event); const style = STATUS_STYLES[eventType]; @@ -83,7 +85,6 @@ export const AuditLogTableClient = () => { 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, @@ -91,7 +92,7 @@ export const AuditLogTableClient = () => { ); }; - const getSelectedClassName = (item: Data, isSelected: boolean) => { + const getSelectedClassName = (item: AuditData, isSelected: boolean) => { if (!isSelected) { return ""; } @@ -123,9 +124,6 @@ export const AuditLogTableClient = () => { onRowClick={setSelectedLog} selectedClassName={getSelectedClassName} keyExtractor={(log) => log.auditLog.id} - renderDetails={(log, onClose, distanceToTop) => ( - - )} config={{ loadingRows: DEFAULT_FETCH_COUNT, }} diff --git a/apps/dashboard/app/(app)/audit/components/table/columns.tsx b/apps/dashboard/app/(app)/audit/components/table/columns.tsx index 5ff67689f5..c7f998c5d9 100644 --- a/apps/dashboard/app/(app)/audit/components/table/columns.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/columns.tsx @@ -3,16 +3,14 @@ import { Badge } from "@/components/ui/badge"; import type { Column } from "@/components/virtual-table/types"; import { cn } from "@unkey/ui/src/lib/utils"; import { FunctionSquare, KeySquare } from "lucide-react"; -import type { Data } from "./types"; +import type { AuditData } from "../../audit.type"; import { getEventType } from "./utils"; -export const columns: Column[] = [ +export const columns: Column[] = [ { key: "time", header: "Time", width: "150px", - headerClassName: "pl-3", - noTruncate: true, render: (log) => (
[] = [ key: "actor", header: "Actor", width: "15%", - headerClassName: "pl-3", - noTruncate: true, render: (log) => (
{log.auditLog.actor.type === "user" && log.user ? ( @@ -54,8 +50,6 @@ export const columns: Column[] = [ key: "action", header: "Action", width: "15%", - headerClassName: "pl-3", - noTruncate: true, render: (log) => { const eventType = getEventType(log.auditLog.event); const badgeClassName = cn("font-mono capitalize", { diff --git a/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-footer.tsx similarity index 93% rename from apps/dashboard/app/(app)/audit/components/table/log-footer.tsx rename to apps/dashboard/app/(app)/audit/components/table/log-details/components/log-footer.tsx index ac2540eea7..7be0419852 100644 --- a/apps/dashboard/app/(app)/audit/components/table/log-footer.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-footer.tsx @@ -1,13 +1,13 @@ "use client"; +import type { AuditData } from "@/app/(app)/audit/audit.type"; import { RequestResponseDetails } from "@/app/(app)/logs/components/table/log-details/components/request-response-details"; import { TimestampInfo } from "@/components/timestamp-info"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { FunctionSquare, KeySquare } from "lucide-react"; -import type { Data } from "./types"; type Props = { - log: Data; + log: AuditData; }; export const LogFooter = ({ log }: Props) => { @@ -65,7 +65,7 @@ export const LogFooter = ({ log }: Props) => { { label: "User Agent", description: (content) => ( - {content} + {content} ), content: log.auditLog.userAgent, tooltipContent: "Copy User Agent", @@ -80,9 +80,7 @@ export const LogFooter = ({ log }: Props) => { }, { label: "Description", - description: (content) => ( - {content} - ), + description: (content) => {content}, content: log.auditLog.description, tooltipContent: "Copy Description", tooltipSuccessMessage: "Description copied to clipboard", diff --git a/apps/dashboard/app/(app)/audit/components/table/log-header.tsx b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-header.tsx similarity index 91% rename from apps/dashboard/app/(app)/audit/components/table/log-header.tsx rename to apps/dashboard/app/(app)/audit/components/table/log-details/components/log-header.tsx index 05d31374f1..e0390b3a2b 100644 --- a/apps/dashboard/app/(app)/audit/components/table/log-header.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-header.tsx @@ -1,10 +1,10 @@ +import type { AuditData } from "@/app/(app)/audit/audit.type"; import { Badge } from "@/components/ui/badge"; import { XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import type { Data } from "./types"; type Props = { - log: Data; + log: AuditData; onClose: () => void; }; diff --git a/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-section.tsx new file mode 100644 index 0000000000..a86487e144 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/components/table/log-details/components/log-section.tsx @@ -0,0 +1,71 @@ +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)/audit/components/table/table-details.tsx b/apps/dashboard/app/(app)/audit/components/table/log-details/index.tsx similarity index 53% rename from apps/dashboard/app/(app)/audit/components/table/table-details.tsx rename to apps/dashboard/app/(app)/audit/components/table/log-details/index.tsx index 29e91188c1..943ffd3464 100644 --- a/apps/dashboard/app/(app)/audit/components/table/table-details.tsx +++ b/apps/dashboard/app/(app)/audit/components/table/log-details/index.tsx @@ -1,49 +1,51 @@ "use client"; -import { LogSection } from "@/app/(app)/logs/components/table/log-details/components/log-section"; -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"; - -type Props = { - log: Data | null; - onClose: () => void; - distanceToTop: number; -}; - -const PANEL_MAX_WIDTH = 600; -const PANEL_MIN_WIDTH = 400; +import type { AuditData } from "../../../audit.type"; +import { DEFAULT_DRAGGABLE_WIDTH, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH } from "../../../constants"; +import { LogFooter } from "./components/log-footer"; +import { LogHeader } from "./components/log-header"; +import { LogSection } from "./components/log-section"; +import { ResizablePanel } from "./resizable-panel"; const createPanelStyle = (distanceToTop: number) => ({ top: `${distanceToTop}px`, - width: "500px", + width: `${DEFAULT_DRAGGABLE_WIDTH}px`, height: `calc(100vh - ${distanceToTop}px)`, paddingBottom: "1rem", }); -export const LogDetails = ({ log, onClose, distanceToTop }: Props) => { +type Props = { + distanceToTop: number; + selectedLog: AuditData | null; + setSelectedLog: (log: AuditData | null) => void; +}; + +export const AuditLogDetails = ({ distanceToTop, selectedLog, setSelectedLog }: Props) => { const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); - if (!log) { + if (!selectedLog) { return null; } + const handleClose = () => { + setSelectedLog(null); + }; + return ( - +
- - {log.auditLog.targets.map((target) => { + + {selectedLog.auditLog.targets.map((target) => { const title = String(target.type).charAt(0).toUpperCase() + String(target.type).slice(1); return ( diff --git a/apps/dashboard/app/(app)/audit/components/table/log-details/resizable-panel.tsx b/apps/dashboard/app/(app)/audit/components/table/log-details/resizable-panel.tsx new file mode 100644 index 0000000000..029f61f84e --- /dev/null +++ b/apps/dashboard/app/(app)/audit/components/table/log-details/resizable-panel.tsx @@ -0,0 +1,79 @@ +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"; + +export 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} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/audit/constants.ts b/apps/dashboard/app/(app)/audit/constants.ts new file mode 100644 index 0000000000..6cb6707d83 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/constants.ts @@ -0,0 +1,6 @@ +export const PANEL_MAX_WIDTH = 600; +export const PANEL_MIN_WIDTH = 400; + +export const DEFAULT_DRAGGABLE_WIDTH = 500; +export const MAX_DRAGGABLE_WIDTH = 800; +export const MIN_DRAGGABLE_WIDTH = 300; diff --git a/apps/dashboard/app/(app)/audit/page.tsx b/apps/dashboard/app/(app)/audit/page.tsx index 4a1e13c357..8ace57ea3c 100644 --- a/apps/dashboard/app/(app)/audit/page.tsx +++ b/apps/dashboard/app/(app)/audit/page.tsx @@ -5,7 +5,7 @@ import { InputSearch } from "@unkey/icons"; import { Empty } from "@unkey/ui"; import { type SearchParams, getWorkspace, parseFilterParams } from "./actions"; import { Filters } from "./components/filters"; -import { AuditLogTableClient } from "./components/table/audit-log-table-client"; +import { LogsClient } from "./components/logs-client"; export const dynamic = "force-dynamic"; export const runtime = "edge"; @@ -35,7 +35,7 @@ export default async function AuditPage(props: Props) { selectedBucketName={parsedParams.bucketName} /> - + ) : ( diff --git a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx index d261ea78e8..98bf8af07b 100644 --- a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx @@ -101,7 +101,7 @@ const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPil {field === "endTime" || field === "startTime" ? ( ) : ( diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx index e82298dd5e..5bfaf6a941 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx @@ -1,12 +1,10 @@ -"use client"; - 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 { toast } from "@/components/ui/toaster"; import { Button, DateTime, type Range, type TimeUnit } from "@unkey/ui"; -import { type PropsWithChildren, useState } from "react"; +import { type PropsWithChildren, useEffect, useState } from "react"; import { processTimeFilters } from "../utils/process-time"; import { DateTimeSuggestions, type OptionsType } from "./suggestions"; @@ -74,11 +72,10 @@ const options: OptionsType = [ }, ]; -const CUSTOM_DATE_TIME = 10; - interface DatetimePopoverProps extends PropsWithChildren { - setTitle: (value: string) => void; - setSelected: (value: boolean) => void; + initialTitle: string; + initialSelected: boolean; + onStateChange: (title: string, selected: boolean) => void; } type TimeRangeType = { @@ -86,36 +83,64 @@ type TimeRangeType = { endTime?: number; }; -export const DatetimePopover = ({ children, setTitle, setSelected }: DatetimePopoverProps) => { +export const DatetimePopover = ({ + children, + initialTitle, + initialSelected, + onStateChange, +}: DatetimePopoverProps) => { const { filters, updateFilters } = useFilters(); + const [title, setTitle] = useState(initialTitle); + const [selected, setSelected] = useState(initialSelected); + const [open, setOpen] = useState(false); + + useEffect(() => { + onStateChange(title, selected); + }, [title, selected, onStateChange]); const sinceFilter = filters.find((f) => f.field === "since"); const startTimeFilter = filters.find((f) => f.field === "startTime"); const endTimeFilter = filters.find((f) => f.field === "endTime"); - let initialSuggestions = [...options]; + const [suggestions, setSuggestions] = useState(() => { + const initialSuggestions = [...options]; - if (sinceFilter) { - const matchingSuggestion = initialSuggestions.find((s) => s.value === sinceFilter.value); - if (matchingSuggestion) { - initialSuggestions = initialSuggestions.map((s) => ({ + if (sinceFilter) { + const matchingSuggestion = options.find((s) => s.value === sinceFilter.value); + if (matchingSuggestion) { + return initialSuggestions.map((s) => ({ + ...s, + checked: s.id === matchingSuggestion.id, + })); + } + } else if (startTimeFilter) { + return initialSuggestions.map((s) => ({ ...s, - checked: s.id === matchingSuggestion.id, + checked: s.id === CUSTOM_OPTION_ID, })); - setTitle(matchingSuggestion.display); - setSelected(true); } - } else if (startTimeFilter) { - initialSuggestions = initialSuggestions.map((s) => ({ - ...s, - checked: s.id === CUSTOM_DATE_TIME, - })); - setTitle("Custom"); - setSelected(true); - } - const [open, setOpen] = useState(false); - const [suggestions, setSuggestions] = useState(initialSuggestions); + return initialSuggestions; + }); + + useEffect(() => { + let newTitle = initialTitle; + let newSelected = initialSelected; + + if (sinceFilter) { + const matchingSuggestion = options.find((s) => s.value === sinceFilter.value); + if (matchingSuggestion) { + newTitle = matchingSuggestion.display; + newSelected = true; + } + } else if (startTimeFilter) { + newTitle = "Custom"; + newSelected = true; + } + + setTitle(newTitle); + setSelected(newSelected); + }, [sinceFilter, startTimeFilter, initialTitle, initialSelected]); const [time, setTime] = useState({ startTime: startTimeFilter?.value as number | undefined, endTime: endTimeFilter?.value as number | undefined, @@ -136,10 +161,10 @@ export const DatetimePopover = ({ children, setTitle, setSelected }: DatetimePop setSuggestions(tempSuggestions); const selectedSuggestion = tempSuggestions.find((s) => s.checked)?.value; - if (id === CUSTOM_OPTION_ID) { return; } + const activeFilters = filters.filter((f) => !["since"].includes(f.field)); if (selectedSuggestion) { activeFilters.push({ @@ -149,15 +174,15 @@ export const DatetimePopover = ({ children, setTitle, setSelected }: DatetimePop id: crypto.randomUUID(), }); } - updateFilters(activeFilters); }; const onDateTimeChange = (newRange?: Range, newStart?: TimeUnit, newEnd?: TimeUnit) => { - const custom = suggestions.find((suggestion) => suggestion.id === CUSTOM_DATE_TIME); + const custom = suggestions.find((suggestion) => suggestion.id === CUSTOM_OPTION_ID); if (!custom?.checked) { - handleSuggestionChange(CUSTOM_DATE_TIME); + handleSuggestionChange(CUSTOM_OPTION_ID); } + const startTimestamp = processTimeFilters(newRange?.from, newStart)?.getTime(); const endTimestamp = processTimeFilters(newRange?.to, newEnd)?.getTime(); setTime({ 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 index dd8a5bec8e..75fb3e461e 100644 --- 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 @@ -9,7 +9,14 @@ export const LogsDateTime = () => { const [isSelected, setIsSelected] = useState(false); return ( - + { + setTitle(newTitle); + setIsSelected(newSelected); + }} + >
-
+
{" "} + {searchText.length > 0 && !isLoading && ( + + )} -
- -
+ {searchText.length === 0 && !isLoading && ( +
+ +
+ )}
diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx index c023482b97..659fffffa1 100644 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx @@ -84,13 +84,18 @@ export const LogFooter = ({ log }: Props) => { { label: "Permissions", description: (content) => ( - - {content.map((permission) => ( - +
+ {content.map((permission, index) => ( + {permission} ))} - +
), content: extractResponseField(log, "permissions"), tooltipContent: "Copy Permissions", diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx index 7c2a533a76..167dcf20af 100644 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx @@ -21,7 +21,7 @@ export const LogMetaSection = ({ content }: { content: string }) => {
Meta
-
{content}
+
{content ?? ""}
diff --git a/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx b/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx index a99e32d50a..8fc307b108 100644 --- a/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/logs-table.tsx @@ -152,49 +152,41 @@ export const LogsTable = () => { { key: "time", header: "Time", - width: "165px", - headerClassName: "pl-9", - noTruncate: true, + width: "5%", render: (log) => ( -
- -
+ ), }, { key: "response_status", header: "Status", width: "7.5%", - noTruncate: true, render: (log) => { const style = getStatusStyle(log.response_status); const isSelected = selectedLog?.request_id === log.request_id; return ( -
- - {log.response_status}{" "} - {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} - -
+ + {log.response_status}{" "} + {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} + ); }, }, { key: "method", header: "Method", - width: "78px", - noTruncate: true, + width: "7.5%", render: (log) => { const isSelected = selectedLog?.request_id === log.request_id; return ( @@ -208,7 +200,7 @@ export const LogsTable = () => { key: "path", header: "Path", width: "15%", - render: (log) =>
{log.path}
, + render: (log) =>
{log.path}
, }, ], [selectedLog?.request_id], @@ -224,7 +216,7 @@ export const LogsTable = () => { const originalRender = filtered[0].render; filtered[0] = { ...filtered[0], - headerClassName: "pl-9", + headerClassName: "pl-8", render: (log: Log) => (
diff --git a/apps/dashboard/app/(app)/logs/utils.ts b/apps/dashboard/app/(app)/logs/utils.ts index 1e12198a4a..e78b1a82d5 100644 --- a/apps/dashboard/app/(app)/logs/utils.ts +++ b/apps/dashboard/app/(app)/logs/utils.ts @@ -16,16 +16,6 @@ export type ResponseBody = { | "INSUFFICIENT_PERMISSIONS"; }; -class ResponseBodyParseError extends Error { - constructor( - message: string, - public readonly context?: unknown, - ) { - super(message); - this.name = "ResponseBodyParseError"; - } -} - export const extractResponseField = ( log: Log, fieldName: K, @@ -38,27 +28,8 @@ export const extractResponseField = ( 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"); - } + } catch { return null; } }; diff --git a/apps/dashboard/app/(app)/workspace-navigations.tsx b/apps/dashboard/app/(app)/workspace-navigations.tsx index 1b41d32276..f249a0ddf0 100644 --- a/apps/dashboard/app/(app)/workspace-navigations.tsx +++ b/apps/dashboard/app/(app)/workspace-navigations.tsx @@ -97,7 +97,6 @@ export const createWorkspaceNavigation = ( label: "Logs", active: segments.at(0) === "logs", tag: , - hidden: !workspace.betaFeatures.logsPage, }, { icon: Crown, diff --git a/apps/dashboard/components/timestamp-info.tsx b/apps/dashboard/components/timestamp-info.tsx index 66ac369f57..701167423e 100644 --- a/apps/dashboard/components/timestamp-info.tsx +++ b/apps/dashboard/components/timestamp-info.tsx @@ -26,7 +26,7 @@ const timestampUtcFormatter = (value: string | number) => { const date = isUnixMicro(value) ? unixMicroToDate(value) : new Date(value); const isoDate = date.toISOString(); const utcDate = `${isoDate.substring(0, 10)} ${isoDate.substring(11, 19)}`; - return format(utcDate, "MMM dd HH:mm:ss"); + return format(utcDate, "MMM d,yyyy HH:mm:ss"); }; const timestampRelativeFormatter = (value: string | number) => { @@ -81,9 +81,7 @@ export const TimestampInfo = ({ className="flex items-center hover:bg-gray-3 text-left cursor-pointer w-full px-5 py-2" > {label} - + {copied ? "Copied!" : value} diff --git a/apps/dashboard/components/virtual-table/components/loading-row.tsx b/apps/dashboard/components/virtual-table/components/loading-row.tsx deleted file mode 100644 index 85058b00d9..0000000000 --- a/apps/dashboard/components/virtual-table/components/loading-row.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Column } from "../types"; - -export const LoadingRow = ({ - columns, -}: { - columns: Column[]; -}) => ( -
col.width).join(" ") }} - > - {columns.map((column) => ( -
-
-
- ))} -
-); diff --git a/apps/dashboard/components/virtual-table/components/table-header.tsx b/apps/dashboard/components/virtual-table/components/table-header.tsx deleted file mode 100644 index f864fbce87..0000000000 --- a/apps/dashboard/components/virtual-table/components/table-header.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Column } from "../types"; - -export const TableHeader = ({ - columns, -}: { - columns: Column[]; -}) => ( - <> -
col.width).join(" "), - }} - > - {columns.map((column) => ( -
-
{column.header}
-
- ))} -
-
- -); diff --git a/apps/dashboard/components/virtual-table/components/table-row.tsx b/apps/dashboard/components/virtual-table/components/table-row.tsx deleted file mode 100644 index 7c17c8260a..0000000000 --- a/apps/dashboard/components/virtual-table/components/table-row.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { cn } from "@/lib/utils"; -import type { VirtualItem } from "@tanstack/react-virtual"; -import type { Column } from "../types"; - -const calculateGridTemplateColumns = (columns: Column[]) => { - return columns - .map((column) => { - if (typeof column.width === "number") { - return `${column.width}px`; - } - - if (typeof column.width === "string") { - // Handle existing pixel and percentage values - if (column.width.endsWith("px") || column.width.endsWith("%")) { - return column.width; - } - if (column.width === "auto") { - return "minmax(min-content, auto)"; - } - if (column.width === "min") { - return "min-content"; - } - if (column.width === "1fr") { - return "1fr"; - } - } - - if (typeof column.width === "object") { - if ("min" in column.width && "max" in column.width) { - return `minmax(${column.width.min}px, ${column.width.max}px)`; - } - if ("flex" in column.width) { - return `${column.width.flex}fr`; - } - } - - return "1fr"; // Default fallback - }) - .join(" "); -}; - -export const TableRow = ({ - item, - columns, - virtualRow, - rowHeight, - isSelected, - rowClassName, - selectedClassName, - onClick, - onRowClick, - measureRef, -}: { - item: T; - columns: Column[]; - virtualRow: VirtualItem; - rowHeight: number; - isSelected: boolean; - rowClassName?: (item: T) => string; - selectedClassName?: (item: T, isSelected: boolean) => string; - onClick: () => void; - onRowClick?: (item: T | null) => void; - measureRef: (element: HTMLElement | null) => void; -}) => { - const gridTemplateColumns = calculateGridTemplateColumns(columns); - - return ( -
{ - if (event.key === "Escape") { - event.preventDefault(); - onRowClick?.(null); - const activeElement = document.activeElement as HTMLElement; - activeElement?.blur(); - } - if (event.key === "ArrowDown" || event.key === "j") { - event.preventDefault(); - const nextElement = document.querySelector( - `[data-index="${virtualRow.index + 1}"]`, - ) as HTMLElement; - if (nextElement) { - nextElement.focus(); - nextElement.click(); - } - } - if (event.key === "ArrowUp" || event.key === "k") { - event.preventDefault(); - const prevElement = document.querySelector( - `[data-index="${virtualRow.index - 1}"]`, - ) as HTMLElement; - if (prevElement) { - prevElement.focus(); - prevElement.click(); - } - } - }} - ref={measureRef} - onClick={onClick} - className={cn( - "grid text-xs cursor-pointer absolute top-0 left-0 w-full mt-1", - "transition-all duration-75 ease-in-out", - "group", - rowClassName?.(item), - selectedClassName?.(item, isSelected), - )} - style={{ - gridTemplateColumns, - height: `${rowHeight}px`, - position: "absolute", - top: 0, - left: 0, - transform: `translateY(${virtualRow.start}px)`, - }} - > - {columns.map((column) => ( -
- {column.noTruncate ? ( - column.render(item) - ) : ( -
{column.render(item)}
- )} -
- ))} -
- ); -}; diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index b5f5fc6dfe..b6894c421f 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -1,17 +1,31 @@ import { cn } from "@/lib/utils"; import { CircleCarretRight } from "@unkey/icons"; -import { useCallback, useRef, useState } from "react"; -import { useScrollLock } from "usehooks-ts"; +import { Fragment, useMemo, useRef } from "react"; import { EmptyState } from "./components/empty-state"; import { LoadingIndicator } from "./components/loading-indicator"; -import { LoadingRow } from "./components/loading-row"; -import { TableHeader } from "./components/table-header"; -import { TableRow } from "./components/table-row"; import { DEFAULT_CONFIG } from "./constants"; import { useTableData } from "./hooks/useTableData"; import { useTableHeight } from "./hooks/useTableHeight"; import { useVirtualData } from "./hooks/useVirtualData"; -import type { VirtualTableProps } from "./types"; +import type { Column, SeparatorItem, VirtualTableProps } from "./types"; + +const calculateTableLayout = (columns: Column[]) => { + return columns.map((column) => { + let width = "auto"; + if (typeof column.width === "number") { + width = `${column.width}px`; + } else if (typeof column.width === "string") { + width = column.width; + } else if (typeof column.width === "object") { + if ("min" in column.width && "max" in column.width) { + width = `${column.width.min}px`; + } else if ("flex" in column.width) { + width = "auto"; + } + } + return { width }; + }); +}; export function VirtualTable({ data: historicData, @@ -26,17 +40,15 @@ export function VirtualTable({ rowClassName, selectedClassName, selectedItem, - renderDetails, isFetchingNextPage, }: VirtualTableProps) { const config = { ...DEFAULT_CONFIG, ...userConfig }; const parentRef = useRef(null); const containerRef = useRef(null); - const [tableDistanceToTop, setTableDistanceToTop] = useState(0); const fixedHeight = useTableHeight(containerRef, config.headerHeight, config.tableBorder); - const tableData = useTableData(realtimeData, historicData); + const virtualizer = useVirtualData({ totalDataLength: tableData.getTotalLength(), isLoading, @@ -46,136 +58,199 @@ export function VirtualTable({ parentRef, }); - useScrollLock({ - autoLock: true, - lockTarget: - typeof window !== "undefined" - ? (document.querySelector("#layout-wrapper") as HTMLElement) - : undefined, - }); - - const handleRowClick = useCallback( - (item: TTableData) => { - if (onRowClick) { - onRowClick(item); - setTableDistanceToTop( - (parentRef.current?.getBoundingClientRect().top ?? 0) + - window.scrollY - - config.tableBorder, - ); - } - }, - [onRowClick, config.tableBorder], - ); + const colWidths = useMemo(() => calculateTableLayout(columns), [columns]); if (!isLoading && historicData.length === 0 && realtimeData.length === 0) { return (
- - + + + + {columns.map((column) => ( + + ))} + + + + + +
+
{column.header}
+
+
+
+ {emptyState ? ( +
{emptyState}
+ ) : ( + + )}
); } return (
-
-
- {virtualizer.getVirtualItems().map((virtualRow) => { - if (isLoading) { - return ( -
- -
- ); - } - - const item = tableData.getItemAt(virtualRow.index); - if (!item) { - return null; - } + + + {colWidths.map((col, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + ))} + - // Render separator - //@ts-expect-error This is our hacky way to separate live data from historic data. This separator acts as just another item in the data list to preserve the correct start position. - if ("isSeparator" in item && item.isSeparator) { - return ( -
+
+ {columns.map((column) => ( + + ))} + + + + + - // Regular row - const typedItem = item as TTableData; - const isSelected = selectedItem - ? keyExtractor(selectedItem) === keyExtractor(typedItem) - : false; + + + {virtualizer.getVirtualItems().map((virtualRow) => { + if (isLoading) { + return ( + + {columns.map((column) => ( + + ))} + + ); + } + const item = tableData.getItemAt(virtualRow.index); + if (!item) { + return null; + } - return ( - handleRowClick(typedItem)} - measureRef={virtualizer.measureElement} - onRowClick={onRowClick} - /> - ); - })} - + const separator = item as SeparatorItem; + if (separator.isSeparator) { + return ( + + + + + + + ); + } - {isFetchingNextPage && } + const typedItem = item as TTableData; + const isSelected = selectedItem + ? keyExtractor(selectedItem) === keyExtractor(typedItem) + : false; - {selectedItem && - renderDetails && - renderDetails(selectedItem, () => onRowClick?.(null), tableDistanceToTop)} + return ( + + + onRowClick?.(typedItem)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onRowClick?.(null); + const activeElement = document.activeElement as HTMLElement; + activeElement?.blur(); + } + if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + const nextElement = document.querySelector( + `[data-index="${virtualRow.index + 1}"]`, + ) as HTMLElement; + if (nextElement) { + nextElement.focus(); + nextElement.click(); + } + } + if (event.key === "ArrowUp" || event.key === "k") { + event.preventDefault(); + const prevElement = document.querySelector( + `[data-index="${virtualRow.index - 1}"]`, + ) as HTMLElement; + if (prevElement) { + prevElement.focus(); + prevElement.click(); + } + } + }} + className={cn( + "cursor-pointer transition-colors hover:bg-accent/50 focus:outline-none focus:ring-1 focus:ring-opacity-40", + rowClassName?.(typedItem), + selectedClassName?.(typedItem, isSelected), + )} + style={{ height: `${config.rowHeight}px` }} + > + {columns.map((column, idx) => ( + + ))} + + + ); + })} + + +
-
- - Live -
- - ); - } +
{column.header}
+
+
+
+
+
+
+ + Live +
+
+ {column.render(typedItem)} +
+ {isFetchingNextPage && }
); diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index c8c9870b81..473ca8b02a 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -12,10 +12,7 @@ export type Column = { header?: string; width: ColumnWidth; headerClassName?: string; - minWidth?: number; - maxWidth?: number; render: (item: T) => React.ReactNode; - noTruncate?: boolean; // Add this to disable truncation for specific columns }; export type TableConfig = { @@ -42,7 +39,6 @@ export type VirtualTableProps = { selectedClassName?: (item: T, isSelected: boolean) => string; selectedItem?: T | null; isFetchingNextPage?: boolean; - renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; }; export type SeparatorItem = { diff --git a/apps/dashboard/lib/trpc/routers/workspace/optIntoBeta.ts b/apps/dashboard/lib/trpc/routers/workspace/optIntoBeta.ts index f2866b2daf..ca4d9a39e0 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/optIntoBeta.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/optIntoBeta.ts @@ -45,10 +45,6 @@ export const optWorkspaceIntoBeta = t.procedure workspace.betaFeatures.ratelimit = true; break; } - case "logsPage": { - workspace.betaFeatures.logsPage = true; - break; - } } await db .transaction(async (tx) => { diff --git a/go/Taskfile.yml b/go/Taskfile.yml index 7cc9fc1b04..7a3ace067b 100644 --- a/go/Taskfile.yml +++ b/go/Taskfile.yml @@ -14,7 +14,7 @@ tasks: build: cmds: - - go build -o unkey ./cmd/main.go + - go build -o unkey ./main.go lint: