diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 4a8f50f63..7ccce8877 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -10,14 +10,17 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +import { logQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; +import { useQuery } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; import axios from "axios"; import { Key, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; export default function GeneralPage() { const router = useRouter(); @@ -28,18 +31,8 @@ export default function GeneralPage() { const { isPaidUser } = usePaidStatus(); - const [rows, setRows] = useState([]); - const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, startTransition] = useTransition(); - const [filterAttributes, setFilterAttributes] = useState<{ - actors: string[]; - actions: string[]; - }>({ - actors: [], - actions: [] - }); - // Filter states - unified object for all filters const [filters, setFilters] = useState<{ action?: string; actor?: string; @@ -48,40 +41,21 @@ export default function GeneralPage() { actor: searchParams.get("actor") || undefined }); - // Pagination state - const [totalCount, setTotalCount] = useState(0); const [currentPage, setCurrentPage] = useState(0); - const [isLoading, setIsLoading] = useState(false); - - // Initialize page size from storage or default const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20); - // 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) - } + startDate: { date: new Date(startParam) }, + endDate: { date: new Date(endParam) } }; } - - const now = new Date(); - const lastWeek = getSevenDaysAgo(); - return { - startDate: { - date: lastWeek - }, - endDate: { - date: now - } + startDate: { date: getSevenDaysAgo() }, + endDate: { date: new Date() } }; }; @@ -90,78 +64,90 @@ export default function GeneralPage() { endDate: DateTimeValue; }>(getDefaultDateRange()); - // Trigger search with default values on component mount - useEffect(() => { - if (build === "oss") { - return; + const queryFilters = useMemo(() => { + let timeStart: string | undefined; + let timeEnd: string | undefined; + + if (dateRange.startDate?.date) { + const dt = new Date(dateRange.startDate.date); + if (dateRange.startDate.time) { + const [h, m, s] = dateRange.startDate.time + .split(":") + .map(Number); + dt.setHours(h, m, s || 0); + } + timeStart = dt.toISOString(); } - const defaultRange = getDefaultDateRange(); - queryDateTime( - defaultRange.startDate, - defaultRange.endDate, - 0, - pageSize - ); - }, [orgId]); // Re-run if orgId changes + + if (dateRange.endDate?.date) { + const dt = new Date(dateRange.endDate.date); + if (dateRange.endDate.time) { + const [h, m, s] = dateRange.endDate.time.split(":").map(Number); + dt.setHours(h, m, s || 0); + } else { + const now = new Date(); + dt.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + timeEnd = dt.toISOString(); + } + + return { + timeStart, + timeEnd, + page: currentPage, + pageSize, + ...filters + }; + }, [dateRange, currentPage, pageSize, filters]); + + const { data, isFetching, isLoading, refetch } = useQuery({ + ...logQueries.action({ + orgId: orgId as string, + filters: queryFilters + }), + enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss" + }); + + const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []); + const totalCount = data?.pagination?.total ?? 0; + const filterAttributes = { + actors: data?.filterAttributes?.actors ?? [] + }; const handleDateRangeChange = ( startDate: DateTimeValue, endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); - setCurrentPage(0); // Reset to first page when filtering - // put the search params in the url for the time + setCurrentPage(0); updateUrlParamsForAllFilters({ start: startDate.date?.toISOString() || "", end: endDate.date?.toISOString() || "" }); - - queryDateTime(startDate, endDate, 0, pageSize); }; - // Handle page changes const handlePageChange = (newPage: number) => { setCurrentPage(newPage); - queryDateTime( - dateRange.startDate, - dateRange.endDate, - newPage, - pageSize - ); }; - // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); - setCurrentPage(0); // Reset to first page when changing page size - queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + setCurrentPage(0); }; - // Handle filter changes generically const handleFilterChange = ( filterType: keyof typeof filters, value: string | undefined ) => { - // Create new filters object with updated value - const newFilters = { - ...filters, - [filterType]: value - }; - + const newFilters = { ...filters, [filterType]: value }; setFilters(newFilters); - setCurrentPage(0); // Reset to first page when filtering - - // Update URL params + setCurrentPage(0); 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 = ( @@ -183,110 +169,8 @@ export default function GeneralPage() { router.replace(`?${params.toString()}`, { scroll: false }); }; - const queryDateTime = async ( - startDate: DateTimeValue, - endDate: DateTimeValue, - page: number = currentPage, - size: number = pageSize, - filtersParam?: { - action?: string; - actor?: string; - } - ) => { - console.log("Date range changed:", { startDate, endDate, page, size }); - if (!isPaidUser(tierMatrix.actionLogs)) { - console.log( - "Access denied: subscription inactive or license locked" - ); - return; - } - 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 - const params: any = { - limit: size, - offset: page * size, - ...activeFilters - }; - - if (startDate?.date) { - const startDateTime = new Date(startDate.date); - if (startDate.time) { - const [hours, minutes, seconds] = startDate.time - .split(":") - .map(Number); - startDateTime.setHours(hours, minutes, seconds || 0); - } - params.timeStart = startDateTime.toISOString(); - } - - if (endDate?.date) { - const endDateTime = new Date(endDate.date); - if (endDate.time) { - const [hours, minutes, seconds] = endDate.time - .split(":") - .map(Number); - endDateTime.setHours(hours, minutes, seconds || 0); - } else { - // If no time is specified, set to NOW - const now = new Date(); - endDateTime.setHours( - now.getHours(), - now.getMinutes(), - now.getSeconds(), - now.getMilliseconds() - ); - } - params.timeEnd = endDateTime.toISOString(); - } - - const res = await api.get(`/org/${orgId}/logs/action`, { params }); - 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) { - toast({ - title: t("error"), - description: t("Failed to filter logs"), - variant: "destructive" - }); - } finally { - setIsLoading(false); - } - }; - - const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - // Refresh data with current date range and pagination - await queryDateTime( - dateRange.startDate, - dateRange.endDate, - currentPage, - pageSize - ); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; - const exportData = async () => { try { - // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() @@ -302,7 +186,6 @@ export default function GeneralPage() { params }); - // Create a URL for the blob and trigger a download const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); link.href = url; @@ -320,7 +203,6 @@ export default function GeneralPage() { const data = error.response.data; if (data instanceof Blob && data.type === "application/json") { - // Parse the Blob as JSON const text = await data.text(); const errorData = JSON.parse(text); apiErrorMessage = errorData.message; @@ -337,7 +219,7 @@ export default function GeneralPage() { const columns: ColumnDef[] = [ { accessorKey: "timestamp", - header: ({ column }) => { + header: () => { return t("timestamp"); }, cell: ({ row }) => { @@ -352,22 +234,16 @@ export default function GeneralPage() { }, { accessorKey: "action", - header: ({ column }) => { + header: () => { return (
{t("action")} ({ - label: - action.charAt(0).toUpperCase() + - action.slice(1), - value: action - }))} + options={[]} selectedValue={filters.action} onValueChange={(value) => handleFilterChange("action", value) } - // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" /> @@ -385,7 +261,7 @@ export default function GeneralPage() { }, { accessorKey: "actor", - header: ({ column }) => { + header: () => { return (
{t("actor")} @@ -398,7 +274,6 @@ export default function GeneralPage() { onValueChange={(value) => handleFilterChange("actor", value) } - // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" /> @@ -420,7 +295,7 @@ export default function GeneralPage() { }, { accessorKey: "actorId", - header: ({ column }) => { + header: () => { return t("actorId"); }, cell: ({ row }) => { @@ -469,12 +344,9 @@ export default function GeneralPage() { title={t("actionLogs")} searchPlaceholder={t("searchLogs")} searchColumn="action" - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={() => refetch()} + isRefreshing={isFetching} onExport={() => startTransition(exportData)} - // isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan - // !isPaidUser(tierMatrix.logExport) || build === "oss" - // } isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ @@ -485,14 +357,12 @@ export default function GeneralPage() { id: "timestamp", desc: true }} - // Server-side pagination props totalCount={totalCount} currentPage={currentPage} pageSize={pageSize} onPageChange={handlePageChange} onPageSizeChange={handlePageSizeChange} isLoading={isLoading} - // Row expansion props expandable={true} renderExpandedRow={renderExpandedRow} disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"} @@ -500,3 +370,39 @@ export default function GeneralPage() { ); } + +function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] { + const actions = [ + "createResource", + "deleteResource", + "updateResource", + "createSite", + "deleteSite", + "inviteUser", + "removeUser" + ]; + const actors = [ + "alice@example.com", + "bob@example.com", + "carol@example.com" + ]; + + const now = Math.floor(Date.now() / 1000); + const sevenDaysAgo = now - 7 * 24 * 60 * 60; + + return Array.from({ length: 10 }, (_, i) => { + const actor = actors[Math.floor(Math.random() * actors.length)]; + + return { + timestamp: Math.floor( + sevenDaysAgo + Math.random() * (now - sevenDaysAgo) + ), + action: actions[Math.floor(Math.random() * actions.length)], + orgId: "sample-org", + actorType: "user", + actor, + actorId: `user-${i}`, + metadata: null + }; + }); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 99c3af21d..c6c4bab5d 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,6 +4,7 @@ import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, + QueryActionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import type { ListClientsResponse } from "@server/routers/client"; @@ -650,6 +651,29 @@ export const accessLogsFiltersSchema = z.object({ export type AccessLogFilters = z.output; +export const actionLogsFiltersSchema = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .optional() + .catch(undefined), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .optional() + .catch(undefined), + page: z.coerce.number().optional().catch(0).default(0), + pageSize: z.coerce.number().optional().catch(20).default(20), + action: z.string().optional().catch(undefined), + actor: z.string().optional().catch(undefined) +}); + +export type ActionLogFilters = z.output; + export const logQueries = { requestAnalytics: ({ orgId, @@ -731,6 +755,37 @@ export const logQueries = { } return false; } + }), + + action: ({ + orgId, + filters + }: { + orgId: string; + filters: ActionLogFilters; + }) => + queryOptions({ + queryKey: ["ACTION_LOGS", orgId, "ALL", filters] as const, + queryFn: async ({ signal, meta }) => { + const { page, pageSize, ...rest } = filters; + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/action`, { + params: { + ...rest, + limit: pageSize, + offset: page * pageSize + }, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } }) };