diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/log-footer.tsx b/apps/dashboard/app/(app)/audit/[bucket]/components/log-footer.tsx new file mode 100644 index 0000000000..145510147c --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/log-footer.tsx @@ -0,0 +1,99 @@ +"use client"; + +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 "./table/types"; + +type Props = { + log: Data; +}; + +export const LogFooter = ({ log }: Props) => { + return ( + ( + + ), + content: log.auditLog.time, + tooltipContent: "Copy Time", + tooltipSuccessMessage: "Time copied to clipboard", + }, + { + label: "Location", + description: (content) => {content}, + content: log.auditLog.location, + tooltipContent: "Copy Location", + tooltipSuccessMessage: "Location copied to clipboard", + }, + { + label: "Actor Details", + description: (content) => { + const { log, user } = content; + return log.actor.type === "user" && user?.imageUrl ? ( +
+ + + {user?.username?.slice(0, 2)} + + + {`${user?.firstName ?? ""} ${user?.lastName ?? ""}`} + +
+ ) : log.actor.type === "key" ? ( +
+ + {log.actor.id} +
+ ) : ( +
+ + {log.actor.id} +
+ ); + }, + content: { log: log.auditLog, user: log.user }, + className: "whitespace-pre", + tooltipContent: "Copy Actor", + tooltipSuccessMessage: "Actor copied to clipboard", + }, + { + label: "User Agent", + description: (content) => ( + {content} + ), + content: log.auditLog.userAgent, + tooltipContent: "Copy User Agent", + tooltipSuccessMessage: "User Agent copied to clipboard", + }, + { + label: "Event", + description: (content) => {content}, + content: log.auditLog.event, + tooltipContent: "Copy Event", + tooltipSuccessMessage: "Event copied to clipboard", + }, + { + label: "Description", + description: (content) => ( + {content} + ), + content: log.auditLog.description, + tooltipContent: "Copy Description", + tooltipSuccessMessage: "Description copied to clipboard", + }, + { + label: "Workspace Id", + description: (content) => {content}, + content: log.auditLog.workspaceId, + tooltipContent: "Copy Workspace Id", + tooltipSuccessMessage: "Workspace Id copied to clipboard", + }, + ]} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/log-header.tsx b/apps/dashboard/app/(app)/audit/[bucket]/components/log-header.tsx new file mode 100644 index 0000000000..e09ebc9601 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/log-header.tsx @@ -0,0 +1,26 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@unkey/ui"; +import { X } from "lucide-react"; +import type { Data } from "./table/types"; + +type Props = { + log: Data; + onClose: () => void; +}; + +export const LogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.auditLog.event} + +
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/columns.tsx b/apps/dashboard/app/(app)/audit/[bucket]/components/table/columns.tsx new file mode 100644 index 0000000000..5186f46115 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/columns.tsx @@ -0,0 +1,80 @@ +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import type { Column } from "@/components/virtual-table"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { FunctionSquare, KeySquare } from "lucide-react"; +import type { Data } from "./types"; +import { getEventType } from "./utils"; + +export const columns: Column[] = [ + { + key: "time", + header: "Time", + width: "130px", + render: (log) => ( + + ), + }, + { + key: "actor", + header: "Actor", + width: "10%", + render: (log) => ( +
+ {log.auditLog.actor.type === "user" && log.user ? ( +
+ {`${log.user.firstName ?? ""} ${ + log.user.lastName ?? "" + }`} +
+ ) : log.auditLog.actor.type === "key" ? ( +
+ + {log.auditLog.actor.id} +
+ ) : ( +
+ + {log.auditLog.actor.id} +
+ )} +
+ ), + }, + { + key: "action", + header: "Action", + width: "72px", + render: (log) => { + const eventType = getEventType(log.auditLog.event); + const badgeClassName = cn("font-mono capitalize", { + "bg-error-3 text-error-11 hover:bg-error-4": eventType === "delete", + "bg-warning-3 text-warning-11 hover:bg-warning-4": eventType === "update", + "bg-success-3 text-success-11 hover:bg-success-4": eventType === "create", + "bg-accent-3 text-accent-11 hover:bg-accent-4": eventType === "other", + }); + return {eventType}; + }, + }, + { + key: "event", + header: "Event", + width: "20%", + render: (log) => ( +
+ {log.auditLog.event} +
+ ), + }, + { + key: "event-description", + header: "Description", + width: "auto", + render: (log) => ( +
{log.auditLog.description}
+ ), + }, +]; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/constants.ts b/apps/dashboard/app/(app)/audit/[bucket]/components/table/constants.ts new file mode 100644 index 0000000000..f3d0d4a6e2 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/constants.ts @@ -0,0 +1,59 @@ +export const DEFAULT_FETCH_COUNT = 50; + +export const eventGroups = { + create: [ + "workspace.create", + "gateway.create", + "llmGateway.create", + "api.create", + "key.create", + "ratelimitNamespace.create", + "vercelIntegration.create", + "vercelBinding.create", + "role.create", + "permission.create", + "secret.create", + "webhook.create", + "reporter.create", + "identity.create", + "ratelimit.create", + "auditLogBucket.create", + ], + delete: [ + "workspace.delete", + "llmGateway.delete", + "api.delete", + "key.delete", + "ratelimitNamespace.delete", + "vercelIntegration.delete", + "vercelBinding.delete", + "role.delete", + "permission.delete", + "webhook.delete", + "identity.delete", + "ratelimit.delete", + "ratelimit.delete_override", + ], + update: [ + "workspace.update", + "api.update", + "key.update", + "ratelimitNamespace.update", + "vercelIntegration.update", + "vercelBinding.update", + "role.update", + "permission.update", + "secret.update", + "webhook.update", + "identity.update", + "ratelimit.update", + "authorization.connect_role_and_permission", + "authorization.disconnect_role_and_permissions", + "authorization.connect_role_and_key", + "authorization.disconnect_role_and_key", + "authorization.connect_permission_and_key", + "authorization.disconnect_permission_and_key", + "ratelimit.set_override", + ], + other: ["workspace.opt_in", "secret.decrypt", "ratelimit.read_override"], +} as const; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/index.tsx b/apps/dashboard/app/(app)/audit/[bucket]/components/table/index.tsx new file mode 100644 index 0000000000..87e87d12a7 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/index.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { EmptyPlaceholder } from "@/components/dashboard/empty-placeholder"; +import { VirtualTable } from "@/components/virtual-table"; +import { trpc } from "@/lib/trpc/client"; +import type { User } from "@clerk/nextjs/server"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useEffect, useState } from "react"; +import type { AuditLogWithTargets } from "../../page"; +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"; + +export const AuditTable = ({ + data: initialData, + users, +}: { + data: AuditLogWithTargets[]; + users: Record; +}) => { + const [selectedLog, setSelectedLog] = useState(null); + const { setCursor, searchParams } = useAuditLogParams(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: including setCursor causes infinite loop + useEffect(() => { + // Only set the cursor if we have initial data and no cursor in URL params + if (initialData.length > 0 && !searchParams.cursorId) { + setCursor({ + time: initialData[initialData.length - 1].time, + id: initialData[initialData.length - 1].id, + }); + } + }, [initialData, searchParams.cursorId]); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = + trpc.audit.fetch.useInfiniteQuery( + { + bucket: searchParams.bucket, + limit: DEFAULT_FETCH_COUNT, + users: searchParams.users, + events: searchParams.events, + rootKeys: searchParams.rootKeys, + }, + { + initialCursor: searchParams.cursorId + ? { + time: searchParams.cursorTime, + id: searchParams.cursorId, + } + : undefined, + getNextPageParam: (lastPage) => { + return lastPage.nextCursor; + }, + //Breaks the paginated data when refreshing because of cursorTime and cursorId + staleTime: Number.POSITIVE_INFINITY, + keepPreviousData: false, + initialData: + !searchParams.cursorId && initialData.length > 0 + ? { + pages: [ + { + items: initialData, + nextCursor: + initialData.length === DEFAULT_FETCH_COUNT + ? { + time: initialData[initialData.length - 1].time, + id: initialData[initialData.length - 1].id, + } + : undefined, + }, + ], + pageParams: [undefined], + } + : undefined, + }, + ); + + const flattenedData = + data?.pages.flatMap((page) => + page.items.map((l) => { + const user = users[l.actorId]; + return { + user: user + ? { + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + imageUrl: user.imageUrl, + } + : undefined, + auditLog: { + id: l.id, + time: l.time, + actor: { + id: l.actorId, + name: l.actorName, + type: l.actorType, + }, + location: l.remoteIp, + description: l.display, + userAgent: l.userAgent, + event: l.event, + workspaceId: l.workspaceId, + targets: l.targets.map((t) => ({ + id: t.id, + type: t.type, + name: t.name, + meta: t.meta, + })), + }, + }; + }), + ) ?? []; + + const handleLoadMore = () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage().then((result) => { + const lastPage = result.data?.pages[result.data.pages.length - 1]; + if (lastPage?.nextCursor) { + setCursor(lastPage.nextCursor); + } + }); + } + }; + + const getRowClassName = (item: Data) => { + const eventType = getEventType(item.auditLog.event); + return cn({ + "hover:bg-error-3": eventType === "delete", + "hover:bg-warning-3": eventType === "update", + "hover:bg-success-3": eventType === "create", + }); + }; + + const getSelectedClassName = (item: Data, isSelected: boolean) => { + if (!isSelected) { + return ""; + } + + const eventType = getEventType(item.auditLog.event); + return cn({ + "bg-error-3": eventType === "delete", + "bg-warning-3": eventType === "update", + "bg-success-3": eventType === "create", + "bg-accent-3": eventType === "other", + }); + }; + + if (isError) { + return ( + +
+
+
Failed to load audit logs
+
+ There was a problem fetching the audit logs. Please try refreshing the page or contact + support if the issue persists. +
+
+
+
+ ); + } + + return ( + log.auditLog.id} + renderDetails={(log, onClose, distanceToTop) => ( + + )} + loadingRows={DEFAULT_FETCH_COUNT} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/table-details.tsx b/apps/dashboard/app/(app)/audit/[bucket]/components/table/table-details.tsx new file mode 100644 index 0000000000..8acc63974f --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/table-details.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { LogSection } from "@/app/(app)/logs/components/table/log-details/components/log-section"; +import { memo, useMemo, useState } from "react"; +import { useDebounceCallback } from "usehooks-ts"; +import ResizablePanel from "../../../../logs/components/table/log-details/resizable-panel"; +import { LogFooter } from "../log-footer"; +import { LogHeader } from "../log-header"; +import type { Data } from "./types"; + +type Props = { + log: Data | null; + onClose: () => void; + distanceToTop: number; +}; + +const DEFAULT_DRAGGABLE_WIDTH = 450; +const PANEL_WIDTH_SET_DELAY = 150; + +const _LogDetails = ({ log, onClose, distanceToTop }: Props) => { + const [panelWidth, setPanelWidth] = useState(DEFAULT_DRAGGABLE_WIDTH); + + const debouncedSetPanelWidth = useDebounceCallback((newWidth) => { + setPanelWidth(newWidth); + }, PANEL_WIDTH_SET_DELAY); + + const panelStyle = useMemo( + () => ({ + top: `${distanceToTop}px`, + width: `${panelWidth}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", + }), + [distanceToTop, panelWidth], + ); + + if (!log) { + return null; + } + + return ( + + +
+
+ + + {log.auditLog.targets.map((target) => { + const title = String(target.type).charAt(0).toUpperCase() + String(target.type).slice(1); + + return ( + + ); + })} +
+ + ); +}; + +export const LogDetails = memo( + _LogDetails, + (prev, next) => prev.log?.auditLog.id === next.log?.auditLog.id, +); diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/types.ts b/apps/dashboard/app/(app)/audit/[bucket]/components/table/types.ts new file mode 100644 index 0000000000..c316d5a143 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/types.ts @@ -0,0 +1,30 @@ +export type Data = { + user: + | { + username?: string | null; + firstName?: string | null; + lastName?: string | null; + imageUrl?: string | null; + } + | undefined; + auditLog: { + id: string; + time: number; + actor: { + id: string; + type: string; + name: string | null; + }; + event: string; + location: string | null; + userAgent: string | null; + workspaceId: string | null; + targets: Array<{ + id: string; + type: string; + name: string | null; + meta: unknown; + }>; + description: string; + }; +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/components/table/utils.ts b/apps/dashboard/app/(app)/audit/[bucket]/components/table/utils.ts new file mode 100644 index 0000000000..07b09610bc --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/components/table/utils.ts @@ -0,0 +1,16 @@ +import { eventGroups } from "./constants"; +export const getEventType = (event: string) => { + //@ts-expect-error passing the event as string to make it easier to use + if (eventGroups.create.includes(event)) { + return "create"; + } + //@ts-expect-error passing the event as string to make it easier to use + if (eventGroups.delete.includes(event)) { + return "delete"; + } + //@ts-expect-error passing the event as string to make it easier to use + if (eventGroups.update.includes(event)) { + return "update"; + } + return "other"; +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/filter.tsx b/apps/dashboard/app/(app)/audit/[bucket]/filter.tsx index 58e8efa5ee..b51ecc3bbe 100644 --- a/apps/dashboard/app/(app)/audit/[bucket]/filter.tsx +++ b/apps/dashboard/app/(app)/audit/[bucket]/filter.tsx @@ -43,32 +43,34 @@ export const Filter: React.FC = ({ options, title, param }) => { return ( - - + +
+ +
@@ -80,6 +82,7 @@ export const Filter: React.FC = ({ options, title, param }) => { const isSelected = selected.includes(option.value); return (
handleSelection(option.value, isSelected)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -89,7 +92,6 @@ export const Filter: React.FC = ({ options, title, param }) => { }} > { const next = isSelected ? selected.filter((v) => v !== option.value) diff --git a/apps/dashboard/app/(app)/audit/[bucket]/page.tsx b/apps/dashboard/app/(app)/audit/[bucket]/page.tsx index 6330fdee9d..010cae67cf 100644 --- a/apps/dashboard/app/(app)/audit/[bucket]/page.tsx +++ b/apps/dashboard/app/(app)/audit/[bucket]/page.tsx @@ -1,8 +1,6 @@ import { EmptyPlaceholder } from "@/components/dashboard/empty-placeholder"; -import { Loading } from "@/components/dashboard/loading"; import { Navbar } from "@/components/navbar"; import { PageContent } from "@/components/page-content"; -import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { getTenantId } from "@/lib/auth"; import { db } from "@/lib/db"; import { clerkClient } from "@clerk/nextjs"; @@ -17,8 +15,9 @@ import { redirect } from "next/navigation"; import { parseAsArrayOf, parseAsString } from "nuqs/server"; import { Suspense } from "react"; import { BucketSelect } from "./bucket-select"; +import { AuditTable } from "./components/table"; +import { DEFAULT_FETCH_COUNT } from "./components/table/constants"; import { Filter } from "./filter"; -import { Row } from "./row"; export const dynamic = "force-dynamic"; export const runtime = "edge"; @@ -28,14 +27,13 @@ type Props = { bucket: string; }; searchParams: { - before?: number; events?: string | string[]; users?: string | string[]; rootKeys?: string | string[]; }; }; -type AuditLogWithTargets = SelectAuditLog & { +export type AuditLogWithTargets = SelectAuditLog & { targets: Array; }; @@ -44,27 +42,6 @@ type AuditLogWithTargets = SelectAuditLog & { */ const filterParser = parseAsArrayOf(parseAsString).withDefault([]); -/** - * Utility to map log with targets to log entry - */ -const toLogEntry = (l: AuditLogWithTargets) => ({ - id: l.id, - event: l.event, - time: l.time, - actor: { - id: l.actorId, - name: l.actorName, - type: l.actorType, - }, - location: l.remoteIp, - description: l.display, - targets: l.targets.map((t) => ({ - id: t.id, - type: t.type, - name: t.name, - })), -}); - export default async function AuditPage(props: Props) { const tenantId = getTenantId(); const workspace = await db.query.workspaces.findFirst({ @@ -80,6 +57,7 @@ export default async function AuditPage(props: Props) { }, }, }); + if (!workspace) { return redirect("/auth/signin"); } @@ -96,7 +74,6 @@ export default async function AuditPage(props: Props) { const retentionCutoffUnixMilli = Date.now() - retentionDays * 24 * 60 * 60 * 1000; const selectedActorIds = [...selectedRootKeys, ...selectedUsers]; - const bucket = await db.query.auditLogBucket.findFirst({ where: (table, { eq, and }) => and(eq(table.workspaceId, workspace.id), eq(table.name, props.params.bucket)), @@ -108,12 +85,11 @@ export default async function AuditPage(props: Props) { gte(table.createdAt, retentionCutoffUnixMilli), selectedActorIds.length > 0 ? inArray(table.actorId, selectedActorIds) : undefined, ), - with: { targets: true, }, orderBy: (table, { desc }) => desc(table.time), - limit: 100, + limit: DEFAULT_FETCH_COUNT, }, }, }); @@ -130,7 +106,7 @@ export default async function AuditPage(props: Props) { -
+
0 || selectedRootKeys.length > 0 ? ( - ) : null}
- - - - - - } - > - {!bucket ? ( - - - - - Bucket Not Found - - The specified audit log bucket does not exist or you do not have access to it. - - - ) : ( - - )} - + {!bucket ? ( + + + + + Bucket Not Found + + The specified audit log bucket does not exist or you do not have access to it. + + + ) : ( + + )}
@@ -213,27 +178,10 @@ const AuditLogTable: React.FC<{ selectedEvents: string[]; selectedUsers: string[]; selectedRootKeys: string[]; - before?: number; - logs: Array<{ - id: string; - event: string; - time: number; - actor: { - id: string; - type: string; - name: string | null; - }; - location: string | null; - description: string; - targets: Array<{ - id: string; - type: string; - name: string | null; - }>; - }>; -}> = async ({ selectedEvents, selectedRootKeys, selectedUsers, before, logs }) => { + logs: AuditLogWithTargets[]; +}> = async ({ selectedEvents, selectedRootKeys, selectedUsers, logs }) => { const isFiltered = - selectedEvents.length > 0 || selectedUsers.length > 0 || selectedRootKeys.length > 0 || before; + selectedEvents.length > 0 || selectedUsers.length > 0 || selectedRootKeys.length > 0; if (logs.length === 0) { return ( @@ -260,30 +208,8 @@ const AuditLogTable: React.FC<{ ); } - const hasMoreLogs = logs.length >= 100; - - function buildHref(override: Partial): string { - const searchParams = new URLSearchParams(); - const newBefore = override.before ?? before; - if (newBefore) { - searchParams.set("before", newBefore.toString()); - } - - for (const event of selectedEvents) { - searchParams.append("event", event); - } + const userIds = [...new Set(logs.filter((l) => l.actorType === "user").map((l) => l.actorId))]; - for (const rootKey of selectedRootKeys) { - searchParams.append("rootKey", rootKey); - } - - for (const user of selectedUsers) { - searchParams.append("user", user); - } - return `/audit?${searchParams.toString()}`; - } - - const userIds = [...new Set(logs.filter((l) => l.actor.type === "user").map((l) => l.actor.id))]; const users = ( await Promise.all(userIds.map((userId) => clerkClient.users.getUser(userId).catch(() => null))) ).reduce( @@ -296,57 +222,8 @@ const AuditLogTable: React.FC<{ {} as Record, ); - return ( -
- - - - Actor - Event - Location - Time - - - - - {logs.map((l) => { - const user = users[l.actor.id]; - return ( - - ); - })} - -
- -
- - - -
-
- ); + // INFO: Without that json.parse and stringify next.js goes brrrrr + return ; }; const UserFilter: React.FC<{ tenantId: string }> = async ({ tenantId }) => { diff --git a/apps/dashboard/app/(app)/audit/[bucket]/query-state.ts b/apps/dashboard/app/(app)/audit/[bucket]/query-state.ts new file mode 100644 index 0000000000..c5010bb9c5 --- /dev/null +++ b/apps/dashboard/app/(app)/audit/[bucket]/query-state.ts @@ -0,0 +1,41 @@ +import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from "nuqs"; + +export type Cursor = { + time: number; + id: string; +}; + +export type AuditLogQueryParams = { + events: string[]; + users: string[]; + rootKeys: string[]; + bucket: string | null; + cursorTime: number | null; + cursorId: string | null; +}; + +export const auditLogParamsPayload = { + bucket: parseAsString, + events: parseAsArrayOf(parseAsString).withDefault([]), + users: parseAsArrayOf(parseAsString).withDefault([]), + rootKeys: parseAsArrayOf(parseAsString).withDefault([]), + cursorTime: parseAsInteger, + cursorId: parseAsString, +}; + +export const useAuditLogParams = () => { + const [searchParams, setSearchParams] = useQueryStates(auditLogParamsPayload); + + const setCursor = (cursor?: Cursor) => { + setSearchParams({ + cursorTime: cursor?.time ?? null, + cursorId: cursor?.id ?? null, + }); + }; + + return { + searchParams, + setSearchParams, + setCursor, + }; +}; diff --git a/apps/dashboard/app/(app)/audit/[bucket]/row.tsx b/apps/dashboard/app/(app)/audit/[bucket]/row.tsx deleted file mode 100644 index eb313ee8ab..0000000000 --- a/apps/dashboard/app/(app)/audit/[bucket]/row.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Code } from "@/components/ui/code"; -import { TableCell, TableRow } from "@/components/ui/table"; -import { cn } from "@/lib/utils"; -import { ChevronDown, FunctionSquare, KeySquare, Minus } from "lucide-react"; -import { useState } from "react"; - -type Props = { - auditLog: { - time: number; - event: string; - actor: { - id: string; - type: string; - name: string | null; - }; - location: string | null; - description: string; - targets: { - type: string; - id: string; - }[]; - }; - user?: { - imageUrl: string; - username: string | null; - firstName: string | null; - lastName: string | null; - }; -}; -export const Row: React.FC = ({ auditLog, user }) => { - const [expandResources, setExpandResources] = useState(false); - return ( - <> - - -
- {auditLog.actor.type === "user" && user ? ( -
- - - {user.username?.slice(0, 2)} - - {`${ - user.firstName ?? "" - } ${user.lastName ?? ""}`} -
- ) : auditLog.actor.type === "key" ? ( -
- - {auditLog.actor.id} -
- ) : ( -
- - {auditLog.actor.id} -
- )} -
-
- -

{auditLog.description}

- -
- - {auditLog.location ? ( -
{auditLog.location}
- ) : ( - - )} -
- -
- - {new Date(auditLog.time).toLocaleDateString()} - - - {new Date(auditLog.time).toLocaleTimeString()} - -
-
-
- {expandResources ? ( - - - - - {JSON.stringify( - auditLog.targets.reduce((acc, t) => { - acc[t.type] = t.id; - return acc; - }, {} as any), - null, - 2, - )} - - - - ) : null} - - ); -}; diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index 605f8cd54d..db88785900 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -41,7 +41,10 @@ export default async function Layout({ children }: LayoutProps) { className="isolate hidden lg:flex min-w-[250px] max-w-[250px] bg-[inherit]" /> -
+
{workspace.enabled ? ( children diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx index 0d0bc50ee7..4d00970ecb 100644 --- a/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx +++ b/apps/dashboard/app/(app)/logs/components/filters/components/custom-date-filter.tsx @@ -10,7 +10,7 @@ import { Button } from "@unkey/ui"; import { ArrowRight, Calendar as CalendarIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useLogSearchParams } from "../../../query-state"; -import TimeSplitInput from "./time-split"; +import { TimeSplitInput } from "./time-split"; export function DatePickerWithRange({ className }: React.HTMLAttributes) { const [interimDate, setInterimDate] = useState({ diff --git a/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx b/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx index 2ddb4e4ac1..80ed053e4b 100644 --- a/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx +++ b/apps/dashboard/app/(app)/logs/components/filters/components/time-split.tsx @@ -2,7 +2,7 @@ import { format } from "date-fns"; import { Clock } from "lucide-react"; import type React from "react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; export type Time = { HH: string; @@ -24,7 +24,7 @@ export interface TimeSplitInputProps { endDate: Date; } -const TimeSplitInput = ({ +export const TimeSplitInput = ({ type, time, setTime, @@ -192,11 +192,6 @@ const TimeSplitInput = ({ setFocus(true); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: no need to call every - useEffect(() => { - handleOnBlur(); - }, [startDate, endDate]); - return (
: handleOnBlur()} + onBlur={handleOnBlur} onFocus={handleFocus} pattern="[0-12]*" placeholder="00" @@ -255,7 +250,7 @@ const TimeSplitInput = ({ : handleOnBlur()} + onBlur={handleOnBlur} onFocus={handleFocus} pattern="[0-59]*" placeholder="00" @@ -277,5 +272,3 @@ const TimeSplitInput = ({
); }; - -export default TimeSplitInput; diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/request-response-details.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/request-response-details.tsx index 1daff3293b..55bae9a6f6 100644 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/request-response-details.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/log-details/components/request-response-details.tsx @@ -67,12 +67,12 @@ export const RequestResponseDetails = ({ fields, className handleClick(field)} > - {field.label} + {field.label} {field.description(field.content as NonNullable)} {field.tooltipContent} diff --git a/apps/dashboard/app/(app)/logs/page.tsx b/apps/dashboard/app/(app)/logs/page.tsx index 80c3bea0ca..94b9c15ce1 100644 --- a/apps/dashboard/app/(app)/logs/page.tsx +++ b/apps/dashboard/app/(app)/logs/page.tsx @@ -3,7 +3,6 @@ import { getTenantId } from "@/lib/auth"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; -import { notFound } from "next/navigation"; import { createSearchParamsCache } from "nuqs/server"; import { DEFAULT_LOGS_FETCH_COUNT } from "./constants"; import { LogsPage } from "./logs-page"; @@ -25,9 +24,9 @@ export default async function Page({ and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), }); - if (!workspace?.betaFeatures.logsPage) { - return notFound(); - } + // if (!workspace?.betaFeatures.logsPage) { + // return notFound(); + // } const [logs, timeseries] = await fetchInitialLogsAndTimeseriesData(parsedParams, workspace.id); diff --git a/apps/dashboard/components/virtual-table.tsx b/apps/dashboard/components/virtual-table.tsx index e7db975304..5c96e55ffc 100644 --- a/apps/dashboard/components/virtual-table.tsx +++ b/apps/dashboard/components/virtual-table.tsx @@ -1,9 +1,10 @@ import { Card, CardContent } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; +import { cn, throttle } from "@/lib/utils"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ScrollText } from "lucide-react"; import type React from "react"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useScrollLock } from "usehooks-ts"; export type Column = { key: string; @@ -17,7 +18,6 @@ export type VirtualTableProps = { columns: Column[]; isLoading?: boolean; rowHeight?: number; - tableHeight?: string; onRowClick?: (item: T | null) => void; onLoadMore?: () => void; emptyState?: React.ReactNode; @@ -27,6 +27,7 @@ export type VirtualTableProps = { rowClassName?: (item: T) => string; selectedClassName?: (item: T, isSelected: boolean) => string; selectedItem?: T | null; + isFetchingNextPage?: boolean; renderDetails?: (item: T, onClose: () => void, distanceToTop: number) => React.ReactNode; }; @@ -34,13 +35,14 @@ const DEFAULT_ROW_HEIGHT = 26; const DEFAULT_LOADING_ROWS = 50; const DEFAULT_OVERSCAN = 5; const TABLE_BORDER_THICKNESS = 1; +const THROTTLE_DELAY = 350; +const HEADER_HEIGHT = 40; // Approximate height of the header export function VirtualTable({ data, columns, isLoading = false, rowHeight = DEFAULT_ROW_HEIGHT, - tableHeight = "80vh", onRowClick, onLoadMore, emptyState, @@ -51,10 +53,68 @@ export function VirtualTable({ selectedClassName, selectedItem, renderDetails, + isFetchingNextPage, }: VirtualTableProps) { const parentRef = useRef(null); const tableRef = useRef(null); + const containerRef = useRef(null); const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + const [fixedHeight, setFixedHeight] = useState(0); + + // We have to lock the wrapper div at layout, otherwise causes weird scrolling issues. + useScrollLock({ + autoLock: true, + lockTarget: document.querySelector("#layout-wrapper") as HTMLElement, + }); + + // Calculate and set fixed height on mount and resize + useEffect(() => { + const calculateHeight = () => { + if (!containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const headerHeight = HEADER_HEIGHT + TABLE_BORDER_THICKNESS; + const availableHeight = window.innerHeight - rect.top - headerHeight; + + setFixedHeight(Math.max(availableHeight, 0)); + }; + + calculateHeight(); + + const resizeObserver = new ResizeObserver(calculateHeight); + window.addEventListener("resize", calculateHeight); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", calculateHeight); + }; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: No need to add more deps + const throttledLoadMore = useCallback( + throttle( + () => { + if (onLoadMore) { + onLoadMore(); + } + }, + THROTTLE_DELAY, + { leading: true, trailing: false }, + ), + [onLoadMore], + ); + + useEffect(() => { + return () => { + throttledLoadMore.cancel(); + }; + }, [throttledLoadMore]); const virtualizer = useVirtualizer({ count: isLoading ? loadingRows : data.length, @@ -67,8 +127,21 @@ export function VirtualTable({ return; } - if (!isLoading && lastItem.index >= data.length - 1 - instance.options.overscan) { - onLoadMore(); + const scrollElement = instance.scrollElement; + if (!scrollElement) { + return; + } + + const scrollOffset = scrollElement.scrollTop + scrollElement.clientHeight; + const scrollThreshold = scrollElement.scrollHeight - rowHeight * 3; + + if ( + !isLoading && + !isFetchingNextPage && + lastItem.index >= data.length - 1 - instance.options.overscan && + scrollOffset >= scrollThreshold + ) { + throttledLoadMore(); } }, }); @@ -86,48 +159,60 @@ export function VirtualTable({ const LoadingRow = () => (
col.width).join(" ") }} > {columns.map((column) => (
-
+
))}
); - if (!isLoading && data.length === 0) { - return ( -
- {emptyState || ( - - - -
No data available
-
-
- )} -
- ); - } - - return ( -
+ const TableHeader = () => ( + <>
col.width).join(" "), }} > {columns.map((column) => ( -
- {column.header} +
+
{column.header}
))}
+ + ); + + if (!isLoading && data.length === 0) { + return ( +
+ +
+ {emptyState || ( + + + +
No data available
+
+
+ )} +
+
+ ); + } + return ( +
+
{ if (el) { @@ -137,8 +222,8 @@ export function VirtualTable({ tableRef.current = el; } }} - className="overflow-auto" - style={{ height: tableHeight }} + className="overflow-auto pb-10" + style={{ height: `${fixedHeight}px` }} >
({ } }} className={cn( - "grid text-[13px] leading-[14px] mb-[1px] rounded-[5px] cursor-pointer absolute top-0 left-0 w-full hover:bg-background-subtle/90 pl-1", + "grid text-[13px] leading-[14px] mb-[1px] rounded-[5px] cursor-pointer absolute top-0 left-0 w-full hover:bg-accent-3 pl-1 group", rowClassName?.(item), selectedItem && { "opacity-50": !isSelected, @@ -222,8 +307,8 @@ export function VirtualTable({ }} > {columns.map((column) => ( -
- {column.render(item)} +
+
{column.render(item)}
))}
@@ -231,6 +316,15 @@ export function VirtualTable({ })}
+ {isFetchingNextPage && ( +
+
+
+ Loading more data +
+
+ )} + {selectedItem && renderDetails && renderDetails(selectedItem, () => onRowClick?.(null as any), tableDistanceToTop)} diff --git a/apps/dashboard/lib/trpc/routers/audit/fetch.ts b/apps/dashboard/lib/trpc/routers/audit/fetch.ts new file mode 100644 index 0000000000..4cb7e2ce6b --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/audit/fetch.ts @@ -0,0 +1,97 @@ +import { DEFAULT_FETCH_COUNT } from "@/app/(app)/audit/[bucket]/components/table/constants"; +import { db } from "@/lib/db"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +const getAuditLogsInput = z.object({ + bucket: z.string().nullable(), + events: z.array(z.string()).default([]), + users: z.array(z.string()).default([]), + rootKeys: z.array(z.string()).default([]), + cursor: z + .object({ + time: z.number(), + id: z.string(), + }) + .optional(), + limit: z.number().min(1).max(100).default(DEFAULT_FETCH_COUNT), +}); + +export const fetchAuditLog = rateLimitedProcedure(ratelimit.update) + .input(getAuditLogsInput) + .query(async ({ ctx, input }) => { + const { bucket, events, users, rootKeys, cursor, limit } = input; + + const workspace = await db.query.workspaces + .findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.tenantId, ctx.tenant.id), isNull(table.deletedAt)), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve workspace logs due to an error. If this issue persists, please contact support@unkey.dev with the time this occurred.", + }); + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found, please contact support using support@unkey.dev.", + }); + } + + const retentionDays = + workspace.features.auditLogRetentionDays ?? (workspace.plan === "free" ? 30 : 90); + + const retentionCutoffUnixMilli = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + + const selectedActorIds = [...rootKeys, ...users]; + + const logs = await db.query.auditLogBucket.findFirst({ + where: (table, { eq, and }) => + and(eq(table.workspaceId, workspace.id), eq(table.name, bucket ?? "unkey_mutations")), + with: { + logs: { + where: (table, { and, inArray, gte, lt }) => + and( + events.length > 0 ? inArray(table.event, events) : undefined, + gte(table.createdAt, retentionCutoffUnixMilli), + selectedActorIds.length > 0 ? inArray(table.actorId, selectedActorIds) : undefined, + cursor ? lt(table.time, cursor.time) : undefined, + ), + with: { + targets: true, + }, + orderBy: (table, { desc }) => desc(table.time), + limit: limit + 1, // Fetch one extra to determine if there are more results + }, + }, + }); + + if (!logs) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Audit log bucket not found", + }); + } + + const items = logs.logs; + // If we got limit + 1 results, there are more pages + const hasMore = items.length > limit; + // Remove the extra item we used to check for more pages + const slicedItems = hasMore ? items.slice(0, -1) : items; + + return { + items: slicedItems, + nextCursor: + hasMore && slicedItems.length > 0 + ? { + time: slicedItems[slicedItems.length - 1].time, + id: slicedItems[slicedItems.length - 1].id, + } + : undefined, + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index b4702d42dd..140ae3013b 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -6,6 +6,7 @@ import { setDefaultApiPrefix } from "./api/setDefaultPrefix"; import { updateAPIDeleteProtection } from "./api/updateDeleteProtection"; import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; +import { fetchAuditLog } from "./audit/fetch"; import { createKey } from "./key/create"; import { createRootKey } from "./key/createRootKey"; import { deleteKeys } from "./key/delete"; @@ -118,8 +119,11 @@ export const router = t.router({ }), }), logs: t.router({ - queryLogs: queryLogs, - queryTimeseries: queryTimeseries, + queryLogs, + queryTimeseries, + }), + audit: t.router({ + fetch: fetchAuditLog, }), }); diff --git a/apps/dashboard/lib/utils.ts b/apps/dashboard/lib/utils.ts index b2433a4a2d..8e54f89c2b 100644 --- a/apps/dashboard/lib/utils.ts +++ b/apps/dashboard/lib/utils.ts @@ -22,3 +22,109 @@ export function debounce any>(func: T, delay: numb return debounced; } + +type ThrottleOptions = { + leading?: boolean; // Whether to invoke on the leading edge + trailing?: boolean; // Whether to invoke on the trailing edge +}; + +type Timer = ReturnType; + +export function throttle any>( + func: T, + wait: number, + options: ThrottleOptions = {}, +): { + (this: ThisParameterType, ...args: Parameters): ReturnType | undefined; + cancel: () => void; + flush: () => ReturnType | undefined; +} { + let timeout: Timer | undefined; + let result: ReturnType | undefined; + let previous = 0; + let pending = false; + + const { leading = true, trailing = true } = options; + + // Function to handle the actual invocation + function invokeFunc(time: number, args: Parameters): ReturnType { + previous = leading ? time : 0; + timeout = undefined; + result = func.apply(null, args); + pending = false; + return result as ReturnType; + } + + // Function to handle the trailing edge call + function trailingEdge(time: number, args: Parameters): ReturnType | undefined { + timeout = undefined; + + if (trailing && pending) { + return invokeFunc(time, args); + } + pending = false; + return result; + } + + // Function to schedule the delayed invocation + function remainingWait(time: number) { + const timeSinceLastCall = time - previous; + const timeWaiting = wait - timeSinceLastCall; + return timeWaiting; + } + + // The actual throttled function + function throttled( + this: ThisParameterType, + ...args: Parameters + ): ReturnType | undefined { + const time = Date.now(); + const isInvoking = shouldInvoke(time); + + pending = true; + + if (isInvoking) { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + return invokeFunc(time, args); + } + + if (!timeout && trailing) { + timeout = setTimeout(() => trailingEdge(Date.now(), args), remainingWait(time)); + } + + return result; + } + + // Helper to determine if we should invoke the function + function shouldInvoke(time: number): boolean { + const timeSinceLastCall = time - previous; + return ( + (previous === 0 && leading) || // First call with leading edge + timeSinceLastCall >= wait // Enough time has passed + ); + } + + // Cancel method + throttled.cancel = (): void => { + if (timeout) { + clearTimeout(timeout); + } + previous = 0; + timeout = undefined; + result = undefined; + pending = false; + }; + + // Flush method + throttled.flush = (): ReturnType | undefined => { + if (timeout) { + return trailingEdge(Date.now(), [] as unknown as Parameters); + } + return result; + }; + + return throttled; +} diff --git a/internal/db/src/schema/audit_logs.ts b/internal/db/src/schema/audit_logs.ts index d3d812219d..187683c14d 100644 --- a/internal/db/src/schema/audit_logs.ts +++ b/internal/db/src/schema/audit_logs.ts @@ -14,6 +14,7 @@ import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; import { newId } from "@unkey/id"; + export const auditLogBucket = mysqlTable( "audit_log_bucket", {