mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 15:38:03 -05:00
misc: finalized audit log display
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<typeof aiMcpActivityLogDALFactory>;
|
||||
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<TOrmify<TableName.AiMcpActivityLog>, "find"> {
|
||||
find: (arg: TFindActivityLogsQuery, tx?: knex.Knex) => Promise<TAiMcpActivityLogs[]>;
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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<typeof aiMcpActivityLogServiceFactory>;
|
||||
|
||||
@@ -14,7 +14,20 @@ export type TAiMcpActivityLogServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { aiMcpActivityLogKeys, useListAiMcpActivityLogs } from "./queries";
|
||||
export * from "./types";
|
||||
export type { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types";
|
||||
|
||||
@@ -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<TAiMcpActivityLog[]>("/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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<TMCPActivityLogDateFilterFormData>({
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu open={isPopupOpen} onOpenChange={(el) => setIsPopOpen(el)}>
|
||||
<div className="flex items-center">
|
||||
{filter.type === MCPActivityLogDateFilterType.Relative ? (
|
||||
<>
|
||||
{RELATIVE_VALUES.map((el) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className={twMerge(
|
||||
"w-[3.82rem] rounded-none px-3 py-2 font-normal first:rounded-l-md",
|
||||
filter.type === MCPActivityLogDateFilterType.Relative &&
|
||||
filter.relativeModeValue === el &&
|
||||
"border-primary/40 bg-primary/10"
|
||||
)}
|
||||
key={`${el}-relative`}
|
||||
onClick={() =>
|
||||
setFilter({
|
||||
relativeModeValue: el,
|
||||
type: MCPActivityLogDateFilterType.Relative,
|
||||
endDate: new Date(),
|
||||
startDate: new Date(Number(new Date()) - ms(el))
|
||||
})
|
||||
}
|
||||
>
|
||||
{el}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-[19.1rem] items-center justify-between rounded-l-md border border-transparent bg-mineshaft-600 px-5 py-2 text-sm text-bunker-200">
|
||||
<div>
|
||||
{formatDateTime({
|
||||
timezone,
|
||||
timestamp: filter.startDate,
|
||||
dateFormat: "yyyy/MM/dd HH:mm"
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faChevronRight} />
|
||||
</div>
|
||||
<div>
|
||||
{formatDateTime({
|
||||
timezone,
|
||||
timestamp: filter.endDate,
|
||||
dateFormat: "yyyy/MM/dd HH:mm"
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className={twMerge(
|
||||
"w-32 rounded-none rounded-r-md px-3 py-2 font-normal",
|
||||
(filter.type === MCPActivityLogDateFilterType.Absolute || isCustomRelative) &&
|
||||
"border-primary/40 bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<span>Custom</span> <FontAwesomeIcon className="ml-1" icon={faCalendar} />
|
||||
{filter.type === MCPActivityLogDateFilterType.Relative && isCustomRelative && (
|
||||
<span className="ml-1">({filter.relativeModeValue})</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[434px]! bg-mineshaft-800 p-4"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<div className="mb-7">
|
||||
<Button
|
||||
onClick={() => field.onChange(MCPActivityLogDateFilterType.Absolute)}
|
||||
variant="outline_bg"
|
||||
className={twMerge(
|
||||
"h-8 rounded-r-none font-normal",
|
||||
field.value === MCPActivityLogDateFilterType.Absolute &&
|
||||
"border-primary/40 bg-primary/10"
|
||||
)}
|
||||
>
|
||||
Absolute
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => field.onChange(MCPActivityLogDateFilterType.Relative)}
|
||||
variant="outline_bg"
|
||||
className={twMerge(
|
||||
"h-8 rounded-l-none font-normal",
|
||||
field.value === MCPActivityLogDateFilterType.Relative &&
|
||||
"border-primary/40 bg-primary/10"
|
||||
)}
|
||||
>
|
||||
Relative
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{selectType === MCPActivityLogDateFilterType.Relative && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="relativeModeValue"
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const duration = field.value?.substring(0, field.value.length - 1);
|
||||
const unitOfTime = field.value?.at(-1);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{RELATIVE_OPTIONS.map(({ label, unit, values }) => (
|
||||
<div key={unit} className="flex items-center gap-2">
|
||||
<div className="w-16">{label}</div>
|
||||
{values.map((v) => {
|
||||
const value = `${v}${unit}`;
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
variant="outline_bg"
|
||||
onClick={() => field.onChange(value)}
|
||||
className={twMerge(
|
||||
"h-8 w-12",
|
||||
field.value === value && "border-primary/40 bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl
|
||||
className="mb-0 w-28"
|
||||
label="Duration"
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(val) => {
|
||||
const durationVal = val.target.value
|
||||
? Number(val.target.value)
|
||||
: undefined;
|
||||
field.onChange(`${durationVal}${unitOfTime}`);
|
||||
}}
|
||||
max={60}
|
||||
min={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl className="mb-0 w-36" label="Unit of Time">
|
||||
<Select
|
||||
value={unitOfTime}
|
||||
onValueChange={(val) => field.onChange(`${duration}${val}`)}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
{RELATIVE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.unit} value={opt.unit}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-opacity-90 text-xs text-red-600">
|
||||
{error.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectType === MCPActivityLogDateFilterType.Absolute && (
|
||||
<div className="flex h-10 w-full items-center justify-between gap-2">
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
label="Start Date"
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
timezone={timezone}
|
||||
dateFormat="P"
|
||||
buttonClassName="w-44 h-8 font-normal"
|
||||
popUpProps={{
|
||||
open: isStartDatePickerOpen,
|
||||
onOpenChange: setIsStartDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
size="xs"
|
||||
className="mt-6 text-mineshaft-400"
|
||||
/>
|
||||
<Controller
|
||||
name="endDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
label="End Date"
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
buttonClassName="w-44 h-8 font-normal"
|
||||
timezone={timezone}
|
||||
popUpProps={{
|
||||
open: isEndDatePickerOpen,
|
||||
onOpenChange: setIsEndDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8 w-full justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="h-9 w-24 font-normal"
|
||||
variant="outline_bg"
|
||||
isDisabled={!formState.isDirty}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Select
|
||||
value={timezone}
|
||||
onValueChange={(val) => setTimezone(val as Timezone)}
|
||||
className="w-[10.6rem] border border-mineshaft-500! bg-mineshaft-600! capitalize"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
position="popper"
|
||||
dropdownContainerStyle={{
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
{Object.values(Timezone).map((tz) => (
|
||||
<SelectItem value={tz} className="capitalize" key={tz}>
|
||||
{tz} Timezone
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<TMCPActivityLogFilter>({
|
||||
export const MCPActivityLogsFilter = ({ filter, setFilter }: Props) => {
|
||||
const { control, handleSubmit, setValue, formState } = useForm<TMCPActivityLogFilterFormData>({
|
||||
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
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFilter({
|
||||
endpoint: undefined,
|
||||
tool: undefined,
|
||||
user: undefined
|
||||
endpointName: undefined,
|
||||
serverName: undefined,
|
||||
toolName: undefined,
|
||||
actor: undefined
|
||||
});
|
||||
}}
|
||||
variant="link"
|
||||
@@ -128,28 +108,49 @@ export const MCPActivityLogsFilter = ({ filter, setFilter, activityLogs }: Props
|
||||
<FilterItem
|
||||
label="Endpoint"
|
||||
onClear={() => {
|
||||
setValue("endpoint", undefined, { shouldDirty: true });
|
||||
setValue("endpointName", undefined, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="endpoint"
|
||||
name="endpointName"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={endpointOptions.find((opt) => opt.value === value) ?? null}
|
||||
isClearable
|
||||
onChange={(option) =>
|
||||
onChange((option as SingleValue<(typeof endpointOptions)[number]>)?.value)
|
||||
}
|
||||
placeholder="All endpoints"
|
||||
options={endpointOptions}
|
||||
getOptionValue={(option) => option.value}
|
||||
getOptionLabel={(option) => option.label}
|
||||
<Input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder="Filter by endpoint name"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem
|
||||
label="Server"
|
||||
onClear={() => {
|
||||
setValue("serverName", undefined, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="serverName"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder="Filter by server name"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -159,28 +160,23 @@ export const MCPActivityLogsFilter = ({ filter, setFilter, activityLogs }: Props
|
||||
<FilterItem
|
||||
label="Tool"
|
||||
onClear={() => {
|
||||
setValue("tool", undefined, { shouldDirty: true });
|
||||
setValue("toolName", undefined, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tool"
|
||||
name="toolName"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={toolOptions.find((opt) => opt.value === value) ?? null}
|
||||
isClearable
|
||||
onChange={(option) =>
|
||||
onChange((option as SingleValue<(typeof toolOptions)[number]>)?.value)
|
||||
}
|
||||
placeholder="All tools"
|
||||
options={toolOptions}
|
||||
getOptionValue={(option) => option.value}
|
||||
getOptionLabel={(option) => option.label}
|
||||
<Input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder="Filter by tool name"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -190,28 +186,23 @@ export const MCPActivityLogsFilter = ({ filter, setFilter, activityLogs }: Props
|
||||
<FilterItem
|
||||
label="User"
|
||||
onClear={() => {
|
||||
setValue("user", undefined, { shouldDirty: true });
|
||||
setValue("actor", undefined, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="user"
|
||||
name="actor"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={userOptions.find((opt) => opt.value === value) ?? null}
|
||||
isClearable
|
||||
onChange={(option) =>
|
||||
onChange((option as SingleValue<(typeof userOptions)[number]>)?.value)
|
||||
}
|
||||
placeholder="All users"
|
||||
options={userOptions}
|
||||
getOptionValue={(option) => option.value}
|
||||
getOptionLabel={(option) => option.label}
|
||||
<Input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder="Filter by user"
|
||||
className="bg-mineshaft-800"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { faFile, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFile } from "@fortawesome/free-solid-svg-icons";
|
||||
import ms from "ms";
|
||||
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -15,73 +14,71 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { Timezone } from "@app/helpers/datetime";
|
||||
import { useListAiMcpActivityLogs } from "@app/hooks/api";
|
||||
|
||||
import { MCPActivityLogsFilter, TMCPActivityLogFilter } from "./MCPActivityLogsFilter";
|
||||
import { MCPActivityLogsDateFilter } from "./MCPActivityLogsDateFilter";
|
||||
import { MCPActivityLogsFilter } from "./MCPActivityLogsFilter";
|
||||
import { MCPActivityLogsTableRow } from "./MCPActivityLogsTableRow";
|
||||
import {
|
||||
MCPActivityLogDateFilterType,
|
||||
TMCPActivityLogDateFilterFormData,
|
||||
TMCPActivityLogFilterFormData
|
||||
} from "./types";
|
||||
|
||||
const MCP_ACTIVITY_LOG_LIMIT = 30;
|
||||
|
||||
export const MCPActivityLogsTab = () => {
|
||||
const { currentProject } = useProject();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filter, setFilter] = useState<TMCPActivityLogFilter>({
|
||||
endpoint: undefined,
|
||||
tool: undefined,
|
||||
user: undefined
|
||||
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
|
||||
const [logFilter, setLogFilter] = useState<TMCPActivityLogFilterFormData>({
|
||||
endpointName: undefined,
|
||||
serverName: undefined,
|
||||
toolName: undefined,
|
||||
actor: undefined
|
||||
});
|
||||
const [dateFilter, setDateFilter] = useState<TMCPActivityLogDateFilterFormData>({
|
||||
startDate: new Date(Number(new Date()) - ms("1h")),
|
||||
endDate: new Date(),
|
||||
type: MCPActivityLogDateFilterType.Relative,
|
||||
relativeModeValue: "1h"
|
||||
});
|
||||
|
||||
const { data: activityLogs = [], isPending: isLoading } = useListAiMcpActivityLogs({
|
||||
projectId: currentProject?.id || ""
|
||||
});
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useListAiMcpActivityLogs({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: MCP_ACTIVITY_LOG_LIMIT,
|
||||
startDate: dateFilter.startDate,
|
||||
endDate: dateFilter.endDate,
|
||||
endpointName: logFilter.endpointName,
|
||||
serverName: logFilter.serverName,
|
||||
toolName: logFilter.toolName,
|
||||
actor: logFilter.actor
|
||||
});
|
||||
|
||||
// Filter logs based on search and filter state
|
||||
const filteredLogs = activityLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
log.toolName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.endpointName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.actor.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesEndpoint = !filter.endpoint || log.endpointName === filter.endpoint;
|
||||
const matchesTool = !filter.tool || log.toolName === filter.tool;
|
||||
const matchesUser = !filter.user || log.actor === filter.user;
|
||||
|
||||
return matchesSearch && matchesEndpoint && matchesTool && matchesUser;
|
||||
});
|
||||
|
||||
const isEmpty = !isLoading && filteredLogs.length === 0;
|
||||
const hasMore = false; // Pagination not implemented yet
|
||||
// Flatten pages into a single array
|
||||
const activityLogs = data?.pages?.flat() ?? [];
|
||||
const isEmpty = !isPending && activityLogs.length === 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2 whitespace-nowrap">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Activity Logs</p>
|
||||
<p className="text-xl font-medium text-mineshaft-100">Activity Logs</p>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Monitor tool invocations and endpoint usage across your MCP infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<MCPActivityLogsFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
activityLogs={activityLogs}
|
||||
<MCPActivityLogsDateFilter
|
||||
filter={dateFilter}
|
||||
setFilter={setDateFilter}
|
||||
timezone={timezone}
|
||||
setTimezone={setTimezone}
|
||||
/>
|
||||
<MCPActivityLogsFilter filter={logFilter} setFilter={setLogFilter} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} className="text-mineshaft-400" />}
|
||||
className="w-full bg-mineshaft-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
@@ -94,9 +91,9 @@ export const MCPActivityLogsTab = () => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={5} innerKey="mcp-activity-logs" />}
|
||||
{!isLoading &&
|
||||
filteredLogs.map((log) => (
|
||||
{isPending && <TableSkeleton columns={5} innerKey="mcp-activity-logs" />}
|
||||
{!isPending &&
|
||||
activityLogs.map((log) => (
|
||||
<Fragment key={`mcp-activity-log-${log.id}`}>
|
||||
<MCPActivityLogsTableRow activityLog={log} />
|
||||
</Fragment>
|
||||
@@ -111,18 +108,16 @@ export const MCPActivityLogsTab = () => {
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{!isEmpty && hasMore && (
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 px-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={false}
|
||||
isDisabled={!hasMore}
|
||||
onClick={() => {
|
||||
// Pagination will be implemented later
|
||||
}}
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
Load More
|
||||
{hasNextPage ? "Load More" : "End of logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<typeof mcpActivityLogFilterFormSchema>;
|
||||
export type TMCPActivityLogDateFilterFormData = z.infer<typeof mcpActivityLogDateFilterFormSchema>;
|
||||
|
||||
Reference in New Issue
Block a user