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