mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-25 10:12:35 +00:00
♻️ useQuery for fetching access logs
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -20,6 +20,9 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -30,23 +33,8 @@ export default function GeneralPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
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;
|
||||
@@ -61,40 +49,21 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize("access-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() }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -103,75 +72,95 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
resourceId: filters.resourceId
|
||||
? Number(filters.resourceId)
|
||||
: undefined
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.access({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: []
|
||||
};
|
||||
|
||||
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 = (
|
||||
@@ -193,110 +182,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;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
||||
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/access`, { 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 () => {
|
||||
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"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -312,7 +199,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;
|
||||
@@ -330,7 +216,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;
|
||||
@@ -347,7 +232,7 @@ export default function GeneralPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -362,7 +247,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
@@ -375,7 +260,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -392,13 +276,11 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
header: () => t("ip")
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
@@ -413,7 +295,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -438,7 +319,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
@@ -451,7 +332,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -477,7 +357,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("type")}</span>
|
||||
@@ -496,7 +376,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -514,7 +393,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
@@ -527,7 +406,6 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -555,16 +433,12 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
header: () => t("actorId"),
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -610,13 +484,10 @@ export default function GeneralPage() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={() => startRefreshTransition(refreshData)}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
// 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.accessLogs) || build === "oss"
|
||||
// }
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
@@ -626,14 +497,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.accessLogs) || build === "oss"}
|
||||
@@ -641,3 +510,41 @@ export default function GeneralPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||
const actors = [
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"carol@example.com",
|
||||
null
|
||||
];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const action = Math.random() > 0.3;
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
|
||||
return {
|
||||
timestamp: Math.floor(
|
||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||
),
|
||||
action,
|
||||
orgId: "sample-org",
|
||||
actorType: actor ? "user" : null,
|
||||
actor,
|
||||
actorId: actor ? `user-${i}` : null,
|
||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||
resourceName: `Resource ${(i % 3) + 1}`,
|
||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
userAgent: "Mozilla/5.0",
|
||||
metadata: null,
|
||||
type: types[Math.floor(Math.random() * types.length)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ export function LogDataTable<TData, TValue>({
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<div className="backdrop-blur-[2px] z-10 absolute inset-0 top-10"></div>
|
||||
<div className="backdrop-blur-[3px] z-10 absolute inset-0 top-10"></div>
|
||||
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
|
||||
<div className="flex items-center gap-2 p-6">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
|
||||
@@ -644,7 +644,8 @@ export const accessLogsFiltersSchema = z.object({
|
||||
resourceId: z.coerce.number().optional().catch(undefined),
|
||||
action: z.string().optional().catch(undefined),
|
||||
location: z.string().optional().catch(undefined),
|
||||
actor: z.string().optional().catch(undefined)
|
||||
actor: z.string().optional().catch(undefined),
|
||||
type: z.string().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
|
||||
@@ -707,7 +708,7 @@ export const logQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
access: ({ orgId, filters }: { orgId: string; filters: HttpLogFilters }) =>
|
||||
access: ({ orgId, filters }: { orgId: string; filters: AccessLogFilters }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
|
||||
Reference in New Issue
Block a user