diff --git a/backend/src/ee/routes/v1/ai-mcp-activity-log-router.ts b/backend/src/ee/routes/v1/ai-mcp-activity-log-router.ts index a8eed9566b..94dfb370e1 100644 --- a/backend/src/ee/routes/v1/ai-mcp-activity-log-router.ts +++ b/backend/src/ee/routes/v1/ai-mcp-activity-log-router.ts @@ -14,23 +14,45 @@ export const registerAiMcpActivityLogRouter = async (server: FastifyZodProvider) }, schema: { querystring: z.object({ - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + endpointName: z.string().optional(), + serverName: z.string().optional(), + toolName: z.string().optional(), + actor: z.string().optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) }), response: { - 200: z.array(AiMcpActivityLogsSchema) + 200: z.object({ + activityLogs: z.array(AiMcpActivityLogsSchema) + }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const { projectId, endpointName, serverName, toolName, actor, startDate, endDate, offset, limit } = req.query; + const activityLogs = await server.services.aiMcpActivityLog.listActivityLogs({ - projectId: req.query.projectId, + projectId, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + filter: { + endpointName, + serverName, + toolName, + actor, + startDate, + endDate, + offset, + limit + } }); - return activityLogs; + return { activityLogs }; } }); }; diff --git a/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal.ts b/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal.ts index 7bc47939d8..82afff447c 100644 --- a/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal.ts +++ b/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal.ts @@ -1,11 +1,86 @@ +import knex from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { TableName, TAiMcpActivityLogs } from "@app/db/schemas"; +import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, TOrmify } from "@app/lib/knex"; -export type TAiMcpActivityLogDALFactory = ReturnType; +export type TFindActivityLogsQuery = { + projectId: string; + endpointName?: string; + serverName?: string; + toolName?: string; + actor?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +}; -export const aiMcpActivityLogDALFactory = (db: TDbClient) => { +export interface TAiMcpActivityLogDALFactory extends Omit, "find"> { + find: (arg: TFindActivityLogsQuery, tx?: knex.Knex) => Promise; +} + +export const aiMcpActivityLogDALFactory = (db: TDbClient): TAiMcpActivityLogDALFactory => { const aiMcpActivityLogOrm = ormify(db, TableName.AiMcpActivityLog); - return aiMcpActivityLogOrm; + const find: TAiMcpActivityLogDALFactory["find"] = async ( + { projectId, endpointName, serverName, toolName, actor, startDate, endDate, limit = 20, offset = 0 }, + tx + ) => { + try { + const sqlQuery = (tx || db.replicaNode())(TableName.AiMcpActivityLog).where( + `${TableName.AiMcpActivityLog}.projectId`, + projectId + ); + + // Apply date filters if provided + if (startDate) { + void sqlQuery.whereRaw(`"${TableName.AiMcpActivityLog}"."createdAt" >= ?::timestamptz`, [startDate]); + } + if (endDate) { + void sqlQuery.andWhereRaw(`"${TableName.AiMcpActivityLog}"."createdAt" < ?::timestamptz`, [endDate]); + } + + // Apply exact filters + if (endpointName) { + void sqlQuery.where(`${TableName.AiMcpActivityLog}.endpointName`, endpointName); + } + + if (serverName) { + void sqlQuery.where(`${TableName.AiMcpActivityLog}.serverName`, serverName); + } + + if (toolName) { + void sqlQuery.where(`${TableName.AiMcpActivityLog}.toolName`, toolName); + } + + if (actor) { + void sqlQuery.where(`${TableName.AiMcpActivityLog}.actor`, actor); + } + + // Apply pagination and ordering + void sqlQuery + .select(selectAllTableCols(TableName.AiMcpActivityLog)) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.AiMcpActivityLog}.createdAt`, "desc"); + + // Timeout long running queries to prevent DB resource issues (2 minutes) + const docs = await sqlQuery.timeout(1000 * 120); + + return docs; + } catch (error) { + if (error instanceof knex.KnexTimeoutError) { + throw new GatewayTimeoutError({ + error, + message: "Failed to fetch MCP activity logs due to timeout. Add more search filters." + }); + } + + throw new DatabaseError({ error }); + } + }; + + return { ...aiMcpActivityLogOrm, find }; }; diff --git a/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service.ts b/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service.ts index 046c6c1f2e..8078389725 100644 --- a/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service.ts +++ b/backend/src/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service.ts @@ -5,7 +5,7 @@ import { TProjectPermission } from "@app/lib/types"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; -import { TAiMcpActivityLogDALFactory } from "./ai-mcp-activity-log-dal"; +import { TAiMcpActivityLogDALFactory, TFindActivityLogsQuery } from "./ai-mcp-activity-log-dal"; export type TAiMcpActivityLogServiceFactory = ReturnType; @@ -14,7 +14,20 @@ export type TAiMcpActivityLogServiceFactoryDep = { permissionService: Pick; }; -export type TListActivityLogsDTO = TProjectPermission; +export type TListActivityLogsFilter = { + endpointName?: string; + serverName?: string; + toolName?: string; + actor?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +}; + +export type TListActivityLogsDTO = TProjectPermission & { + filter?: TListActivityLogsFilter; +}; export const aiMcpActivityLogServiceFactory = ({ aiMcpActivityLogDAL, @@ -24,7 +37,14 @@ export const aiMcpActivityLogServiceFactory = ({ return aiMcpActivityLogDAL.create(activityLog); }; - const listActivityLogs = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId }: TListActivityLogsDTO) => { + const listActivityLogs = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + filter + }: TListActivityLogsDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -36,7 +56,12 @@ export const aiMcpActivityLogServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.McpActivityLogs); - return aiMcpActivityLogDAL.find({ projectId }); + const query: TFindActivityLogsQuery = { + projectId, + ...filter + }; + + return aiMcpActivityLogDAL.find(query); }; return { diff --git a/frontend/src/hooks/api/aiMcpActivityLogs/index.ts b/frontend/src/hooks/api/aiMcpActivityLogs/index.ts index 822aa34f96..96a2e2e00d 100644 --- a/frontend/src/hooks/api/aiMcpActivityLogs/index.ts +++ b/frontend/src/hooks/api/aiMcpActivityLogs/index.ts @@ -1,2 +1,2 @@ export { aiMcpActivityLogKeys, useListAiMcpActivityLogs } from "./queries"; -export * from "./types"; +export type { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types"; diff --git a/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx b/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx index 0782eade0e..44ee88104b 100644 --- a/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx +++ b/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx @@ -1,23 +1,52 @@ -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { onRequestError } from "@app/hooks/api/reactQuery"; +import { TReactQueryOptions } from "@app/types/reactQuery"; -import { TAiMcpActivityLog, TListAiMcpActivityLogsDTO } from "./types"; +import { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types"; export const aiMcpActivityLogKeys = { all: ["aiMcpActivityLogs"] as const, - list: (projectId: string) => [...aiMcpActivityLogKeys.all, "list", projectId] as const + list: (projectId: string, filters: TListAiMcpActivityLogsFilter) => + [...aiMcpActivityLogKeys.all, "list", projectId, filters] as const }; -export const useListAiMcpActivityLogs = ({ projectId }: TListAiMcpActivityLogsDTO) => { - return useQuery({ - queryKey: aiMcpActivityLogKeys.list(projectId), - queryFn: async () => { - const { data } = await apiRequest.get("/api/v1/ai/mcp-activity-logs", { - params: { projectId } - }); - return data; +export const useListAiMcpActivityLogs = ( + filters: TListAiMcpActivityLogsFilter, + options: TReactQueryOptions["options"] = {} +) => { + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: aiMcpActivityLogKeys.list(filters.projectId, filters), + queryFn: async ({ pageParam }) => { + try { + const { data } = await apiRequest.get<{ activityLogs: TAiMcpActivityLog[] }>( + "/api/v1/ai/mcp-activity-logs", + { + params: { + projectId: filters.projectId, + offset: pageParam, + limit: filters.limit, + startDate: filters.startDate.toISOString(), + endDate: filters.endDate.toISOString(), + ...(filters.endpointName ? { endpointName: filters.endpointName } : {}), + ...(filters.serverName ? { serverName: filters.serverName } : {}), + ...(filters.toolName ? { toolName: filters.toolName } : {}), + ...(filters.actor ? { actor: filters.actor } : {}) + } + } + ); + return data.activityLogs; + } catch (error) { + onRequestError(error); + return []; + } }, - enabled: Boolean(projectId) + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined, + placeholderData: (prev) => prev, + enabled: Boolean(filters.projectId), + ...options }); }; diff --git a/frontend/src/hooks/api/aiMcpActivityLogs/types.ts b/frontend/src/hooks/api/aiMcpActivityLogs/types.ts index 6b11657cd3..2ffb488a40 100644 --- a/frontend/src/hooks/api/aiMcpActivityLogs/types.ts +++ b/frontend/src/hooks/api/aiMcpActivityLogs/types.ts @@ -11,6 +11,17 @@ export type TAiMcpActivityLog = { updatedAt: string; }; +export type TListAiMcpActivityLogsFilter = { + projectId: string; + endpointName?: string; + serverName?: string; + toolName?: string; + actor?: string; + startDate: Date; + endDate: Date; + limit: number; +}; + export type TListAiMcpActivityLogsDTO = { projectId: string; }; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsDateFilter.tsx b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsDateFilter.tsx new file mode 100644 index 0000000000..77f094b7e3 --- /dev/null +++ b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsDateFilter.tsx @@ -0,0 +1,345 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCalendar, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; + +import { + Button, + DatePicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { formatDateTime, Timezone } from "@app/helpers/datetime"; + +import { + mcpActivityLogDateFilterFormSchema, + MCPActivityLogDateFilterType, + TMCPActivityLogDateFilterFormData +} from "./types"; + +type Props = { + setFilter: (data: TMCPActivityLogDateFilterFormData) => void; + filter: TMCPActivityLogDateFilterFormData; + setTimezone: (timezone: Timezone) => void; + timezone: Timezone; +}; + +const RELATIVE_VALUES = ["5m", "30m", "1h", "3h", "12h"]; + +const RELATIVE_OPTIONS = [ + { label: "Minutes", unit: "m", values: [5, 10, 15, 30, 45] }, + { label: "Hours", unit: "h", values: [1, 2, 3, 6, 8, 12] }, + { label: "Days", unit: "d", values: [1, 2, 3, 4, 5, 6] }, + { label: "Weeks", unit: "w", values: [1, 2, 3, 4] } +]; + +export const MCPActivityLogsDateFilter = ({ setFilter, filter, timezone, setTimezone }: Props) => { + const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); + const [isPopupOpen, setIsPopOpen] = useState(false); + + const { control, watch, handleSubmit, formState } = useForm({ + resolver: zodResolver(mcpActivityLogDateFilterFormSchema), + values: filter + }); + const selectType = watch("type"); + const isCustomRelative = + filter.type === MCPActivityLogDateFilterType.Relative && + !RELATIVE_VALUES.includes(filter.relativeModeValue || ""); + + const onSubmit = (data: TMCPActivityLogDateFilterFormData) => { + const endDate = data.type === MCPActivityLogDateFilterType.Relative ? new Date() : data.endDate; + const startDate = + data.type === MCPActivityLogDateFilterType.Relative && data.relativeModeValue + ? new Date(Number(new Date()) - ms(data.relativeModeValue)) + : data.startDate; + setFilter({ + ...data, + startDate, + endDate + }); + setIsPopOpen(false); + }; + + return ( + <> + setIsPopOpen(el)}> +
+ {filter.type === MCPActivityLogDateFilterType.Relative ? ( + <> + {RELATIVE_VALUES.map((el) => ( + + ))} + + ) : ( +
+
+ {formatDateTime({ + timezone, + timestamp: filter.startDate, + dateFormat: "yyyy/MM/dd HH:mm" + })} +
+
+ +
+
+ {formatDateTime({ + timezone, + timestamp: filter.endDate, + dateFormat: "yyyy/MM/dd HH:mm" + })} +
+
+ )} + + + +
+ +
+ ( +
+ + +
+ )} + /> + {selectType === MCPActivityLogDateFilterType.Relative && ( + { + const duration = field.value?.substring(0, field.value.length - 1); + const unitOfTime = field.value?.at(-1); + return ( +
+ {RELATIVE_OPTIONS.map(({ label, unit, values }) => ( +
+
{label}
+ {values.map((v) => { + const value = `${v}${unit}`; + return ( + + ); + })} +
+ ))} +
+
+ + { + const durationVal = val.target.value + ? Number(val.target.value) + : undefined; + field.onChange(`${durationVal}${unitOfTime}`); + }} + max={60} + min={1} + /> + + + + +
+ {error && ( + + {error.message} + + )} +
+
+ ); + }} + /> + )} + {selectType === MCPActivityLogDateFilterType.Absolute && ( +
+ { + return ( + + + + ); + }} + /> + + { + return ( + + + + ); + }} + /> +
+ )} +
+ +
+ +
+
+ + + ); +}; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsFilter.tsx b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsFilter.tsx index 7ce09ada93..14f23a70b8 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsFilter.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsFilter.tsx @@ -1,30 +1,23 @@ -import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; -import { SingleValue } from "react-select"; import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, - FilterableSelect, - FormControl + FormControl, + Input } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { TAiMcpActivityLog } from "@app/hooks/api"; -export type TMCPActivityLogFilter = { - endpoint?: string; - tool?: string; - user?: string; -}; +import { mcpActivityLogFilterFormSchema, TMCPActivityLogFilterFormData } from "./types"; type Props = { - filter: TMCPActivityLogFilter; - setFilter: (filter: TMCPActivityLogFilter) => void; - activityLogs: TAiMcpActivityLog[]; + filter: TMCPActivityLogFilterFormData; + setFilter: (filter: TMCPActivityLogFilterFormData) => void; }; type FilterItemProps = { @@ -52,35 +45,21 @@ const FilterItem = ({ label, onClear, children }: FilterItemProps) => { ); }; -const getActiveFilterCount = (filter: TMCPActivityLogFilter): number => { +const getActiveFilterCount = (filter: TMCPActivityLogFilterFormData): number => { let count = 0; - if (filter.endpoint) count += 1; - if (filter.tool) count += 1; - if (filter.user) count += 1; + if (filter.endpointName) count += 1; + if (filter.serverName) count += 1; + if (filter.toolName) count += 1; + if (filter.actor) count += 1; return count; }; -export const MCPActivityLogsFilter = ({ filter, setFilter, activityLogs }: Props) => { - const { control, handleSubmit, setValue, formState } = useForm({ +export const MCPActivityLogsFilter = ({ filter, setFilter }: Props) => { + const { control, handleSubmit, setValue, formState } = useForm({ + resolver: zodResolver(mcpActivityLogFilterFormSchema), values: filter }); - // Generate filter options dynamically from activity logs - const endpointOptions = useMemo(() => { - const uniqueEndpoints = Array.from(new Set(activityLogs.map((log) => log.endpointName))); - return uniqueEndpoints.map((endpoint) => ({ label: endpoint, value: endpoint })); - }, [activityLogs]); - - const toolOptions = useMemo(() => { - const uniqueTools = Array.from(new Set(activityLogs.map((log) => log.toolName))); - return uniqueTools.map((tool) => ({ label: tool, value: tool })); - }, [activityLogs]); - - const userOptions = useMemo(() => { - const uniqueUsers = Array.from(new Set(activityLogs.map((log) => log.actor))); - return uniqueUsers.map((user) => ({ label: user, value: user })); - }, [activityLogs]); - const activeFilterCount = getActiveFilterCount(filter); return ( @@ -110,9 +89,10 @@ export const MCPActivityLogsFilter = ({ filter, setFilter, activityLogs }: Props )} diff --git a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsTableRow.tsx b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsTableRow.tsx index f8f7106ea2..52ce0022bf 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsTableRow.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/MCPActivityLogsTableRow.tsx @@ -3,11 +3,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Td, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; - -import { TMCPActivityLog } from "./types"; +import { TAiMcpActivityLog } from "@app/hooks/api"; type Props = { - activityLog: TMCPActivityLog; + activityLog: TAiMcpActivityLog; }; const formatTimestamp = (dateString: string): string => { diff --git a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/types.ts b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/types.ts index 5f15164513..8932462eec 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/types.ts +++ b/frontend/src/pages/ai/MCPPage/components/MCPActivityLogsTab/types.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type TMCPActivityLog = { id: string; projectId: string; @@ -10,3 +12,35 @@ export type TMCPActivityLog = { createdAt: string; updatedAt: string; }; + +export enum MCPActivityLogDateFilterType { + Relative = "relative", + Absolute = "absolute" +} + +export const mcpActivityLogFilterFormSchema = z.object({ + endpointName: z.string().optional(), + serverName: z.string().optional(), + toolName: z.string().optional(), + actor: z.string().optional() +}); + +export const mcpActivityLogDateFilterFormSchema = z + .object({ + startDate: z.date(), + endDate: z.date(), + type: z.nativeEnum(MCPActivityLogDateFilterType), + relativeModeValue: z.string().optional() + }) + .superRefine((el, ctx) => { + if (el.type === MCPActivityLogDateFilterType.Absolute && el.startDate > el.endDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); + } + }); + +export type TMCPActivityLogFilterFormData = z.infer; +export type TMCPActivityLogDateFilterFormData = z.infer;