Allow filter by request contains
This commit is contained in:
@@ -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;
|
||||||
56
app/src/components/requestLogs/LogFilters/LogFilter.tsx
Normal file
56
app/src/components/requestLogs/LogFilters/LogFilter.tsx
Normal 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;
|
||||||
20
app/src/components/requestLogs/LogFilters/LogFilters.tsx
Normal file
20
app/src/components/requestLogs/LogFilters/LogFilters.tsx
Normal 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;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) };
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
39
app/src/state/logFiltersSlice.ts
Normal file
39
app/src/state/logFiltersSlice.ts
Normal 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 = [];
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user