From 264bf46798b56cfa65f619c2bde954c0db9d29c6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Oct 2025 14:34:56 -0700 Subject: [PATCH] Filtering working on both access and request --- messages/en-US.json | 2 +- .../routers/auditLogs/exportAccessAuditLog.ts | 10 +- .../routers/auditLogs/exportActionAuditLog.ts | 10 +- .../routers/auditLogs/queryAccessAuditLog.ts | 139 ++++++-- .../routers/auditLogs/queryActionAuditLog.ts | 62 ++-- .../routers/auditLogs/queryRequstAuditLog.ts | 102 +++++- server/routers/auditLogs/types.ts | 20 +- src/app/[orgId]/settings/logs/access/page.tsx | 275 +++++++++++++-- .../[orgId]/settings/logs/request/page.tsx | 330 ++++++++++++++++-- src/components/ColumnFilter.tsx | 104 ++++++ src/components/DateTimePicker.tsx | 10 +- src/components/LogDataTable.tsx | 8 +- 12 files changed, 936 insertions(+), 136 deletions(-) create mode 100644 src/components/ColumnFilter.tsx diff --git a/messages/en-US.json b/messages/en-US.json index cb470439..801b5d5b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1916,7 +1916,7 @@ "noSessions": "No Sessions", "temporaryRequestToken": "Temporary Request Token", "noMoreAuthMethods": "No Valid Auth", - "ip": "IP Address", + "ip": "IP", "reason": "Reason", "requestLogs": "Request Logs", "host": "Host", diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts index 8a6398fa..89aef6cb 100644 --- a/server/private/routers/auditLogs/exportAccessAuditLog.ts +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -49,7 +49,6 @@ export async function exportAccessAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -60,16 +59,17 @@ export async function exportAccessAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryAccess(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${orgId}-${Date.now()}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`); return res.send(csvData); } catch (error) { diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 21329879..12c9ff8b 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -49,7 +49,6 @@ export async function exportActionAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryActionAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -60,16 +59,17 @@ export async function exportActionAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryAction(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${orgId}-${Date.now()}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`); return res.send(csvData); } catch (error) { diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index cad2027f..92f927fd 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -41,6 +41,20 @@ export const queryAccessAuditLogsQuery = z.object({ .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .default(new Date().toISOString()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + type: z.string().optional(), limit: z .string() .optional() @@ -59,7 +73,32 @@ export const queryAccessAuditLogsParams = z.object({ orgId: z.string() }); -export function queryAccess(timeStart: number, timeEnd: number, orgId: string) { +export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryAccessAuditLogsParams +); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(accessAuditLog.timestamp, data.timeStart), + lt(accessAuditLog.timestamp, data.timeEnd), + eq(accessAuditLog.orgId, data.orgId), + data.resourceId + ? eq(accessAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, + data.actorType + ? eq(accessAuditLog.actorType, data.actorType) + : undefined, + data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined, + data.type ? eq(accessAuditLog.type, data.type) : undefined, + data.action !== undefined + ? eq(accessAuditLog.action, data.action) + : undefined + ); +} + +export function queryAccess(data: Q) { return db .select({ orgId: accessAuditLog.orgId, @@ -78,31 +117,69 @@ export function queryAccess(timeStart: number, timeEnd: number, orgId: string) { actor: accessAuditLog.actor }) .from(accessAuditLog) - .leftJoin(resources, eq(accessAuditLog.resourceId, resources.resourceId)) - .where( - and( - gt(accessAuditLog.timestamp, timeStart), - lt(accessAuditLog.timestamp, timeEnd), - eq(accessAuditLog.orgId, orgId) - ) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) ) + .where(getWhere(data)) .orderBy(accessAuditLog.timestamp); } -export function countAccessQuery(timeStart: number, timeEnd: number, orgId: string) { - const countQuery = db - .select({ count: count() }) - .from(accessAuditLog) - .where( - and( - gt(accessAuditLog.timestamp, timeStart), - lt(accessAuditLog.timestamp, timeEnd), - eq(accessAuditLog.orgId, orgId) - ) - ); +export function countAccessQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(accessAuditLog) + .where(getWhere(data)); return countQuery; } +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: accessAuditLog.location + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: accessAuditLog.resourceId, + name: resources.name + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null) + }; +} + registry.registerPath({ method: "get", path: "/org/{orgId}/logs/access", @@ -130,8 +207,6 @@ export async function queryAccessAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; - const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( @@ -141,23 +216,31 @@ export async function queryAccessAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryAccess(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryAccess(data); - const totalCountResult = await countAccessQuery(timeStart, timeEnd, orgId); + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countAccessQuery(data); const totalCount = totalCountResult[0].count; + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + return response(res, { data: { log: log, pagination: { total: totalCount, - limit, - offset - } + limit: data.limit, + offset: data.offset + }, + filterAttributes }, success: true, error: false, diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 9e357e8d..6e785c76 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -24,7 +24,6 @@ import { fromError } from "zod-validation-error"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; -import { metadata } from "@app/app/[orgId]/settings/layout"; export const queryActionAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -42,6 +41,10 @@ export const queryActionAuditLogsQuery = z.object({ .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .default(new Date().toISOString()), + action: z.string().optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + actor: z.string().optional(), limit: z .string() .optional() @@ -60,7 +63,23 @@ export const queryActionAuditLogsParams = z.object({ orgId: z.string() }); -export function queryAction(timeStart: number, timeEnd: number, orgId: string) { +export const queryActionAuditLogsCombined = + queryActionAuditLogsQuery.merge(queryActionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(actionAuditLog.timestamp, data.timeStart), + lt(actionAuditLog.timestamp, data.timeEnd), + eq(actionAuditLog.orgId, data.orgId), + data.actor ? eq(actionAuditLog.actor, data.actor) : undefined, + data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined, + data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined, + data.action ? eq(actionAuditLog.action, data.action) : undefined + ); +} + +export function queryAction(data: Q) { return db .select({ orgId: actionAuditLog.orgId, @@ -72,27 +91,15 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) { actor: actionAuditLog.actor }) .from(actionAuditLog) - .where( - and( - gt(actionAuditLog.timestamp, timeStart), - lt(actionAuditLog.timestamp, timeEnd), - eq(actionAuditLog.orgId, orgId) - ) - ) + .where(getWhere(data)) .orderBy(actionAuditLog.timestamp); } -export function countActionQuery(timeStart: number, timeEnd: number, orgId: string) { - const countQuery = db - .select({ count: count() }) - .from(actionAuditLog) - .where( - and( - gt(actionAuditLog.timestamp, timeStart), - lt(actionAuditLog.timestamp, timeEnd), - eq(actionAuditLog.orgId, orgId) - ) - ); +export function countActionQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where(getWhere(data)); return countQuery; } @@ -123,8 +130,6 @@ export async function queryActionAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; - const parsedParams = queryActionAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( @@ -134,13 +139,14 @@ export async function queryActionAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryAction(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryAction(data); - const totalCountResult = await countActionQuery(timeStart, timeEnd, orgId); + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countActionQuery(data); const totalCount = totalCountResult[0].count; return response(res, { @@ -148,8 +154,8 @@ export async function queryActionAuditLogs( log: log, pagination: { total: totalCount, - limit, - offset + limit: data.limit, + offset: data.offset } }, success: true, diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index f4e24649..283e3d7e 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -28,11 +28,26 @@ export const queryAccessAuditLogsQuery = z.object({ .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .default(new Date().toISOString()), - action: z.boolean().optional(), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), - reason: z.number().optional(), - resourceId: z.number().optional(), + reason: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), actor: z.string().optional(), + host: z.string().optional(), + path: z.string().optional(), limit: z .string() .optional() @@ -65,7 +80,12 @@ function getWhere(data: Q) { : 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 + data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, + data.host ? eq(requestAuditLog.host, data.host) : undefined, + data.path ? eq(requestAuditLog.path, data.path) : undefined, + data.action !== undefined + ? eq(requestAuditLog.action, data.action) + : undefined ); } @@ -124,6 +144,71 @@ registry.registerPath({ responses: {} }); +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(requestAuditLog.timestamp, timeStart), + lt(requestAuditLog.timestamp, timeEnd), + eq(requestAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: requestAuditLog.actor + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: requestAuditLog.location + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniqueHosts = await db + .selectDistinct({ + hosts: requestAuditLog.host + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniquePaths = await db + .selectDistinct({ + paths: requestAuditLog.path + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null), + hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null), + paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null) + }; +} + export async function queryRequestAuditLogs( req: Request, res: Response, @@ -159,6 +244,12 @@ export async function queryRequestAuditLogs( const totalCountResult = await countRequestQuery(data); const totalCount = totalCountResult[0].count; + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + return response(res, { data: { log: log, @@ -166,7 +257,8 @@ export async function queryRequestAuditLogs( total: totalCount, limit: data.limit, offset: data.offset - } + }, + filterAttributes }, success: true, error: false, diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index b35c2d9e..8adc1750 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -45,6 +45,16 @@ export type QueryRequestAuditLogResponse = { limit: number; offset: number; }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }; }; export type QueryAccessAuditLogResponse = { @@ -69,4 +79,12 @@ export type QueryAccessAuditLogResponse = { limit: number; offset: number; }; -}; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }; +}; \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index e2b68ae0..47544ff6 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -4,17 +4,19 @@ import { toast } from "@app/hooks/useToast"; import { useState, useRef, useEffect } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { LogDataTable } from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const router = useRouter(); + const searchParams = useSearchParams(); const api = createApiClient(useEnvContext()); const t = useTranslations(); const { env } = useEnvContext(); @@ -23,6 +25,33 @@ export default function GeneralPage() { const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }>({ + actors: [], + resources: [], + locations: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + type: searchParams.get("type") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined + }); // Pagination state const [totalCount, setTotalCount] = useState(0); @@ -32,6 +61,20 @@ export default function GeneralPage() { // Set default date range to last 24 hours const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + const now = new Date(); const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); @@ -53,7 +96,12 @@ export default function GeneralPage() { // Trigger search with default values on component mount useEffect(() => { const defaultRange = getDefaultDateRange(); - queryDateTime(defaultRange.startDate, defaultRange.endDate); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); }, [orgId]); // Re-run if orgId changes const handleDateRangeChange = ( @@ -62,6 +110,12 @@ export default function GeneralPage() { ) => { setDateRange({ startDate, endDate }); setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + queryDateTime(startDate, endDate, 0, pageSize); }; @@ -83,20 +137,76 @@ export default function GeneralPage() { queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + console.log(`${filterType} filter changed:`, value); + + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, page: number = currentPage, - size: number = pageSize + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); setIsLoading(true); try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + // Convert the date/time values to API parameters let params: any = { limit: size, - offset: page * size + offset: page * size, + ...activeFilters }; if (startDate?.date) { @@ -134,6 +244,7 @@ export default function GeneralPage() { if (res.status === 200) { setRows(res.data.data.log || []); setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); console.log("Fetched logs:", res.data); } } catch (error) { @@ -172,16 +283,21 @@ export default function GeneralPage() { const exportData = async () => { try { setIsExporting(true); + + // Prepare query params for export + let params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + const response = await api.get(`/org/${orgId}/logs/access/export`, { responseType: "blob", - params: { - timeStart: dateRange.startDate?.date - ? new Date(dateRange.startDate.date).toISOString() - : undefined, - timeEnd: dateRange.endDate?.date - ? new Date(dateRange.endDate.date).toISOString() - : undefined - } + params }); // Create a URL for the blob and trigger a download @@ -225,7 +341,24 @@ export default function GeneralPage() { { accessorKey: "action", header: ({ column }) => { - return t("action"); + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -244,7 +377,26 @@ export default function GeneralPage() { { accessorKey: "location", header: ({ column }) => { - return t("location"); + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -264,7 +416,26 @@ export default function GeneralPage() { }, { accessorKey: "resourceName", - header: t("resource"), + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, cell: ({ row }) => { return ( { - return t("type"); + return ( +
+ {t("type")} + + handleFilterChange("type", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { - return ( - - {/* {row.original.type == "pincode" ? ( - - ) : ( - - )} */} - {row.original.type.charAt(0).toUpperCase() + - row.original.type.slice(1)} - - ); + // should be capitalized first letter + return {row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1) || "-"}; } }, { accessorKey: "actor", header: ({ column }) => { - return t("actor"); + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -344,10 +545,24 @@ export default function GeneralPage() { return (
+ {row.userAgent != "node" && ( +
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+ )}
Metadata:
-                            {row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
                         
@@ -362,8 +577,6 @@ export default function GeneralPage() { data={rows} persistPageSize="access-logs-table" title={t("accessLogs")} - searchPlaceholder={t("searchLogs")} - searchColumn="type" onRefresh={refreshData} isRefreshing={isRefreshing} onExport={exportData} diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 9c7f6bd4..50ae3740 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -4,21 +4,22 @@ import { toast } from "@app/hooks/useToast"; import { useState, useRef, useEffect } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { LogDataTable } from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react"; import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; export default function GeneralPage() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const router = useRouter(); const api = createApiClient(useEnvContext()); const t = useTranslations(); const { env } = useEnvContext(); const { orgId } = useParams(); + const searchParams = useSearchParams(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -30,8 +31,60 @@ export default function GeneralPage() { const [pageSize, setPageSize] = useState(20); const [isLoading, setIsLoading] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }>({ + actors: [], + resources: [], + locations: [], + hosts: [], + paths: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + resourceId?: string; + host?: string; + location?: string; + actor?: string; + method?: string; + reason?: string; + path?: string; + }>({ + action: searchParams.get("action") || undefined, + host: searchParams.get("host") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined, + method: searchParams.get("method") || undefined, + reason: searchParams.get("reason") || undefined, + path: searchParams.get("path") || undefined + }); + // Set default date range to last 24 hours const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + const now = new Date(); const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); @@ -53,7 +106,12 @@ export default function GeneralPage() { // Trigger search with default values on component mount useEffect(() => { const defaultRange = getDefaultDateRange(); - queryDateTime(defaultRange.startDate, defaultRange.endDate); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); }, [orgId]); // Re-run if orgId changes const handleDateRangeChange = ( @@ -62,6 +120,12 @@ export default function GeneralPage() { ) => { setDateRange({ startDate, endDate }); setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + queryDateTime(startDate, endDate, 0, pageSize); }; @@ -83,20 +147,76 @@ export default function GeneralPage() { queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + console.log(`${filterType} filter changed:`, value); + + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, page: number = currentPage, - size: number = pageSize + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); setIsLoading(true); try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + // Convert the date/time values to API parameters let params: any = { limit: size, - offset: page * size + offset: page * size, + ...activeFilters }; if (startDate?.date) { @@ -134,6 +254,7 @@ export default function GeneralPage() { if (res.status === 200) { setRows(res.data.data.log || []); setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); console.log("Fetched logs:", res.data); } } catch (error) { @@ -172,18 +293,23 @@ export default function GeneralPage() { const exportData = async () => { try { setIsExporting(true); + + // Prepare query params for export + let params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + const response = await api.get( `/org/${orgId}/logs/request/export`, { responseType: "blob", - params: { - timeStart: dateRange.startDate?.date - ? new Date(dateRange.startDate.date).toISOString() - : undefined, - timeEnd: dateRange.endDate?.date - ? new Date(dateRange.endDate.date).toISOString() - : undefined - } + params } ); @@ -269,7 +395,24 @@ export default function GeneralPage() { { accessorKey: "action", header: ({ column }) => { - return t("action"); + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -288,7 +431,26 @@ export default function GeneralPage() { { accessorKey: "location", header: ({ column }) => { - return t("location"); + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -308,7 +470,26 @@ export default function GeneralPage() { }, { accessorKey: "resourceName", - header: t("resource"), + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, cell: ({ row }) => { return ( { - return t("host"); + return ( +
+ {t("host")} + ({ + value: host, + label: host + }))} + selectedValue={filters.host} + onValueChange={(value) => + handleFilterChange("host", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -348,8 +546,25 @@ export default function GeneralPage() { { accessorKey: "path", header: ({ column }) => { - return t("path"); - } + return ( +
+ {t("path")} + ({ + value: path, + label: path + }))} + selectedValue={filters.path} + onValueChange={(value) => + handleFilterChange("path", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, }, // { @@ -361,13 +576,65 @@ export default function GeneralPage() { { accessorKey: "method", header: ({ column }) => { - return t("method"); - } + return ( +
+ {t("method")} + + handleFilterChange("method", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, }, { accessorKey: "reason", header: ({ column }) => { - return t("reason"); + return ( +
+ {t("reason")} + + handleFilterChange("reason", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( @@ -380,7 +647,24 @@ export default function GeneralPage() { { accessorKey: "actor", header: ({ column }) => { - return t("actor"); + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); }, cell: ({ row }) => { return ( diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx new file mode 100644 index 00000000..eee91ecc --- /dev/null +++ b/src/components/ColumnFilter.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; +import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +interface FilterOption { + value: string; + label: string; +} + +interface ColumnFilterProps { + options: FilterOption[]; + selectedValue?: string; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; +} + +export function ColumnFilter({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className +}: ColumnFilterProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find(option => option.value === selectedValue); + + return ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value ? undefined : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} \ No newline at end of file diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index d079ff26..d0b6d40e 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -80,9 +80,9 @@ const getDisplayText = () => { return (
-
+
{label && ( -