Allow filter by request contains

This commit is contained in:
David Corbitt
2023-08-14 20:01:17 -07:00
parent 3d3ddbe7a9
commit a5fe35912e
9 changed files with 312 additions and 20 deletions

View File

@@ -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 (
<HStack
as={Button}
variant="ghost"
fontWeight="normal"
onClick={() =>
addFilter({ field: filterableFields[0] as string, comparator: comparators[0] })
}
>
<Icon as={BsPlus} />
<Text>Add Filter</Text>
</HStack>
);
};
export default AddFilterButton;

View File

@@ -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 (
<HStack>
<Select
value={field}
onChange={(e) => updateFilter(index, { ...filter, field: e.target.value })}
>
{filterableFields.data?.map((field) => (
<option key={field} value={field}>
{field}
</option>
))}
</Select>
<Select
value={comparator}
onChange={(e) =>
updateFilter(index, {
...filter,
comparator: e.target.value as (typeof comparators)[number],
})
}
>
{comparators.map((comparator) => (
<option key={comparator} value={comparator}>
{comparator}
</option>
))}
</Select>
<Input
value={value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
/>
<IconButton
aria-label="Delete Filter"
icon={<BsTrash />}
onClick={() => deleteFilter(index)}
/>
</HStack>
);
};
export default LogFilter;

View File

@@ -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 (
<VStack bgColor="white" borderRadius={8} borderWidth={1} w="full" alignItems="flex-start" p={4}>
<Text>Filters</Text>
{filters.map((filter, index) => (
<LogFilter key={index} filter={filter} index={index} />
))}
<AddFilterButton />
</VStack>
);
};
export default LogFilters;

View File

@@ -12,7 +12,7 @@ export default function LoggedCallsTable() {
<Table> <Table>
<TableHeader showCheckbox /> <TableHeader showCheckbox />
<Tbody> <Tbody>
{loggedCalls?.calls.map((loggedCall) => { {loggedCalls?.calls?.map((loggedCall) => {
return ( return (
<TableRow <TableRow
key={loggedCall.id} key={loggedCall.id}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Text, VStack, Divider, HStack } from "@chakra-ui/react"; import { Text, VStack, Divider, HStack } from "@chakra-ui/react";
import AppShell from "~/components/nav/AppShell"; import AppShell from "~/components/nav/AppShell";
@@ -6,9 +7,14 @@ import LoggedCallsPaginator from "~/components/requestLogs/LoggedCallsPaginator"
import ActionButton from "~/components/requestLogs/ActionButton"; import ActionButton from "~/components/requestLogs/ActionButton";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
import { RiFlaskLine } from "react-icons/ri"; import { RiFlaskLine } from "react-icons/ri";
import { FiFilter } from "react-icons/fi";
import LogFilters from "~/components/requestLogs/LogFilters/LogFilters";
export default function LoggedCalls() { export default function LoggedCalls() {
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds); const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
const [filtersShown, setFiltersShown] = useState(true);
return ( return (
<AppShell title="Request Logs" requireAuth> <AppShell title="Request Logs" requireAuth>
<VStack px={8} py={8} alignItems="flex-start" spacing={4} w="full"> <VStack px={8} py={8} alignItems="flex-start" spacing={4} w="full">
@@ -17,6 +23,13 @@ export default function LoggedCalls() {
</Text> </Text>
<Divider /> <Divider />
<HStack w="full" justifyContent="flex-end"> <HStack w="full" justifyContent="flex-end">
<ActionButton
onClick={() => {
setFiltersShown(!filtersShown);
}}
label={filtersShown ? "Hide Filters" : "Show Filters"}
icon={FiFilter}
/>
<ActionButton <ActionButton
onClick={() => { onClick={() => {
console.log("experimenting with these ids", selectedLogIds); console.log("experimenting with these ids", selectedLogIds);
@@ -26,6 +39,7 @@ export default function LoggedCalls() {
isDisabled={selectedLogIds.size === 0} isDisabled={selectedLogIds.size === 0}
/> />
</HStack> </HStack>
{filtersShown && <LogFilters />}
<LoggedCallTable /> <LoggedCallTable />
<LoggedCallsPaginator /> <LoggedCallsPaginator />
</VStack> </VStack>

View File

@@ -1,33 +1,154 @@
import { z } from "zod"; import { z } from "zod";
import { type Expression, type SqlBool } from "kysely";
import { sql } from "kysely";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; 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"; import { requireCanViewProject } from "~/utils/accessControl";
const defaultFilterableFields = ["Request", "Response", "Model"];
const comparatorToSql = {
"=": "=",
"!=": "!=",
CONTAINS: "like",
} as const;
export const loggedCallsRouter = createTRPCRouter({ export const loggedCallsRouter = createTRPCRouter({
list: protectedProcedure 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 }) => { .query(async ({ input, ctx }) => {
const { projectId, page, pageSize } = input; const { projectId, page, pageSize } = input;
await requireCanViewProject(projectId, ctx); await requireCanViewProject(projectId, ctx);
const calls = await prisma.loggedCall.findMany({ const baseQuery = kysely
where: { projectId }, .selectFrom("LoggedCall as lc")
orderBy: { requestedAt: "desc" }, .leftJoin("LoggedCallModelResponse as lcmr", "lc.id", "lcmr.originalLoggedCallId")
include: { tags: true, modelResponse: true }, .where((eb) => {
skip: (page - 1) * pageSize, const wheres: Expression<SqlBool>[] = [eb("lc.projectId", "=", projectId)];
take: pageSize,
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({ return [...defaultFilterableFields, ...tags.map((tag) => tag.name)];
where: { projectId },
select: { id: true },
});
const count = await prisma.loggedCall.count({
where: { projectId },
});
return { calls, count, matchingLogIds: matchingLogs.map((log) => log.id) };
}), }),
}); });

View File

@@ -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<LogFiltersSlice> = (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 = [];
}),
});

View File

@@ -10,6 +10,7 @@ import {
import { type APIClient } from "~/utils/api"; import { type APIClient } from "~/utils/api";
import { persistOptions, type stateToPersist } from "./persist"; import { persistOptions, type stateToPersist } from "./persist";
import { type SelectedLogsSlice, createSelectedLogsSlice } from "./selectedLogsSlice"; import { type SelectedLogsSlice, createSelectedLogsSlice } from "./selectedLogsSlice";
import { type LogFiltersSlice, createLogFiltersSlice } from "./logFiltersSlice";
enableMapSet(); enableMapSet();
@@ -23,6 +24,7 @@ export type State = {
selectedProjectId: string | null; selectedProjectId: string | null;
setSelectedProjectId: (id: string) => void; setSelectedProjectId: (id: string) => void;
selectedLogs: SelectedLogsSlice; selectedLogs: SelectedLogsSlice;
logFilters: LogFiltersSlice;
}; };
export type SliceCreator<T> = StateCreator<State, [["zustand/immer", never]], [], T>; export type SliceCreator<T> = StateCreator<State, [["zustand/immer", never]], [], T>;
@@ -58,6 +60,7 @@ const useBaseStore = create<
state.selectedProjectId = id; state.selectedProjectId = id;
}), }),
selectedLogs: createSelectedLogsSlice(set, get, ...rest), selectedLogs: createSelectedLogsSlice(set, get, ...rest),
logFilters: createLogFiltersSlice(set, get, ...rest),
})), })),
persistOptions, persistOptions,
), ),

View File

@@ -179,9 +179,18 @@ export const useScenarioVars = () => {
export const useLoggedCalls = () => { export const useLoggedCalls = () => {
const selectedProjectId = useAppStore((state) => state.selectedProjectId); const selectedProjectId = useAppStore((state) => state.selectedProjectId);
const { page, pageSize } = usePageParams(); const { page, pageSize } = usePageParams();
const filters = useAppStore((state) => state.logFilters.filters);
return api.loggedCalls.list.useQuery( 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 }, { enabled: !!selectedProjectId },
); );
}; };