misc: finalized audit log display

This commit is contained in:
Sheen Capadngan
2025-12-16 21:59:52 +08:00
parent f2cbb22bc1
commit 2dcac4fc27
11 changed files with 690 additions and 164 deletions

View File

@@ -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 };
}
});
};

View File

@@ -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 };
};

View File

@@ -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 {

View File

@@ -1,2 +1,2 @@
export { aiMcpActivityLogKeys, useListAiMcpActivityLogs } from "./queries";
export * from "./types";
export type { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types";

View File

@@ -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
});
};

View File

@@ -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;
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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 => {

View File

@@ -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>;