mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 10:16:41 +00:00
Filtering working on both access and request
This commit is contained in:
@@ -1916,7 +1916,7 @@
|
|||||||
"noSessions": "No Sessions",
|
"noSessions": "No Sessions",
|
||||||
"temporaryRequestToken": "Temporary Request Token",
|
"temporaryRequestToken": "Temporary Request Token",
|
||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP Address",
|
"ip": "IP",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"requestLogs": "Request Logs",
|
"requestLogs": "Request Logs",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export async function exportAccessAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -60,16 +59,17 @@ export async function exportAccessAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryAccess(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryAccess(data);
|
||||||
|
|
||||||
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
const csvData = generateCSV(log);
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${orgId}-${Date.now()}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||||
|
|
||||||
return res.send(csvData);
|
return res.send(csvData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export async function exportActionAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -60,16 +59,17 @@ export async function exportActionAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryAction(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryAction(data);
|
||||||
|
|
||||||
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
const csvData = generateCSV(log);
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${orgId}-${Date.now()}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||||
|
|
||||||
return res.send(csvData);
|
return res.send(csvData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.default(new Date().toISOString()),
|
.default(new Date().toISOString()),
|
||||||
|
action: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||||
|
.optional(),
|
||||||
|
actorType: z.string().optional(),
|
||||||
|
actorId: z.string().optional(),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
.optional(),
|
||||||
|
actor: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -59,7 +73,32 @@ export const queryAccessAuditLogsParams = z.object({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export function queryAccess(timeStart: number, timeEnd: number, orgId: string) {
|
export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||||
|
queryAccessAuditLogsParams
|
||||||
|
);
|
||||||
|
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
|
||||||
|
|
||||||
|
function getWhere(data: Q) {
|
||||||
|
return and(
|
||||||
|
gt(accessAuditLog.timestamp, data.timeStart),
|
||||||
|
lt(accessAuditLog.timestamp, data.timeEnd),
|
||||||
|
eq(accessAuditLog.orgId, data.orgId),
|
||||||
|
data.resourceId
|
||||||
|
? eq(accessAuditLog.resourceId, data.resourceId)
|
||||||
|
: undefined,
|
||||||
|
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
|
||||||
|
data.actorType
|
||||||
|
? eq(accessAuditLog.actorType, data.actorType)
|
||||||
|
: undefined,
|
||||||
|
data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined,
|
||||||
|
data.type ? eq(accessAuditLog.type, data.type) : undefined,
|
||||||
|
data.action !== undefined
|
||||||
|
? eq(accessAuditLog.action, data.action)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryAccess(data: Q) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
orgId: accessAuditLog.orgId,
|
orgId: accessAuditLog.orgId,
|
||||||
@@ -78,31 +117,69 @@ export function queryAccess(timeStart: number, timeEnd: number, orgId: string) {
|
|||||||
actor: accessAuditLog.actor
|
actor: accessAuditLog.actor
|
||||||
})
|
})
|
||||||
.from(accessAuditLog)
|
.from(accessAuditLog)
|
||||||
.leftJoin(resources, eq(accessAuditLog.resourceId, resources.resourceId))
|
.leftJoin(
|
||||||
.where(
|
resources,
|
||||||
and(
|
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||||
gt(accessAuditLog.timestamp, timeStart),
|
|
||||||
lt(accessAuditLog.timestamp, timeEnd),
|
|
||||||
eq(accessAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.where(getWhere(data))
|
||||||
.orderBy(accessAuditLog.timestamp);
|
.orderBy(accessAuditLog.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countAccessQuery(timeStart: number, timeEnd: number, orgId: string) {
|
export function countAccessQuery(data: Q) {
|
||||||
const countQuery = db
|
const countQuery = db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(accessAuditLog)
|
.from(accessAuditLog)
|
||||||
.where(
|
.where(getWhere(data));
|
||||||
and(
|
|
||||||
gt(accessAuditLog.timestamp, timeStart),
|
|
||||||
lt(accessAuditLog.timestamp, timeEnd),
|
|
||||||
eq(accessAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return countQuery;
|
return countQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queryUniqueFilterAttributes(
|
||||||
|
timeStart: number,
|
||||||
|
timeEnd: number,
|
||||||
|
orgId: string
|
||||||
|
) {
|
||||||
|
const baseConditions = and(
|
||||||
|
gt(accessAuditLog.timestamp, timeStart),
|
||||||
|
lt(accessAuditLog.timestamp, timeEnd),
|
||||||
|
eq(accessAuditLog.orgId, orgId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get unique actors
|
||||||
|
const uniqueActors = await db
|
||||||
|
.selectDistinct({
|
||||||
|
actor: accessAuditLog.actor
|
||||||
|
})
|
||||||
|
.from(accessAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique locations
|
||||||
|
const uniqueLocations = await db
|
||||||
|
.selectDistinct({
|
||||||
|
locations: accessAuditLog.location
|
||||||
|
})
|
||||||
|
.from(accessAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique resources with names
|
||||||
|
const uniqueResources = await db
|
||||||
|
.selectDistinct({
|
||||||
|
id: accessAuditLog.resourceId,
|
||||||
|
name: resources.name
|
||||||
|
})
|
||||||
|
.from(accessAuditLog)
|
||||||
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
||||||
|
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
|
||||||
|
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/logs/access",
|
path: "/org/{orgId}/logs/access",
|
||||||
@@ -130,8 +207,6 @@ export async function queryAccessAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
@@ -141,23 +216,31 @@ export async function queryAccessAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryAccess(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryAccess(data);
|
||||||
|
|
||||||
const totalCountResult = await countAccessQuery(timeStart, timeEnd, orgId);
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
const totalCountResult = await countAccessQuery(data);
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
const filterAttributes = await queryUniqueFilterAttributes(
|
||||||
|
data.timeStart,
|
||||||
|
data.timeEnd,
|
||||||
|
data.orgId
|
||||||
|
);
|
||||||
|
|
||||||
return response<QueryAccessAuditLogResponse>(res, {
|
return response<QueryAccessAuditLogResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
log: log,
|
log: log,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit: data.limit,
|
||||||
offset
|
offset: data.offset
|
||||||
}
|
},
|
||||||
|
filterAttributes
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { metadata } from "@app/app/[orgId]/settings/layout";
|
|
||||||
|
|
||||||
export const queryActionAuditLogsQuery = z.object({
|
export const queryActionAuditLogsQuery = z.object({
|
||||||
// iso string just validate its a parseable date
|
// iso string just validate its a parseable date
|
||||||
@@ -42,6 +41,10 @@ export const queryActionAuditLogsQuery = z.object({
|
|||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.default(new Date().toISOString()),
|
.default(new Date().toISOString()),
|
||||||
|
action: z.string().optional(),
|
||||||
|
actorType: z.string().optional(),
|
||||||
|
actorId: z.string().optional(),
|
||||||
|
actor: z.string().optional(),
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -60,7 +63,23 @@ export const queryActionAuditLogsParams = z.object({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export function queryAction(timeStart: number, timeEnd: number, orgId: string) {
|
export const queryActionAuditLogsCombined =
|
||||||
|
queryActionAuditLogsQuery.merge(queryActionAuditLogsParams);
|
||||||
|
type Q = z.infer<typeof queryActionAuditLogsCombined>;
|
||||||
|
|
||||||
|
function getWhere(data: Q) {
|
||||||
|
return and(
|
||||||
|
gt(actionAuditLog.timestamp, data.timeStart),
|
||||||
|
lt(actionAuditLog.timestamp, data.timeEnd),
|
||||||
|
eq(actionAuditLog.orgId, data.orgId),
|
||||||
|
data.actor ? eq(actionAuditLog.actor, data.actor) : undefined,
|
||||||
|
data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined,
|
||||||
|
data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined,
|
||||||
|
data.action ? eq(actionAuditLog.action, data.action) : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryAction(data: Q) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
orgId: actionAuditLog.orgId,
|
orgId: actionAuditLog.orgId,
|
||||||
@@ -72,27 +91,15 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) {
|
|||||||
actor: actionAuditLog.actor
|
actor: actionAuditLog.actor
|
||||||
})
|
})
|
||||||
.from(actionAuditLog)
|
.from(actionAuditLog)
|
||||||
.where(
|
.where(getWhere(data))
|
||||||
and(
|
|
||||||
gt(actionAuditLog.timestamp, timeStart),
|
|
||||||
lt(actionAuditLog.timestamp, timeEnd),
|
|
||||||
eq(actionAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(actionAuditLog.timestamp);
|
.orderBy(actionAuditLog.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countActionQuery(timeStart: number, timeEnd: number, orgId: string) {
|
export function countActionQuery(data: Q) {
|
||||||
const countQuery = db
|
const countQuery = db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(actionAuditLog)
|
.from(actionAuditLog)
|
||||||
.where(
|
.where(getWhere(data));
|
||||||
and(
|
|
||||||
gt(actionAuditLog.timestamp, timeStart),
|
|
||||||
lt(actionAuditLog.timestamp, timeEnd),
|
|
||||||
eq(actionAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return countQuery;
|
return countQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +130,6 @@ export async function queryActionAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
@@ -134,13 +139,14 @@ export async function queryActionAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryAction(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryAction(data);
|
||||||
|
|
||||||
const totalCountResult = await countActionQuery(timeStart, timeEnd, orgId);
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
const totalCountResult = await countActionQuery(data);
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
return response<QueryActionAuditLogResponse>(res, {
|
return response<QueryActionAuditLogResponse>(res, {
|
||||||
@@ -148,8 +154,8 @@ export async function queryActionAuditLogs(
|
|||||||
log: log,
|
log: log,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit: data.limit,
|
||||||
offset
|
offset: data.offset
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -28,11 +28,26 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.default(new Date().toISOString()),
|
.default(new Date().toISOString()),
|
||||||
action: z.boolean().optional(),
|
action: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||||
|
.optional(),
|
||||||
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
||||||
reason: z.number().optional(),
|
reason: z
|
||||||
resourceId: z.number().optional(),
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
.optional(),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
.optional(),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
|
host: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -65,7 +80,12 @@ function getWhere(data: Q) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||||
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined
|
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined,
|
||||||
|
data.host ? eq(requestAuditLog.host, data.host) : undefined,
|
||||||
|
data.path ? eq(requestAuditLog.path, data.path) : undefined,
|
||||||
|
data.action !== undefined
|
||||||
|
? eq(requestAuditLog.action, data.action)
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +144,71 @@ registry.registerPath({
|
|||||||
responses: {}
|
responses: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function queryUniqueFilterAttributes(
|
||||||
|
timeStart: number,
|
||||||
|
timeEnd: number,
|
||||||
|
orgId: string
|
||||||
|
) {
|
||||||
|
const baseConditions = and(
|
||||||
|
gt(requestAuditLog.timestamp, timeStart),
|
||||||
|
lt(requestAuditLog.timestamp, timeEnd),
|
||||||
|
eq(requestAuditLog.orgId, orgId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get unique actors
|
||||||
|
const uniqueActors = await db
|
||||||
|
.selectDistinct({
|
||||||
|
actor: requestAuditLog.actor
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique locations
|
||||||
|
const uniqueLocations = await db
|
||||||
|
.selectDistinct({
|
||||||
|
locations: requestAuditLog.location
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique actors
|
||||||
|
const uniqueHosts = await db
|
||||||
|
.selectDistinct({
|
||||||
|
hosts: requestAuditLog.host
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique actors
|
||||||
|
const uniquePaths = await db
|
||||||
|
.selectDistinct({
|
||||||
|
paths: requestAuditLog.path
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique resources with names
|
||||||
|
const uniqueResources = await db
|
||||||
|
.selectDistinct({
|
||||||
|
id: requestAuditLog.resourceId,
|
||||||
|
name: resources.name
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(requestAuditLog.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
||||||
|
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
|
||||||
|
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null),
|
||||||
|
hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null),
|
||||||
|
paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function queryRequestAuditLogs(
|
export async function queryRequestAuditLogs(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -159,6 +244,12 @@ export async function queryRequestAuditLogs(
|
|||||||
const totalCountResult = await countRequestQuery(data);
|
const totalCountResult = await countRequestQuery(data);
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
const filterAttributes = await queryUniqueFilterAttributes(
|
||||||
|
data.timeStart,
|
||||||
|
data.timeEnd,
|
||||||
|
data.orgId
|
||||||
|
);
|
||||||
|
|
||||||
return response<QueryRequestAuditLogResponse>(res, {
|
return response<QueryRequestAuditLogResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
log: log,
|
log: log,
|
||||||
@@ -166,7 +257,8 @@ export async function queryRequestAuditLogs(
|
|||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit: data.limit,
|
limit: data.limit,
|
||||||
offset: data.offset
|
offset: data.offset
|
||||||
}
|
},
|
||||||
|
filterAttributes
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ export type QueryRequestAuditLogResponse = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
filterAttributes: {
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
hosts: string[];
|
||||||
|
paths: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryAccessAuditLogResponse = {
|
export type QueryAccessAuditLogResponse = {
|
||||||
@@ -69,4 +79,12 @@ export type QueryAccessAuditLogResponse = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
};
|
filterAttributes: {
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,17 +4,19 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { LogDataTable } from "@app/components/LogDataTable";
|
import { LogDataTable } from "@app/components/LogDataTable";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
import { ArrowUpRight, Key, User } from "lucide-react";
|
import { ArrowUpRight, Key, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -23,6 +25,33 @@ export default function GeneralPage() {
|
|||||||
const [rows, setRows] = useState<any[]>([]);
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
|
const [filters, setFilters] = useState<{
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
location?: string;
|
||||||
|
actor?: string;
|
||||||
|
}>({
|
||||||
|
action: searchParams.get("action") || undefined,
|
||||||
|
type: searchParams.get("type") || undefined,
|
||||||
|
resourceId: searchParams.get("resourceId") || undefined,
|
||||||
|
location: searchParams.get("location") || undefined,
|
||||||
|
actor: searchParams.get("actor") || undefined
|
||||||
|
});
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
@@ -32,6 +61,20 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
// Set default date range to last 24 hours
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
|
const startParam = searchParams.get("start");
|
||||||
|
const endParam = searchParams.get("end");
|
||||||
|
if (startParam && endParam) {
|
||||||
|
return {
|
||||||
|
startDate: {
|
||||||
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -53,7 +96,12 @@ export default function GeneralPage() {
|
|||||||
// Trigger search with default values on component mount
|
// Trigger search with default values on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultRange = getDefaultDateRange();
|
const defaultRange = getDefaultDateRange();
|
||||||
queryDateTime(defaultRange.startDate, defaultRange.endDate);
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
|
defaultRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}, [orgId]); // Re-run if orgId changes
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
@@ -62,6 +110,12 @@ export default function GeneralPage() {
|
|||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
|
updateUrlParamsForAllFilters({
|
||||||
|
start: startDate.date?.toISOString() || "",
|
||||||
|
end: endDate.date?.toISOString() || ""
|
||||||
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,20 +137,76 @@ export default function GeneralPage() {
|
|||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
|
const handleFilterChange = (
|
||||||
|
filterType: keyof typeof filters,
|
||||||
|
value: string | undefined
|
||||||
|
) => {
|
||||||
|
console.log(`${filterType} filter changed:`, value);
|
||||||
|
|
||||||
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUrlParamsForAllFilters = (
|
||||||
|
newFilters:
|
||||||
|
| typeof filters
|
||||||
|
| {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
Object.entries(newFilters).forEach(([key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
const queryDateTime = async (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue,
|
endDate: DateTimeValue,
|
||||||
page: number = currentPage,
|
page: number = currentPage,
|
||||||
size: number = pageSize
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
// Convert the date/time values to API parameters
|
||||||
let params: any = {
|
let params: any = {
|
||||||
limit: size,
|
limit: size,
|
||||||
offset: page * size
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startDate?.date) {
|
if (startDate?.date) {
|
||||||
@@ -134,6 +244,7 @@ export default function GeneralPage() {
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setRows(res.data.data.log || []);
|
setRows(res.data.data.log || []);
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
console.log("Fetched logs:", res.data);
|
console.log("Fetched logs:", res.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,16 +283,21 @@ export default function GeneralPage() {
|
|||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
|
||||||
|
// Prepare query params for export
|
||||||
|
let params: any = {
|
||||||
|
timeStart: dateRange.startDate?.date
|
||||||
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
timeEnd: dateRange.endDate?.date
|
||||||
|
? new Date(dateRange.endDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
|
||||||
const response = await api.get(`/org/${orgId}/logs/access/export`, {
|
const response = await api.get(`/org/${orgId}/logs/access/export`, {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
params: {
|
params
|
||||||
timeStart: dateRange.startDate?.date
|
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
|
||||||
: undefined,
|
|
||||||
timeEnd: dateRange.endDate?.date
|
|
||||||
? new Date(dateRange.endDate.date).toISOString()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
// Create a URL for the blob and trigger a download
|
||||||
@@ -225,7 +341,24 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("action");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("action")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: "Allowed" },
|
||||||
|
{ value: "false", label: "Denied" }
|
||||||
|
]}
|
||||||
|
selectedValue={filters.action}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("action", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +377,26 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("location");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("location")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.locations.map(
|
||||||
|
(location) => ({
|
||||||
|
value: location,
|
||||||
|
label: location
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
selectedValue={filters.location}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("location", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -264,7 +416,26 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: t("resource"),
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("resource")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.resources.map((res) => ({
|
||||||
|
value: res.id.toString(),
|
||||||
|
label: res.name || "Unnamed Resource"
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.resourceId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("resourceId", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -285,26 +456,56 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("type");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("type")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={[
|
||||||
|
{ value: "password", label: "Password" },
|
||||||
|
{ value: "pincode", label: "Pincode" },
|
||||||
|
{ value: "login", label: "Login" },
|
||||||
|
{
|
||||||
|
value: "whitelistedEmail",
|
||||||
|
label: "Whitelisted Email"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={filters.type}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("type", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
// should be capitalized first letter
|
||||||
<span className="flex items-center gap-1">
|
return <span>{row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1) || "-"}</span>;
|
||||||
{/* {row.original.type == "pincode" ? (
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Key className="h-4 w-4" />
|
|
||||||
)} */}
|
|
||||||
{row.original.type.charAt(0).toUpperCase() +
|
|
||||||
row.original.type.slice(1)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("actor");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("actor")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.actors.map((actor) => ({
|
||||||
|
value: actor,
|
||||||
|
label: actor
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.actor}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("actor", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -344,10 +545,24 @@ export default function GeneralPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
{row.userAgent != "node" && (
|
||||||
|
<div>
|
||||||
|
<strong>User Agent:</strong>
|
||||||
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
|
{row.userAgent || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>Metadata:</strong>
|
<strong>Metadata:</strong>
|
||||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
|
{row.metadata
|
||||||
|
? JSON.stringify(
|
||||||
|
JSON.parse(row.metadata),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
: "N/A"}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,8 +577,6 @@ export default function GeneralPage() {
|
|||||||
data={rows}
|
data={rows}
|
||||||
persistPageSize="access-logs-table"
|
persistPageSize="access-logs-table"
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
|
||||||
searchColumn="type"
|
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onExport={exportData}
|
onExport={exportData}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { LogDataTable } from "@app/components/LogDataTable";
|
import { LogDataTable } from "@app/components/LogDataTable";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
|
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
@@ -30,8 +31,60 @@ export default function GeneralPage() {
|
|||||||
const [pageSize, setPageSize] = useState<number>(20);
|
const [pageSize, setPageSize] = useState<number>(20);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [filterAttributes, setFilterAttributes] = useState<{
|
||||||
|
actors: string[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
locations: string[];
|
||||||
|
hosts: string[];
|
||||||
|
paths: string[];
|
||||||
|
}>({
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: [],
|
||||||
|
hosts: [],
|
||||||
|
paths: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states - unified object for all filters
|
||||||
|
const [filters, setFilters] = useState<{
|
||||||
|
action?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
host?: string;
|
||||||
|
location?: string;
|
||||||
|
actor?: string;
|
||||||
|
method?: string;
|
||||||
|
reason?: string;
|
||||||
|
path?: string;
|
||||||
|
}>({
|
||||||
|
action: searchParams.get("action") || undefined,
|
||||||
|
host: searchParams.get("host") || undefined,
|
||||||
|
resourceId: searchParams.get("resourceId") || undefined,
|
||||||
|
location: searchParams.get("location") || undefined,
|
||||||
|
actor: searchParams.get("actor") || undefined,
|
||||||
|
method: searchParams.get("method") || undefined,
|
||||||
|
reason: searchParams.get("reason") || undefined,
|
||||||
|
path: searchParams.get("path") || undefined
|
||||||
|
});
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
// Set default date range to last 24 hours
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
|
// if the time is in the url params, use that instead
|
||||||
|
const startParam = searchParams.get("start");
|
||||||
|
const endParam = searchParams.get("end");
|
||||||
|
if (startParam && endParam) {
|
||||||
|
return {
|
||||||
|
startDate: {
|
||||||
|
date: new Date(startParam)
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
date: new Date(endParam)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -53,7 +106,12 @@ export default function GeneralPage() {
|
|||||||
// Trigger search with default values on component mount
|
// Trigger search with default values on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultRange = getDefaultDateRange();
|
const defaultRange = getDefaultDateRange();
|
||||||
queryDateTime(defaultRange.startDate, defaultRange.endDate);
|
queryDateTime(
|
||||||
|
defaultRange.startDate,
|
||||||
|
defaultRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize
|
||||||
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}, [orgId]); // Re-run if orgId changes
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
@@ -62,6 +120,12 @@ export default function GeneralPage() {
|
|||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
// put the search params in the url for the time
|
||||||
|
updateUrlParamsForAllFilters({
|
||||||
|
start: startDate.date?.toISOString() || "",
|
||||||
|
end: endDate.date?.toISOString() || ""
|
||||||
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
queryDateTime(startDate, endDate, 0, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,20 +147,76 @@ export default function GeneralPage() {
|
|||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle filter changes generically
|
||||||
|
const handleFilterChange = (
|
||||||
|
filterType: keyof typeof filters,
|
||||||
|
value: string | undefined
|
||||||
|
) => {
|
||||||
|
console.log(`${filterType} filter changed:`, value);
|
||||||
|
|
||||||
|
// Create new filters object with updated value
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[filterType]: value
|
||||||
|
};
|
||||||
|
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(0); // Reset to first page when filtering
|
||||||
|
|
||||||
|
// Update URL params
|
||||||
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
|
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||||
|
queryDateTime(
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
0,
|
||||||
|
pageSize,
|
||||||
|
newFilters
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUrlParamsForAllFilters = (
|
||||||
|
newFilters:
|
||||||
|
| typeof filters
|
||||||
|
| {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
Object.entries(newFilters).forEach(([key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
const queryDateTime = async (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue,
|
endDate: DateTimeValue,
|
||||||
page: number = currentPage,
|
page: number = currentPage,
|
||||||
size: number = pageSize
|
size: number = pageSize,
|
||||||
|
filtersParam?: {
|
||||||
|
action?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use the provided filters or fall back to current state
|
||||||
|
const activeFilters = filtersParam || filters;
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
// Convert the date/time values to API parameters
|
||||||
let params: any = {
|
let params: any = {
|
||||||
limit: size,
|
limit: size,
|
||||||
offset: page * size
|
offset: page * size,
|
||||||
|
...activeFilters
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startDate?.date) {
|
if (startDate?.date) {
|
||||||
@@ -134,6 +254,7 @@ export default function GeneralPage() {
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setRows(res.data.data.log || []);
|
setRows(res.data.data.log || []);
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
setTotalCount(res.data.data.pagination?.total || 0);
|
||||||
|
setFilterAttributes(res.data.data.filterAttributes);
|
||||||
console.log("Fetched logs:", res.data);
|
console.log("Fetched logs:", res.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,18 +293,23 @@ export default function GeneralPage() {
|
|||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
|
||||||
|
// Prepare query params for export
|
||||||
|
let params: any = {
|
||||||
|
timeStart: dateRange.startDate?.date
|
||||||
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
timeEnd: dateRange.endDate?.date
|
||||||
|
? new Date(dateRange.endDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/org/${orgId}/logs/request/export`,
|
`/org/${orgId}/logs/request/export`,
|
||||||
{
|
{
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
params: {
|
params
|
||||||
timeStart: dateRange.startDate?.date
|
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
|
||||||
: undefined,
|
|
||||||
timeEnd: dateRange.endDate?.date
|
|
||||||
? new Date(dateRange.endDate.date).toISOString()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -269,7 +395,24 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("action");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("action")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: "Allowed" },
|
||||||
|
{ value: "false", label: "Denied" }
|
||||||
|
]}
|
||||||
|
selectedValue={filters.action}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("action", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -288,7 +431,26 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("location");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("location")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.locations.map(
|
||||||
|
(location) => ({
|
||||||
|
value: location,
|
||||||
|
label: location
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
selectedValue={filters.location}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("location", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -308,7 +470,26 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: t("resource"),
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("resource")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.resources.map((res) => ({
|
||||||
|
value: res.id.toString(),
|
||||||
|
label: res.name || "Unnamed Resource"
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.resourceId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("resourceId", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -330,7 +511,24 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "host",
|
accessorKey: "host",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("host");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("host")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.hosts.map((host) => ({
|
||||||
|
value: host,
|
||||||
|
label: host
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.host}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("host", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -348,8 +546,25 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "path",
|
accessorKey: "path",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("path");
|
return (
|
||||||
}
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("path")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.paths.map((path) => ({
|
||||||
|
value: path,
|
||||||
|
label: path
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.path}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("path", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// {
|
// {
|
||||||
@@ -361,13 +576,65 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("method");
|
return (
|
||||||
}
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("method")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={[
|
||||||
|
{ value: "GET", label: "GET" },
|
||||||
|
{ value: "POST", label: "POST" },
|
||||||
|
{ value: "PUT", label: "PUT" },
|
||||||
|
{ value: "DELETE", label: "DELETE" },
|
||||||
|
{ value: "PATCH", label: "PATCH" },
|
||||||
|
{ value: "HEAD", label: "HEAD" },
|
||||||
|
{ value: "OPTIONS", label: "OPTIONS" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
selectedValue={filters.method}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("method", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "reason",
|
accessorKey: "reason",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("reason");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("reason")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={[
|
||||||
|
{ value: "100", label: t("allowedByRule") },
|
||||||
|
{ value: "101", label: t("allowedNoAuth") },
|
||||||
|
{ value: "102", label: t("validAccessToken") },
|
||||||
|
{ value: "103", label: t("validHeaderAuth") },
|
||||||
|
{ value: "104", label: t("validPincode") },
|
||||||
|
{ value: "105", label: t("validPassword") },
|
||||||
|
{ value: "106", label: t("validEmail") },
|
||||||
|
{ value: "107", label: t("validSSO") },
|
||||||
|
{ value: "201", label: t("resourceNotFound") },
|
||||||
|
{ value: "202", label: t("resourceBlocked") },
|
||||||
|
{ value: "203", label: t("droppedByRule") },
|
||||||
|
{ value: "204", label: t("noSessions") },
|
||||||
|
{ value: "205", label: t("temporaryRequestToken") },
|
||||||
|
{ value: "299", label: t("noMoreAuthMethods") }
|
||||||
|
]}
|
||||||
|
selectedValue={filters.reason}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("reason", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -380,7 +647,24 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return t("actor");
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t("actor")}</span>
|
||||||
|
<ColumnFilter
|
||||||
|
options={filterAttributes.actors.map((actor) => ({
|
||||||
|
value: actor,
|
||||||
|
label: actor
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.actor}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("actor", value)
|
||||||
|
}
|
||||||
|
// placeholder=""
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
emptyMessage="None found"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
104
src/components/ColumnFilter.tsx
Normal file
104
src/components/ColumnFilter.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command";
|
||||||
|
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnFilterProps {
|
||||||
|
options: FilterOption[];
|
||||||
|
selectedValue?: string;
|
||||||
|
onValueChange: (value: string | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnFilter({
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onValueChange,
|
||||||
|
placeholder,
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
emptyMessage = "No options found",
|
||||||
|
className
|
||||||
|
}: ColumnFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedOption = options.find(option => option.value === selectedValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2",
|
||||||
|
!selectedValue && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[200px]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* Clear filter option */}
|
||||||
|
{selectedValue && (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(undefined);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(
|
||||||
|
selectedValue === option.value ? undefined : option.value
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedValue === option.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,9 +80,9 @@ const getDisplayText = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-4", className)}>
|
<div className={cn("flex gap-4", className)}>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-2">
|
||||||
{label && (
|
{label && (
|
||||||
<Label htmlFor="date-picker" className="px-1">
|
<Label htmlFor="date-picker">
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
@@ -193,9 +193,9 @@ export function DateRangePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-4", className)}>
|
<div className={cn("flex gap-4 items-center", className)}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
// label={startLabel}
|
label="Start"
|
||||||
value={startValue}
|
value={startValue}
|
||||||
onChange={handleStartChange}
|
onChange={handleStartChange}
|
||||||
placeholder="Start date & time"
|
placeholder="Start date & time"
|
||||||
@@ -203,7 +203,7 @@ export function DateRangePicker({
|
|||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
/>
|
/>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
// label={endLabel}
|
label="End"
|
||||||
value={endValue}
|
value={endValue}
|
||||||
onChange={handleEndChange}
|
onChange={handleEndChange}
|
||||||
placeholder="End date & time"
|
placeholder="End date & time"
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export function LogDataTable<TData, TValue>({
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
onExport,
|
onExport,
|
||||||
isExporting,
|
isExporting,
|
||||||
searchPlaceholder = "Search...",
|
// searchPlaceholder = "Search...",
|
||||||
searchColumn = "name",
|
// searchColumn = "name",
|
||||||
defaultSort,
|
defaultSort,
|
||||||
tabs,
|
tabs,
|
||||||
defaultTab,
|
defaultTab,
|
||||||
@@ -354,7 +354,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||||
<div className="flex flex-row items-start w-full sm:mr-2 gap-2">
|
<div className="flex flex-row items-start w-full sm:mr-2 gap-2">
|
||||||
<div className="relative w-full sm:max-w-sm">
|
{/* <div className="relative w-full sm:max-w-sm">
|
||||||
<Input
|
<Input
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={globalFilter ?? ""}
|
value={globalFilter ?? ""}
|
||||||
@@ -366,7 +366,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
className="w-full pl-8 m-0"
|
className="w-full pl-8 m-0"
|
||||||
/>
|
/>
|
||||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
</div>
|
</div> */}
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
startValue={startDate}
|
startValue={startDate}
|
||||||
endValue={endDate}
|
endValue={endDate}
|
||||||
|
|||||||
Reference in New Issue
Block a user