diff --git a/app/src/components/requestLogs/LogFilters/AddFilterButton.tsx b/app/src/components/requestLogs/LogFilters/AddFilterButton.tsx new file mode 100644 index 0000000..ae78032 --- /dev/null +++ b/app/src/components/requestLogs/LogFilters/AddFilterButton.tsx @@ -0,0 +1,30 @@ +import { Button, HStack, Icon, Text } from "@chakra-ui/react"; +import { BsPlus } from "react-icons/bs"; +import { comparators } from "~/state/logFiltersSlice"; +import { useAppStore } from "~/state/store"; +import { useFilterableFields } from "~/utils/hooks"; + +const AddFilterButton = () => { + const filterableFields = useFilterableFields().data; + + const addFilter = useAppStore((s) => s.logFilters.addFilter); + + if (!filterableFields || !filterableFields.length || !comparators || !comparators.length) + return null; + + return ( + + addFilter({ field: filterableFields[0] as string, comparator: comparators[0] }) + } + > + + Add Filter + + ); +}; + +export default AddFilterButton; diff --git a/app/src/components/requestLogs/LogFilters/LogFilter.tsx b/app/src/components/requestLogs/LogFilters/LogFilter.tsx new file mode 100644 index 0000000..33f8c48 --- /dev/null +++ b/app/src/components/requestLogs/LogFilters/LogFilter.tsx @@ -0,0 +1,56 @@ +import { HStack, IconButton, Input, Select } from "@chakra-ui/react"; +import { BsTrash } from "react-icons/bs"; + +import { type LogFilter, comparators } from "~/state/logFiltersSlice"; +import { useAppStore } from "~/state/store"; +import { useFilterableFields } from "~/utils/hooks"; + +const LogFilter = ({ filter, index }: { filter: LogFilter; index: number }) => { + const filterableFields = useFilterableFields(); + + const updateFilter = useAppStore((s) => s.logFilters.updateFilter); + const deleteFilter = useAppStore((s) => s.logFilters.deleteFilter); + + const { field, comparator, value } = filter; + + return ( + + + + updateFilter(index, { ...filter, value: e.target.value })} + /> + } + onClick={() => deleteFilter(index)} + /> + + ); +}; + +export default LogFilter; diff --git a/app/src/components/requestLogs/LogFilters/LogFilters.tsx b/app/src/components/requestLogs/LogFilters/LogFilters.tsx new file mode 100644 index 0000000..f5ffa13 --- /dev/null +++ b/app/src/components/requestLogs/LogFilters/LogFilters.tsx @@ -0,0 +1,20 @@ +import { VStack, Text } from "@chakra-ui/react"; + +import AddFilterButton from "./AddFilterButton"; +import { useAppStore } from "~/state/store"; +import LogFilter from "./LogFilter"; + +const LogFilters = () => { + const filters = useAppStore((s) => s.logFilters.filters); + return ( + + Filters + {filters.map((filter, index) => ( + + ))} + + + ); +}; + +export default LogFilters; diff --git a/app/src/components/requestLogs/LoggedCallsTable.tsx b/app/src/components/requestLogs/LoggedCallsTable.tsx index 55692e9..66b8355 100644 --- a/app/src/components/requestLogs/LoggedCallsTable.tsx +++ b/app/src/components/requestLogs/LoggedCallsTable.tsx @@ -12,7 +12,7 @@ export default function LoggedCallsTable() { - {loggedCalls?.calls.map((loggedCall) => { + {loggedCalls?.calls?.map((loggedCall) => { return ( s.selectedLogs.selectedLogIds); + + const [filtersShown, setFiltersShown] = useState(true); + return ( @@ -17,6 +23,13 @@ export default function LoggedCalls() { + { + setFiltersShown(!filtersShown); + }} + label={filtersShown ? "Hide Filters" : "Show Filters"} + icon={FiFilter} + /> { console.log("experimenting with these ids", selectedLogIds); @@ -26,6 +39,7 @@ export default function LoggedCalls() { isDisabled={selectedLogIds.size === 0} /> + {filtersShown && } diff --git a/app/src/server/api/routers/loggedCalls.router.ts b/app/src/server/api/routers/loggedCalls.router.ts index f8f0602..f9602a9 100644 --- a/app/src/server/api/routers/loggedCalls.router.ts +++ b/app/src/server/api/routers/loggedCalls.router.ts @@ -1,33 +1,154 @@ import { z } from "zod"; +import { type Expression, type SqlBool } from "kysely"; +import { sql } from "kysely"; + import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; -import { prisma } from "~/server/db"; +import { kysely, prisma } from "~/server/db"; +import { comparators } from "~/state/logFiltersSlice"; import { requireCanViewProject } from "~/utils/accessControl"; +const defaultFilterableFields = ["Request", "Response", "Model"]; + +const comparatorToSql = { + "=": "=", + "!=": "!=", + CONTAINS: "like", +} as const; + export const loggedCallsRouter = createTRPCRouter({ list: protectedProcedure - .input(z.object({ projectId: z.string(), page: z.number(), pageSize: z.number() })) + .input( + z.object({ + projectId: z.string(), + page: z.number(), + pageSize: z.number(), + filters: z.array( + z.object({ + field: z.string(), + comparator: z.enum(comparators), + value: z.string().optional(), + }), + ), + }), + ) .query(async ({ input, ctx }) => { const { projectId, page, pageSize } = input; await requireCanViewProject(projectId, ctx); - const calls = await prisma.loggedCall.findMany({ - where: { projectId }, - orderBy: { requestedAt: "desc" }, - include: { tags: true, modelResponse: true }, - skip: (page - 1) * pageSize, - take: pageSize, + const baseQuery = kysely + .selectFrom("LoggedCall as lc") + .leftJoin("LoggedCallModelResponse as lcmr", "lc.id", "lcmr.originalLoggedCallId") + .where((eb) => { + const wheres: Expression[] = [eb("lc.projectId", "=", projectId)]; + + for (const filter of input.filters) { + if (!filter.value) continue; + if (filter.field === "Request") { + wheres.push(sql.raw(`lcmr."reqPayload"::text like '%${filter.value}%'`)); + } + } + + return eb.and(wheres); + }); + + const rawCalls = await baseQuery + .select((eb) => [ + "lc.id as id", + "lc.requestedAt as requestedAt", + "model", + "cacheHit", + "lc.requestedAt", + "receivedAt", + "reqPayload", + "respPayload", + "model", + "inputTokens", + "outputTokens", + "cost", + "statusCode", + "durationMs", + ]) + .orderBy("lc.requestedAt", "desc") + .limit(pageSize) + .offset((page - 1) * pageSize) + .execute(); + + const calls = rawCalls.map((rawCall) => ({ + id: rawCall.id, + requestedAt: rawCall.requestedAt, + model: rawCall.model, + cacheHit: rawCall.cacheHit, + modelResponse: { + receivedAt: rawCall.receivedAt, + reqPayload: rawCall.reqPayload, + respPayload: rawCall.respPayload, + inputTokens: rawCall.inputTokens, + outputTokens: rawCall.outputTokens, + cost: rawCall.cost, + statusCode: rawCall.statusCode, + durationMs: rawCall.durationMs, + }, + })); + + const matchingLogIds = await baseQuery.select(["lc.id"]).execute(); + + const count = matchingLogIds.length; + + return { calls, count, matchingLogIds: matchingLogIds.map((log) => log.id) }; + + // const whereClauses: Prisma.LoggedCallWhereInput[] = [{ projectId }]; + + // for (const filter of input.filters) { + // if (!filter.value) continue; + // if (filter.field === "Request") { + // console.log("filter.value is", filter.value); + // whereClauses.push({ + // modelResponse: { + // is: { + // reqPayload: { + // string_contains: filter.value, + // }, + // }, + // }, + // }); + // } + // } + + // const calls = await prisma.loggedCall.findMany({ + // where: { AND: whereClauses }, + // orderBy: { requestedAt: "desc" }, + // include: { tags: true, modelResponse: true }, + // skip: (page - 1) * pageSize, + // take: pageSize, + // }); + + // const matchingLogs = await prisma.loggedCall.findMany({ + // where: { AND: whereClauses }, + // select: { id: true }, + // }); + + // const count = await prisma.loggedCall.count({ + // where: { AND: whereClauses }, + // }); + + // return { calls, count, matchingLogIds: matchingLogs.map((log) => log.id) }; + }), + getFilterableFields: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input, ctx }) => { + await requireCanViewProject(input.projectId, ctx); + + const tags = await prisma.loggedCallTag.findMany({ + distinct: ["name"], + where: { + projectId: input.projectId, + }, + select: { + name: true, + }, }); - const matchingLogs = await prisma.loggedCall.findMany({ - where: { projectId }, - select: { id: true }, - }); - - const count = await prisma.loggedCall.count({ - where: { projectId }, - }); - - return { calls, count, matchingLogIds: matchingLogs.map((log) => log.id) }; + return [...defaultFilterableFields, ...tags.map((tag) => tag.name)]; }), }); diff --git a/app/src/state/logFiltersSlice.ts b/app/src/state/logFiltersSlice.ts new file mode 100644 index 0000000..4c4a937 --- /dev/null +++ b/app/src/state/logFiltersSlice.ts @@ -0,0 +1,39 @@ +import { type SliceCreator } from "./store"; + +export const editorBackground = "#fafafa"; + +export const comparators = ["=", "!=", "CONTAINS"] as const; + +export interface LogFilter { + field: string; + comparator: (typeof comparators)[number]; + value?: string; +} + +export type LogFiltersSlice = { + filters: LogFilter[]; + addFilter: (filter: LogFilter) => void; + updateFilter: (index: number, filter: LogFilter) => void; + deleteFilter: (index: number) => void; + clearSelectedLogIds: () => void; +}; + +export const createLogFiltersSlice: SliceCreator = (set, get) => ({ + filters: [], + addFilter: (filter: LogFilter) => + set((state) => { + state.logFilters.filters.push(filter); + }), + updateFilter: (index: number, filter: LogFilter) => + set((state) => { + state.logFilters.filters[index] = filter; + }), + deleteFilter: (index: number) => + set((state) => { + state.logFilters.filters.splice(index, 1); + }), + clearSelectedLogIds: () => + set((state) => { + state.logFilters.filters = []; + }), +}); diff --git a/app/src/state/store.ts b/app/src/state/store.ts index 5f115c1..d1d3f52 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -10,6 +10,7 @@ import { import { type APIClient } from "~/utils/api"; import { persistOptions, type stateToPersist } from "./persist"; import { type SelectedLogsSlice, createSelectedLogsSlice } from "./selectedLogsSlice"; +import { type LogFiltersSlice, createLogFiltersSlice } from "./logFiltersSlice"; enableMapSet(); @@ -23,6 +24,7 @@ export type State = { selectedProjectId: string | null; setSelectedProjectId: (id: string) => void; selectedLogs: SelectedLogsSlice; + logFilters: LogFiltersSlice; }; export type SliceCreator = StateCreator; @@ -58,6 +60,7 @@ const useBaseStore = create< state.selectedProjectId = id; }), selectedLogs: createSelectedLogsSlice(set, get, ...rest), + logFilters: createLogFiltersSlice(set, get, ...rest), })), persistOptions, ), diff --git a/app/src/utils/hooks.ts b/app/src/utils/hooks.ts index b975d1d..26f22b3 100644 --- a/app/src/utils/hooks.ts +++ b/app/src/utils/hooks.ts @@ -179,9 +179,18 @@ export const useScenarioVars = () => { export const useLoggedCalls = () => { const selectedProjectId = useAppStore((state) => state.selectedProjectId); const { page, pageSize } = usePageParams(); + const filters = useAppStore((state) => state.logFilters.filters); return api.loggedCalls.list.useQuery( - { projectId: selectedProjectId ?? "", page, pageSize }, + { projectId: selectedProjectId ?? "", page, pageSize, filters }, + { enabled: !!selectedProjectId }, + ); +}; + +export const useFilterableFields = () => { + const selectedProjectId = useAppStore((state) => state.selectedProjectId); + return api.loggedCalls.getFilterableFields.useQuery( + { projectId: selectedProjectId ?? "" }, { enabled: !!selectedProjectId }, ); };