Merge pull request #3627 from fosrl/feat/ip-filtering

feat: Add IP column filtering to tables
This commit is contained in:
Owen Schwartz
2026-08-31 16:59:34 -04:00
committed by GitHub
9 changed files with 198 additions and 58 deletions
+46 -13
View File
@@ -12,6 +12,7 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -26,6 +27,7 @@ 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";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export default function GeneralPage() {
const router = useRouter();
@@ -45,12 +47,14 @@ export default function GeneralPage() {
resourceId?: string;
location?: string;
actor?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
type: searchParams.get("type") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
location: searchParams.get("location") || undefined,
actor: searchParams.get("actor") || undefined
actor: searchParams.get("actor") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const [currentPage, setCurrentPage] = useState<number>(0);
@@ -176,7 +180,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -194,10 +198,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -205,6 +212,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -212,13 +220,20 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
const response = await api.get(`/org/${orgId}/logs/access/export`, {
responseType: "blob",
params
});
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/access/export?${sp.toString()}`,
{
responseType: "blob",
params
}
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
@@ -297,7 +312,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => <span className="px-2">{t("ip")}</span>,
header: () => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -315,7 +347,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
label={t("location")}
@@ -334,7 +366,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">
+40 -10
View File
@@ -23,6 +23,8 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
export default function GeneralPage() {
const router = useRouter();
@@ -47,6 +49,7 @@ export default function GeneralPage() {
method?: string;
reason?: string;
path?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
host: searchParams.get("host") || undefined,
@@ -55,7 +58,8 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined,
method: searchParams.get("method") || undefined,
reason: searchParams.get("reason") || undefined,
path: searchParams.get("path") || undefined
path: searchParams.get("path") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const getDefaultDateRange = () => {
@@ -179,7 +183,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -197,10 +201,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -209,6 +216,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
// Prepare query params for export
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -216,11 +224,15 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/request/export`,
`/org/${orgId}/logs/request/export?${sp.toString()}`,
{
responseType: "blob",
params
@@ -351,7 +363,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: ({ column }) => <span className="px-2">{t("ip")}</span>,
header: ({ column }) => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -369,7 +398,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
selectedValue={filters.location}
@@ -389,7 +418,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">