Compare commits

...

14 Commits

Author SHA1 Message Date
Owen Schwartz
1a3cf2094b Merge pull request #3117 from Fredkiss3/refactor/loading-animation-on-request-logs
feat: Show a loading animation on http request logs table
2026-05-21 17:28:20 -07:00
Fred KISSIE
09cb20a084 push 2026-05-22 00:44:29 +02:00
Owen
d1fb2e19d3 Fix cache import to be dynamic 2026-05-21 14:43:50 -07:00
Fred KISSIE
2934bbdd20 ♻️ use react query for network logs 2026-05-21 23:27:02 +02:00
Fred KISSIE
2b46e8eaba ♻️ use useQuery for network action logs 2026-05-21 23:23:49 +02:00
Fred KISSIE
5bf8b336c5 ♻️ useQuery for fetching access logs 2026-05-21 23:05:34 +02:00
Fred KISSIE
21a144753d Add access react query 2026-05-21 22:50:15 +02:00
Fred KISSIE
c1b8dfc863 ♻️ refactor 2026-05-21 22:44:24 +02:00
Fred KISSIE
5efcd4479a Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-21 22:31:16 +02:00
Fred KISSIE
a163cc3678 💄 show loading animation on http request logs table 2026-05-20 04:50:49 +02:00
Fred KISSIE
1dfb3408e8 ♻️ use react query for fetching instead of useEfffect 2026-05-20 01:00:56 +02:00
Fred KISSIE
67fb2beba1 Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-19 23:54:21 +02:00
Fred KISSIE
c500979099 Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-18 22:52:27 +02:00
Fred KISSIE
81274960f6 🚧 refactor 2026-05-04 20:22:16 +02:00
9 changed files with 762 additions and 869 deletions

View File

@@ -1646,6 +1646,7 @@
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingEllipsis": "Loading...",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
"domains": "Domains",

View File

@@ -154,8 +154,19 @@ class AdaptiveCache {
keys(): string[] {
return localCache.keys();
}
/**
* Get keys with a specific prefix
* @param prefix - Key prefix to match
* @returns Array of matching keys
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
const allKeys = localCache.keys();
return allKeys.filter((key) => key.startsWith(prefix));
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export default cache;

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import { regionalCache as cache } from "@server/private/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds

View File

@@ -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, setIsRefreshing] = useState(false);
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,114 +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 () => {
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()
@@ -316,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;
@@ -334,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;
@@ -351,7 +232,7 @@ export default function GeneralPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "timestamp",
header: ({ column }) => {
header: () => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -366,7 +247,7 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
@@ -379,7 +260,6 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("action", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -396,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>
@@ -417,7 +295,6 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("location", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -442,7 +319,7 @@ export default function GeneralPage() {
},
{
accessorKey: "resourceName",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
@@ -455,7 +332,6 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -481,7 +357,7 @@ export default function GeneralPage() {
},
{
accessorKey: "type",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("type")}</span>
@@ -500,7 +376,6 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("type", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -518,7 +393,7 @@ export default function GeneralPage() {
},
{
accessorKey: "actor",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
@@ -531,7 +406,6 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
// placeholder=""
searchPlaceholder="Search..."
emptyMessage="None found"
/>
@@ -559,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>
)
}
];
@@ -614,13 +484,10 @@ export default function GeneralPage() {
columns={columns}
data={rows}
title={t("accessLogs")}
onRefresh={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,
@@ -630,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"}
@@ -645,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)]
};
});
}

View File

@@ -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<any[]>([]);
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<number>(0);
const [currentPage, setCurrentPage] = useState<number>(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<any>[] = [
{
accessorKey: "timestamp",
header: ({ column }) => {
header: () => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -352,22 +234,16 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("action")}</span>
<ColumnFilter
options={filterAttributes.actions.map((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 (
<div className="flex items-center gap-2">
<span>{t("actor")}</span>
@@ -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
};
});
}

View File

@@ -9,26 +9,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
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 { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
import { useQuery } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { ArrowUpRight, Laptop, User } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
import { useMemo, useState, useTransition } from "react";
function formatDuration(startedAt: number, endedAt: number | null): string {
if (endedAt === null || endedAt === undefined) return "Active";
@@ -54,24 +48,8 @@ export default function ConnectionLogsPage() {
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, startTransition] = useTransition();
const [filterAttributes, setFilterAttributes] = useState<{
protocols: string[];
destAddrs: string[];
clients: { id: number; name: string }[];
resources: { id: number; name: string | null }[];
users: { id: string; email: string | null }[];
}>({
protocols: [],
destAddrs: [],
clients: [],
resources: [],
users: []
});
// Filter states - unified object for all filters
const [filters, setFilters] = useState<{
protocol?: string;
destAddr?: string;
@@ -86,43 +64,24 @@ export default function ConnectionLogsPage() {
userId: searchParams.get("userId") || 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(
"connection-audit-logs",
20
);
// Set default date range to last 7 days
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() }
};
};
@@ -131,78 +90,100 @@ export default function ConnectionLogsPage() {
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,
clientId: filters.clientId ? Number(filters.clientId) : undefined,
siteResourceId: filters.siteResourceId
? Number(filters.siteResourceId)
: undefined
};
}, [dateRange, currentPage, pageSize, filters]);
const { data, isFetching, isLoading, refetch } = useQuery({
...logQueries.connection({
orgId: orgId as string,
filters: queryFilters
}),
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
});
const rows = isLoading
? generateSampleConnectionLogs()
: (data?.log ?? []);
const totalCount = data?.pagination?.total ?? 0;
const filterAttributes = data?.filterAttributes ?? {
protocols: [],
destAddrs: [],
clients: [],
resources: [],
users: []
};
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 = (
@@ -224,109 +205,8 @@ export default function ConnectionLogsPage() {
router.replace(`?${params.toString()}`, { scroll: false });
};
const queryDateTime = async (
startDate: DateTimeValue,
endDate: DateTimeValue,
page: number = currentPage,
size: number = pageSize,
filtersParam?: typeof filters
) => {
console.log("Date range changed:", { startDate, endDate, page, size });
if (!isPaidUser(tierMatrix.connectionLogs)) {
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/connection`, {
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 connection logs:", res.data);
}
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error),
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()
@@ -345,7 +225,6 @@ export default function ConnectionLogsPage() {
}
);
// 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;
@@ -363,7 +242,6 @@ export default function ConnectionLogsPage() {
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;
@@ -380,7 +258,7 @@ export default function ConnectionLogsPage() {
const columns: ColumnDef<any>[] = [
{
accessorKey: "startedAt",
header: ({ column }) => {
header: () => {
return t("timestamp");
},
cell: ({ row }) => {
@@ -395,7 +273,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "protocol",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("protocol")}</span>
@@ -426,7 +304,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "resourceName",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("resource")}</span>
@@ -467,7 +345,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "clientName",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("client")}</span>
@@ -510,7 +388,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "userEmail",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("user")}</span>
@@ -543,7 +421,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "sourceAddr",
header: ({ column }) => {
header: () => {
return t("sourceAddress");
},
cell: ({ row }) => {
@@ -556,7 +434,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "destAddr",
header: ({ column }) => {
header: () => {
return (
<div className="flex items-center gap-2">
<span>{t("destinationAddress")}</span>
@@ -585,7 +463,7 @@ export default function ConnectionLogsPage() {
},
{
accessorKey: "duration",
header: ({ column }) => {
header: () => {
return t("duration");
},
cell: ({ row }) => {
@@ -606,9 +484,6 @@ export default function ConnectionLogsPage() {
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
<div className="space-y-2">
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
Connection Details
</div>*/}
<div>
<strong>Session ID:</strong>{" "}
<span className="font-mono">
@@ -633,18 +508,6 @@ export default function ConnectionLogsPage() {
</div>
</div>
<div className="space-y-2">
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
Resource & Site
</div>*/}
{/*<div>
<strong>Resource:</strong>{" "}
{row.resourceName ?? "-"}
{row.resourceNiceId && (
<span className="text-muted-foreground ml-1">
({row.resourceNiceId})
</span>
)}
</div>*/}
<div>
<strong>Client Endpoint:</strong>{" "}
<span className="font-mono">
@@ -680,30 +543,8 @@ export default function ConnectionLogsPage() {
<strong>Duration:</strong>{" "}
{formatDuration(row.startedAt, row.endedAt)}
</div>
{/*<div>
<strong>Resource ID:</strong>{" "}
{row.siteResourceId ?? "-"}
</div>*/}
</div>
<div className="space-y-2">
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
Client & Transfer
</div>*/}
{/*<div>
<strong>Bytes Sent (TX):</strong>{" "}
{formatBytes(row.bytesTx)}
</div>*/}
{/*<div>
<strong>Bytes Received (RX):</strong>{" "}
{formatBytes(row.bytesRx)}
</div>*/}
{/*<div>
<strong>Total Transfer:</strong>{" "}
{formatBytes(
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
)}
</div>*/}
</div>
<div className="space-y-2" />
</div>
</div>
);
@@ -724,8 +565,8 @@ export default function ConnectionLogsPage() {
title={t("connectionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="protocol"
onRefresh={refreshData}
isRefreshing={isRefreshing}
onRefresh={() => refetch()}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
@@ -737,14 +578,12 @@ export default function ConnectionLogsPage() {
id: "startedAt",
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={
@@ -754,3 +593,49 @@ export default function ConnectionLogsPage() {
</>
);
}
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
const protocols = ["tcp", "udp", "icmp"];
const destAddrs = [
"10.0.0.1:22",
"10.0.0.2:80",
"10.0.0.3:443",
"192.168.1.10:3306"
];
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
return Array.from({ length: 10 }, (_, i) => {
const startedAt = Math.floor(
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
);
const active = Math.random() > 0.3;
return {
sessionId: `session-${i}`,
siteResourceId: (i % 3) + 1,
orgId: "sample-org",
siteId: 1,
clientId: (i % 4) + 1,
clientEndpoint: `10.0.0.${i + 1}:51820`,
userId: i % 2 === 0 ? `user-${i}` : null,
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
protocol:
protocols[Math.floor(Math.random() * protocols.length)],
startedAt,
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
resourceName: `Resource ${(i % 3) + 1}`,
resourceNiceId: `resource-${(i % 3) + 1}`,
siteName: "Sample Site",
siteNiceId: "sample-site",
clientName: `Client ${(i % 4) + 1}`,
clientNiceId: `client-${(i % 4) + 1}`,
clientType: i % 2 === 0 ? "user" : "machine",
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
};
});
}

View File

@@ -9,14 +9,17 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { logQueries } from "@app/lib/queries";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { build } from "@server/build";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
export default function GeneralPage() {
const router = useRouter();
@@ -25,36 +28,11 @@ export default function GeneralPage() {
const { orgId } = useParams();
const searchParams = useSearchParams();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, startTransition] = useTransition();
// 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("request-audit-logs", 20);
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;
@@ -75,32 +53,18 @@ export default function GeneralPage() {
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)
}
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() }
};
};
@@ -109,80 +73,97 @@ 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,
resourceId: filters.resourceId
? Number(filters.resourceId)
: undefined
};
}, [dateRange, currentPage, pageSize, filters]);
const { data, isFetching, isLoading, refetch } = useQuery({
...logQueries.requests({
orgId: orgId as string,
filters: queryFilters
}),
enabled: build !== "oss"
});
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
const totalCount = data?.pagination?.total ?? 0;
const filterAttributes = data?.filterAttributes ?? {
actors: [],
resources: [],
locations: [],
hosts: [],
paths: []
};
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
) => {
console.log(`${filterType} filter changed:`, value);
// 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 = (
@@ -204,101 +185,6 @@ 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;
}
) => {
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
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/request`, { 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
@@ -781,8 +667,8 @@ export default function GeneralPage() {
title={t("requestLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="host"
onRefresh={refreshData}
isRefreshing={isRefreshing}
onRefresh={() => refetch()}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
@@ -794,7 +680,6 @@ export default function GeneralPage() {
id: "timestamp",
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
currentPage={currentPage}
onPageChange={handlePageChange}
@@ -808,3 +693,63 @@ export default function GeneralPage() {
</>
);
}
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const paths = [
"/api/v1/users",
"/dashboard",
"/settings",
"/health",
"/metrics"
];
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
const deniedReasons = [201, 202, 203, 204, 205, 299];
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 reason = action
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
const actor = actors[Math.floor(Math.random() * actors.length)];
return {
timestamp: Math.floor(
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
),
action,
reason,
orgId: "sample-org",
actorType: actor ? "user" : null,
actor,
actorId: actor ? `user-${i}` : null,
resourceId: Math.floor(Math.random() * 5) + 1,
siteResourceId: null,
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,
headers: null,
query: null,
originalRequestURL: null,
scheme: "https",
host: hosts[Math.floor(Math.random() * hosts.length)],
path: paths[Math.floor(Math.random() * paths.length)],
method: methods[Math.floor(Math.random() * methods.length)],
tls: true
};
});
}

View File

@@ -28,6 +28,7 @@ import {
ChevronRight,
Download,
Loader,
LoaderIcon,
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -427,7 +428,7 @@ export function LogDataTable<TData, TValue>({
)}
</div>
</CardHeader>
<CardContent>
<CardContent className="relative">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -535,6 +536,19 @@ export function LogDataTable<TData, TValue>({
)}
</TableBody>
</Table>
{isLoading && (
<>
<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" />
{t("loadingEllipsis")}
</div>
</div>
</>
)}
<div className="mt-4">
<DataTablePagination
table={table}

View File

@@ -1,17 +1,25 @@
import { build } from "@server/build";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type {
QueryAccessAuditLogResponse,
QueryActionAuditLogResponse,
QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types";
import type { ListClientsResponse } from "@server/routers/client";
import type {
ListDomainsResponse,
GetDNSRecordsResponse
GetDNSRecordsResponse,
ListDomainsResponse
} from "@server/routers/domain";
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type {
GetResourceWhitelistResponse,
ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
@@ -31,8 +39,6 @@ import type { AxiosResponse } from "axios";
import z from "zod";
import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
export type ProductUpdate = {
@@ -589,7 +595,111 @@ export const logAnalyticsFiltersSchema = z.object({
resourceId: z.coerce.number().optional().catch(undefined)
});
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
export const httpLogsFiltersSchema = 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),
resourceId: z.coerce.number().optional().catch(undefined),
action: z.string().optional().catch(undefined),
host: z.string().optional().catch(undefined),
location: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
method: z.string().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
path: z.string().optional().catch(undefined)
});
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
export const accessLogsFiltersSchema = 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),
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),
type: z.string().optional().catch(undefined)
});
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
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<typeof actionLogsFiltersSchema>;
export const connectionLogsFiltersSchema = 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),
protocol: z.string().optional().catch(undefined),
destAddr: z.string().optional().catch(undefined),
clientId: z.coerce.number().optional().catch(undefined),
siteResourceId: z.coerce.number().optional().catch(undefined),
userId: z.string().optional().catch(undefined)
});
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
export const logQueries = {
requestAnalytics: ({
@@ -600,7 +710,7 @@ export const logQueries = {
filters: LogAnalyticsFilters;
}) =>
queryOptions({
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
queryKey: ["REQUEST_LOGS", orgId, "ANALYTICS", filters] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryRequestAnalyticsResponse>
@@ -616,6 +726,124 @@ export const logQueries = {
}
return false;
}
}),
requests: ({
orgId,
filters
}: {
orgId: string;
filters: HttpLogFilters;
}) =>
queryOptions({
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const res = await meta!.api.get<
AxiosResponse<QueryRequestAuditLogResponse>
>(`/org/${orgId}/logs/request`, {
params: {
...rest,
limit: pageSize,
offset: page * pageSize
},
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}),
access: ({ orgId, filters }: { orgId: string; filters: AccessLogFilters }) =>
queryOptions({
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const res = await meta!.api.get<
AxiosResponse<QueryAccessAuditLogResponse>
>(`/org/${orgId}/logs/access`, {
params: {
...rest,
limit: pageSize,
offset: page * pageSize
},
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
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<QueryActionAuditLogResponse>
>(`/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;
}
}),
connection: ({
orgId,
filters
}: {
orgId: string;
filters: ConnectionLogFilters;
}) =>
queryOptions({
queryKey: ["CONNECTION_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const res = await meta!.api.get<
AxiosResponse<QueryConnectionAuditLogResponse>
>(`/org/${orgId}/logs/connection`, {
params: {
...rest,
limit: pageSize,
offset: page * pageSize
},
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
})
};