Add expandable columns

This commit is contained in:
Owen
2025-10-22 18:21:54 -07:00
parent 0211f75cb6
commit eae2c37388
8 changed files with 316 additions and 106 deletions

View File

@@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error";
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { metadata } from "@app/app/[orgId]/settings/layout";
export const queryActionAuditLogsQuery = z.object({ export const queryActionAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
@@ -65,6 +66,7 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) {
orgId: actionAuditLog.orgId, orgId: actionAuditLog.orgId,
action: actionAuditLog.action, action: actionAuditLog.action,
actorType: actionAuditLog.actorType, actorType: actionAuditLog.actorType,
metadata: actionAuditLog.metadata,
actorId: actionAuditLog.actorId, actorId: actionAuditLog.actorId,
timestamp: actionAuditLog.timestamp, timestamp: actionAuditLog.timestamp,
actor: actionAuditLog.actor actor: actionAuditLog.actor

View File

@@ -41,7 +41,6 @@ export async function exportRequestAuditLogs(
) )
); );
} }
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -52,16 +51,17 @@ export async function exportRequestAuditLogs(
) )
); );
} }
const { orgId } = parsedParams.data;
const baseQuery = queryRequest(timeStart, timeEnd, orgId); const data = { ...parsedQuery.data, ...parsedParams.data };
const log = await baseQuery.limit(limit).offset(offset); const baseQuery = queryRequest(data);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const csvData = generateCSV(log); const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${orgId}-${Date.now()}.csv"`); res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`);
return res.send(csvData); return res.send(csvData);
} catch (error) { } catch (error) {

View File

@@ -28,6 +28,11 @@ export const queryAccessAuditLogsQuery = z.object({
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional() .optional()
.default(new Date().toISOString()), .default(new Date().toISOString()),
action: z.boolean().optional(),
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
reason: z.number().optional(),
resourceId: z.number().optional(),
actor: z.string().optional(),
limit: z limit: z
.string() .string()
.optional() .optional()
@@ -46,55 +51,64 @@ export const queryRequestAuditLogsParams = z.object({
orgId: z.string() orgId: z.string()
}); });
export function queryRequest(timeStart: number, timeEnd: number, orgId: string) { export const queryRequestAuditLogsCombined =
queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams);
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
function getWhere(data: Q) {
return and(
gt(requestAuditLog.timestamp, data.timeStart),
lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId),
data.resourceId
? eq(requestAuditLog.resourceId, data.resourceId)
: undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined,
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined
);
}
export function queryRequest(data: Q) {
return db return db
.select({ .select({
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId, orgId: requestAuditLog.orgId,
action: requestAuditLog.action, action: requestAuditLog.action,
reason: requestAuditLog.reason, reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType, actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor, actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId, actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId, resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip, ip: requestAuditLog.ip,
location: requestAuditLog.location, location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent, userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata, metadata: requestAuditLog.metadata,
headers: requestAuditLog.headers, headers: requestAuditLog.headers,
query: requestAuditLog.query, query: requestAuditLog.query,
originalRequestURL: requestAuditLog.originalRequestURL, originalRequestURL: requestAuditLog.originalRequestURL,
scheme: requestAuditLog.scheme, scheme: requestAuditLog.scheme,
host: requestAuditLog.host, host: requestAuditLog.host,
path: requestAuditLog.path, path: requestAuditLog.path,
method: requestAuditLog.method, method: requestAuditLog.method,
tls: requestAuditLog.tls, tls: requestAuditLog.tls,
resourceName: resources.name, resourceName: resources.name,
resourceNiceId: resources.niceId resourceNiceId: resources.niceId
}) })
.from(requestAuditLog) .from(requestAuditLog)
.leftJoin(resources, eq(requestAuditLog.resourceId, resources.resourceId)) // TODO: Is this efficient? .leftJoin(
.where( resources,
and( eq(requestAuditLog.resourceId, resources.resourceId)
gt(requestAuditLog.timestamp, timeStart), ) // TODO: Is this efficient?
lt(requestAuditLog.timestamp, timeEnd), .where(getWhere(data))
eq(requestAuditLog.orgId, orgId)
)
)
.orderBy(requestAuditLog.timestamp); .orderBy(requestAuditLog.timestamp);
} }
export function countRequestQuery(timeStart: number, timeEnd: number, orgId: string) { export function countRequestQuery(data: Q) {
const countQuery = db const countQuery = db
.select({ count: count() }) .select({ count: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where( .where(getWhere(data));
and(
gt(requestAuditLog.timestamp, timeStart),
lt(requestAuditLog.timestamp, timeEnd),
eq(requestAuditLog.orgId, orgId)
)
);
return countQuery; return countQuery;
} }
@@ -125,7 +139,6 @@ export async function queryRequestAuditLogs(
) )
); );
} }
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -136,13 +149,14 @@ export async function queryRequestAuditLogs(
) )
); );
} }
const { orgId } = parsedParams.data;
const baseQuery = queryRequest(timeStart, timeEnd, orgId); const data = { ...parsedQuery.data, ...parsedParams.data };
const log = await baseQuery.limit(limit).offset(offset); const baseQuery = queryRequest(data);
const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId); const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countRequestQuery(data);
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;
return response<QueryRequestAuditLogResponse>(res, { return response<QueryRequestAuditLogResponse>(res, {
@@ -150,8 +164,8 @@ export async function queryRequestAuditLogs(
log: log, log: log,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, limit: data.limit,
offset offset: data.offset
} }
}, },
success: true, success: true,

View File

@@ -4,6 +4,7 @@ export type QueryActionAuditLogResponse = {
action: string; action: string;
actorType: string; actorType: string;
actorId: string; actorId: string;
metadata: string | null;
timestamp: number; timestamp: number;
actor: string; actor: string;
}[]; }[];
@@ -49,8 +50,9 @@ export type QueryRequestAuditLogResponse = {
export type QueryAccessAuditLogResponse = { export type QueryAccessAuditLogResponse = {
log: { log: {
orgId: string; orgId: string;
action: string; action: boolean;
type: string; actorType: string | null;
actorId: string | null;
resourceId: number | null; resourceId: number | null;
resourceName: string | null; resourceName: string | null;
resourceNiceId: string | null; resourceNiceId: string | null;
@@ -58,10 +60,9 @@ export type QueryAccessAuditLogResponse = {
location: string | null; location: string | null;
userAgent: string | null; userAgent: string | null;
metadata: string | null; metadata: string | null;
actorType: string; type: string;
actorId: string;
timestamp: number; timestamp: number;
actor: string; actor: string | null;
}[]; }[];
pagination: { pagination: {
total: number; total: number;

View File

@@ -340,6 +340,21 @@ export default function GeneralPage() {
} }
]; ];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<strong>Metadata:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
</pre>
</div>
</div>
</div>
);
};
return ( return (
<> <>
<LogDataTable <LogDataTable
@@ -369,6 +384,9 @@ export default function GeneralPage() {
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
isLoading={isLoading} isLoading={isLoading}
defaultPageSize={pageSize} defaultPageSize={pageSize}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
/> />
</> </>
); );

View File

@@ -269,6 +269,21 @@ export default function GeneralPage() {
} }
]; ];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<strong>Metadata:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
</pre>
</div>
</div>
</div>
);
};
return ( return (
<> <>
<LogDataTable <LogDataTable
@@ -298,6 +313,9 @@ export default function GeneralPage() {
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
isLoading={isLoading} isLoading={isLoading}
defaultPageSize={pageSize} defaultPageSize={pageSize}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
/> />
</> </>
); );

View File

@@ -313,6 +313,7 @@ export default function GeneralPage() {
return ( return (
<Link <Link
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`} href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
onClick={(e) => e.stopPropagation()}
> >
<Button <Button
variant="outline" variant="outline"
@@ -402,6 +403,55 @@ export default function GeneralPage() {
} }
]; ];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<strong>User Agent:</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.userAgent || "N/A"}
</p>
</div>
<div>
<strong>Original URL:</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.originalRequestURL || "N/A"}
</p>
</div>
<div>
<strong>Scheme:</strong>
<p className="text-muted-foreground mt-1">
{row.scheme || "N/A"}
</p>
</div>
<div>
<strong>Metadata:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
</pre>
</div>
{row.headers && (
<div className="md:col-span-2">
<strong>Headers:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
{JSON.stringify(JSON.parse(row.headers), null, 2)}
</pre>
</div>
)}
{row.query && (
<div className="md:col-span-2">
<strong>Query Parameters:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
{JSON.stringify(JSON.parse(row.query), null, 2)}
</pre>
</div>
)}
</div>
</div>
);
};
return ( return (
<> <>
<LogDataTable <LogDataTable
@@ -431,6 +481,9 @@ export default function GeneralPage() {
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
isLoading={isLoading} isLoading={isLoading}
defaultPageSize={pageSize} defaultPageSize={pageSize}
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
/> />
</> </>
); );

View File

@@ -23,7 +23,16 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw, Filter, X, Download } from "lucide-react"; import {
Plus,
Search,
RefreshCw,
Filter,
X,
Download,
ChevronRight,
ChevronDown
} from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -109,6 +118,9 @@ type DataTableProps<TData, TValue> = {
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
isLoading?: boolean; isLoading?: boolean;
// Row expansion props
expandable?: boolean;
renderExpandedRow?: (row: TData) => React.ReactNode;
}; };
export function LogDataTable<TData, TValue>({ export function LogDataTable<TData, TValue>({
@@ -132,7 +144,9 @@ export function LogDataTable<TData, TValue>({
currentPage = 0, currentPage = 0,
onPageChange, onPageChange,
onPageSizeChange: onPageSizeChangeProp, onPageSizeChange: onPageSizeChangeProp,
isLoading = false isLoading = false,
expandable = false,
renderExpandedRow
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const t = useTranslations(); const t = useTranslations();
@@ -161,6 +175,7 @@ export function LogDataTable<TData, TValue>({
dateRange?.start || {} dateRange?.start || {}
); );
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {}); const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
// Sync internal date state with external dateRange prop // Sync internal date state with external dateRange prop
useEffect(() => { useEffect(() => {
@@ -186,25 +201,77 @@ export function LogDataTable<TData, TValue>({
return data.filter(activeTabFilter.filterFn); return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab]); }, [data, tabs, activeTab]);
// Toggle row expansion
const toggleRowExpansion = (rowId: string) => {
setExpandedRows((prev) => {
const newSet = new Set(prev);
if (newSet.has(rowId)) {
newSet.delete(rowId);
} else {
newSet.add(rowId);
}
return newSet;
});
};
// Determine if using server-side pagination // Determine if using server-side pagination
const isServerPagination = totalCount !== undefined; const isServerPagination = totalCount !== undefined;
// Create columns with expansion column if expandable
const enhancedColumns = useMemo(() => {
if (!expandable) {
return columns;
}
const expansionColumn: ColumnDef<TData, TValue> = {
id: "expand",
header: () => null,
cell: ({ row }) => {
const isExpanded = expandedRows.has(row.id);
return (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
toggleRowExpansion(row.id);
e.stopPropagation();
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
);
},
size: 40
};
return [expansionColumn, ...columns];
}, [columns, expandable, expandedRows, toggleRowExpansion]);
const table = useReactTable({ const table = useReactTable({
data: filteredData, data: filteredData,
columns, columns: enhancedColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
// Only use client-side pagination if totalCount is not provided // Only use client-side pagination if totalCount is not provided
...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }), ...(isServerPagination
? {}
: { getPaginationRowModel: getPaginationRowModel() }),
onSortingChange: setSorting, onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
// Configure pagination state // Configure pagination state
...(isServerPagination ? { ...(isServerPagination
manualPagination: true, ? {
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0, manualPagination: true,
} : {}), pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0
}
: {}),
initialState: { initialState: {
pagination: { pagination: {
pageSize: pageSize, pageSize: pageSize,
@@ -321,10 +388,7 @@ export function LogDataTable<TData, TValue>({
</Button> </Button>
)} )}
{onExport && ( {onExport && (
<Button <Button onClick={onExport} disabled={isExporting}>
onClick={onExport}
disabled={isExporting}
>
<Download <Download
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`} className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
/> />
@@ -354,48 +418,84 @@ export function LogDataTable<TData, TValue>({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => {
<TableRow const isExpanded =
key={row.id} expandable && expandedRows.has(row.id);
data-state={ return (
row.getIsSelected() && "selected" <>
} <TableRow
className="text-xs" // made smaller key={row.id}
> data-state={
{row.getVisibleCells().map((cell) => { row.getIsSelected() &&
const originalRow = "selected"
row.original as any; }
const actionValue = onClick={() =>
originalRow?.action; expandable
let className = ""; ? toggleRowExpansion(
row.id
)
: undefined
}
className="text-xs" // made smaller
>
{row
.getVisibleCells()
.map((cell) => {
const originalRow =
row.original as any;
const actionValue =
originalRow?.action;
let className = "";
if ( if (
typeof actionValue === "boolean" typeof actionValue ===
) { "boolean"
className = actionValue ) {
? "bg-green-100 dark:bg-green-900/50" className =
: "bg-red-100 dark:bg-red-900/50"; actionValue
} ? "bg-green-100 dark:bg-green-900/50"
: "bg-red-100 dark:bg-red-900/50";
}
return ( return (
<TableCell <TableCell
key={cell.id} key={cell.id}
className={`${className} py-2`} // made smaller className={`${className} py-2`} // made smaller
> >
{flexRender( {flexRender(
cell.column.columnDef cell.column
.cell, .columnDef
cell.getContext() .cell,
)} cell.getContext()
</TableCell> )}
); </TableCell>
})} );
</TableRow> })}
)) </TableRow>
{isExpanded &&
renderExpandedRow && (
<TableRow
key={`${row.id}-expanded`}
>
<TableCell
colSpan={
enhancedColumns.length
}
className="p-4 bg-muted/50"
>
{renderExpandedRow(
row.original
)}
</TableCell>
</TableRow>
)}
</>
);
})
) : ( ) : (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={columns.length} colSpan={enhancedColumns.length}
className="h-24 text-center" className="h-24 text-center"
> >
No results found. No results found.
@@ -408,7 +508,11 @@ export function LogDataTable<TData, TValue>({
<DataTablePagination <DataTablePagination
table={table} table={table}
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
onPageChange={isServerPagination ? handlePageChange : undefined} onPageChange={
isServerPagination
? handlePageChange
: undefined
}
totalCount={totalCount} totalCount={totalCount}
isServerPagination={isServerPagination} isServerPagination={isServerPagination}
isLoading={isLoading} isLoading={isLoading}