Add NOT_CONTAINS, fix bugs (#160)
* Fix null case for tag comparisons * Change debounce time to 500ms * Add NOT_CONTAINS * Avoid sql injection * Store filters by id * Fix chained NOT_CONTAINS
This commit is contained in:
@@ -10,7 +10,14 @@ const AddFilterButton = () => {
|
|||||||
<HStack
|
<HStack
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => addFilter({ field: defaultFilterableFields[0], comparator: comparators[0] })}
|
onClick={() =>
|
||||||
|
addFilter({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
field: defaultFilterableFields[0],
|
||||||
|
comparator: comparators[0],
|
||||||
|
value: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
spacing={0}
|
spacing={0}
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,39 +8,34 @@ import { debounce } from "lodash-es";
|
|||||||
import SelectFieldDropdown from "./SelectFieldDropdown";
|
import SelectFieldDropdown from "./SelectFieldDropdown";
|
||||||
import SelectComparatorDropdown from "./SelectComparatorDropdown";
|
import SelectComparatorDropdown from "./SelectComparatorDropdown";
|
||||||
|
|
||||||
const LogFilter = ({ filter, index }: { filter: LogFilter; index: number }) => {
|
const LogFilter = ({ filter }: { filter: LogFilter }) => {
|
||||||
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
||||||
const deleteFilter = useAppStore((s) => s.logFilters.deleteFilter);
|
const deleteFilter = useAppStore((s) => s.logFilters.deleteFilter);
|
||||||
|
|
||||||
const [editedValue, setEditedValue] = useState("");
|
const [editedValue, setEditedValue] = useState(filter.value);
|
||||||
|
|
||||||
const debouncedUpdateFilter = useCallback(
|
const debouncedUpdateFilter = useCallback(
|
||||||
debounce(
|
debounce((filter: LogFilter) => updateFilter(filter), 500, {
|
||||||
(index: number, filter: LogFilter) => {
|
leading: true,
|
||||||
console.log("updating filter!!!");
|
}),
|
||||||
updateFilter(index, filter);
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
{ leading: true },
|
|
||||||
),
|
|
||||||
[updateFilter],
|
[updateFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack>
|
<HStack>
|
||||||
<SelectFieldDropdown filter={filter} index={index} />
|
<SelectFieldDropdown filter={filter} />
|
||||||
<SelectComparatorDropdown filter={filter} index={index} />
|
<SelectComparatorDropdown filter={filter} />
|
||||||
<Input
|
<Input
|
||||||
value={editedValue}
|
value={editedValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEditedValue(e.target.value);
|
setEditedValue(e.target.value);
|
||||||
debouncedUpdateFilter(index, { ...filter, value: e.target.value });
|
debouncedUpdateFilter({ ...filter, value: e.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Delete Filter"
|
aria-label="Delete Filter"
|
||||||
icon={<BsTrash />}
|
icon={<BsTrash />}
|
||||||
onClick={() => deleteFilter(index)}
|
onClick={() => deleteFilter(filter.id)}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const LogFilters = () => {
|
|||||||
<Text fontWeight="bold" color="gray.500">
|
<Text fontWeight="bold" color="gray.500">
|
||||||
Filters
|
Filters
|
||||||
</Text>
|
</Text>
|
||||||
{filters.map((filter, index) => (
|
{filters.map((filter) => (
|
||||||
<LogFilter key={index} filter={filter} index={index} />
|
<LogFilter key={filter.id} filter={filter} />
|
||||||
))}
|
))}
|
||||||
<AddFilterButton />
|
<AddFilterButton />
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { comparators, type LogFilter } from "~/state/logFiltersSlice";
|
|||||||
import { useAppStore } from "~/state/store";
|
import { useAppStore } from "~/state/store";
|
||||||
import InputDropdown from "~/components/InputDropdown";
|
import InputDropdown from "~/components/InputDropdown";
|
||||||
|
|
||||||
const SelectComparatorDropdown = ({ filter, index }: { filter: LogFilter; index: number }) => {
|
const SelectComparatorDropdown = ({ filter }: { filter: LogFilter }) => {
|
||||||
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
||||||
|
|
||||||
const { comparator } = filter;
|
const { comparator } = filter;
|
||||||
@@ -11,7 +11,7 @@ const SelectComparatorDropdown = ({ filter, index }: { filter: LogFilter; index:
|
|||||||
<InputDropdown
|
<InputDropdown
|
||||||
options={comparators}
|
options={comparators}
|
||||||
selectedOption={comparator}
|
selectedOption={comparator}
|
||||||
onSelect={(option) => updateFilter(index, { ...filter, comparator: option })}
|
onSelect={(option) => updateFilter({ ...filter, comparator: option })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useAppStore } from "~/state/store";
|
|||||||
import { useTagNames } from "~/utils/hooks";
|
import { useTagNames } from "~/utils/hooks";
|
||||||
import InputDropdown from "~/components/InputDropdown";
|
import InputDropdown from "~/components/InputDropdown";
|
||||||
|
|
||||||
const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: number }) => {
|
const SelectFieldDropdown = ({ filter }: { filter: LogFilter }) => {
|
||||||
const tagNames = useTagNames().data;
|
const tagNames = useTagNames().data;
|
||||||
|
|
||||||
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
|
||||||
@@ -14,7 +14,7 @@ const SelectFieldDropdown = ({ filter, index }: { filter: LogFilter; index: numb
|
|||||||
<InputDropdown
|
<InputDropdown
|
||||||
options={[...defaultFilterableFields, ...(tagNames || [])]}
|
options={[...defaultFilterableFields, ...(tagNames || [])]}
|
||||||
selectedOption={field}
|
selectedOption={field}
|
||||||
onSelect={(option) => updateFilter(index, { ...filter, field: option })}
|
onSelect={(option) => updateFilter({ ...filter, field: option })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type Expression, type SqlBool, sql } from "kysely";
|
import { type Expression, type SqlBool, sql, type RawBuilder } from "kysely";
|
||||||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
@@ -8,15 +8,22 @@ import { comparators, defaultFilterableFields } from "~/state/logFiltersSlice";
|
|||||||
import { requireCanViewProject } from "~/utils/accessControl";
|
import { requireCanViewProject } from "~/utils/accessControl";
|
||||||
|
|
||||||
// create comparator type based off of comparators
|
// create comparator type based off of comparators
|
||||||
const comparatorToSqlValue = (comparator: (typeof comparators)[number], value: string) => {
|
const comparatorToSqlExpression = (comparator: (typeof comparators)[number], value: string) => {
|
||||||
switch (comparator) {
|
return (reference: RawBuilder<unknown>): Expression<SqlBool> => {
|
||||||
case "=":
|
switch (comparator) {
|
||||||
return `= '${value}'`;
|
case "=":
|
||||||
case "!=":
|
return sql`${reference} = ${value}`;
|
||||||
return `!= '${value}'`;
|
case "!=":
|
||||||
case "CONTAINS":
|
// Handle NULL values
|
||||||
return `like '%${value}%'`;
|
return sql`${reference} IS DISTINCT FROM ${value}`;
|
||||||
}
|
case "CONTAINS":
|
||||||
|
return sql`${reference} LIKE ${"%" + value + "%"}`;
|
||||||
|
case "NOT_CONTAINS":
|
||||||
|
return sql`(${reference} NOT LIKE ${"%" + value + "%"} OR ${reference} IS NULL)`;
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown comparator");
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loggedCallsRouter = createTRPCRouter({
|
export const loggedCallsRouter = createTRPCRouter({
|
||||||
@@ -30,7 +37,7 @@ export const loggedCallsRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
field: z.string(),
|
field: z.string(),
|
||||||
comparator: z.enum(comparators),
|
comparator: z.enum(comparators),
|
||||||
value: z.string().optional(),
|
value: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -48,40 +55,19 @@ export const loggedCallsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const filter of input.filters) {
|
for (const filter of input.filters) {
|
||||||
if (!filter.value) continue;
|
if (!filter.value) continue;
|
||||||
|
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
|
||||||
|
|
||||||
if (filter.field === "Request") {
|
if (filter.field === "Request") {
|
||||||
wheres.push(
|
wheres.push(filterExpression(sql.raw(`lcmr."reqPayload"::text`)));
|
||||||
sql.raw(
|
|
||||||
`lcmr."reqPayload"::text ${comparatorToSqlValue(
|
|
||||||
filter.comparator,
|
|
||||||
filter.value,
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filter.field === "Response") {
|
if (filter.field === "Response") {
|
||||||
wheres.push(
|
wheres.push(filterExpression(sql.raw(`lcmr."respPayload"::text`)));
|
||||||
sql.raw(
|
|
||||||
`lcmr."respPayload"::text ${comparatorToSqlValue(
|
|
||||||
filter.comparator,
|
|
||||||
filter.value,
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filter.field === "Model") {
|
if (filter.field === "Model") {
|
||||||
wheres.push(
|
wheres.push(filterExpression(sql.raw(`lc."model"`)));
|
||||||
sql.raw(`lc."model" ${comparatorToSqlValue(filter.comparator, filter.value)}`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filter.field === "Status Code") {
|
if (filter.field === "Status Code") {
|
||||||
wheres.push(
|
wheres.push(filterExpression(sql.raw(`lcmr."statusCode"::text`)));
|
||||||
sql.raw(
|
|
||||||
`lcmr."statusCode"::text ${comparatorToSqlValue(
|
|
||||||
filter.comparator,
|
|
||||||
filter.value,
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,15 +87,15 @@ export const loggedCallsRouter = createTRPCRouter({
|
|||||||
const filter = tagFilters[i];
|
const filter = tagFilters[i];
|
||||||
if (!filter?.value) continue;
|
if (!filter?.value) continue;
|
||||||
const tableAlias = `lct${i}`;
|
const tableAlias = `lct${i}`;
|
||||||
|
const filterExpression = comparatorToSqlExpression(filter.comparator, filter.value);
|
||||||
|
|
||||||
updatedBaseQuery = updatedBaseQuery
|
updatedBaseQuery = updatedBaseQuery
|
||||||
.leftJoin(`LoggedCallTag as ${tableAlias}`, (join) =>
|
.leftJoin(`LoggedCallTag as ${tableAlias}`, (join) =>
|
||||||
join
|
join
|
||||||
.onRef("lc.id", "=", `${tableAlias}.loggedCallId`)
|
.onRef("lc.id", "=", `${tableAlias}.loggedCallId`)
|
||||||
.on(`${tableAlias}.name`, "=", filter.field),
|
.on(`${tableAlias}.name`, "=", filter.field),
|
||||||
)
|
)
|
||||||
.where(
|
.where(filterExpression(sql.raw(`${tableAlias}.value`))) as unknown as typeof baseQuery;
|
||||||
sql.raw(`${tableAlias}.value ${comparatorToSqlValue(filter.comparator, filter.value)}`),
|
|
||||||
) as unknown as typeof baseQuery;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawCalls = await updatedBaseQuery
|
const rawCalls = await updatedBaseQuery
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { type SliceCreator } from "./store";
|
import { type SliceCreator } from "./store";
|
||||||
|
|
||||||
export const comparators = ["=", "!=", "CONTAINS"] as const;
|
export const comparators = ["=", "!=", "CONTAINS", "NOT_CONTAINS"] as const;
|
||||||
|
|
||||||
export const defaultFilterableFields = ["Request", "Response", "Model", "Status Code"] as const;
|
export const defaultFilterableFields = ["Request", "Response", "Model", "Status Code"] as const;
|
||||||
|
|
||||||
export interface LogFilter {
|
export interface LogFilter {
|
||||||
|
id: string;
|
||||||
field: string;
|
field: string;
|
||||||
comparator: (typeof comparators)[number];
|
comparator: (typeof comparators)[number];
|
||||||
value?: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogFiltersSlice = {
|
export type LogFiltersSlice = {
|
||||||
filters: LogFilter[];
|
filters: LogFilter[];
|
||||||
addFilter: (filter: LogFilter) => void;
|
addFilter: (filter: LogFilter) => void;
|
||||||
updateFilter: (index: number, filter: LogFilter) => void;
|
updateFilter: (filter: LogFilter) => void;
|
||||||
deleteFilter: (index: number) => void;
|
deleteFilter: (id: string) => void;
|
||||||
clearSelectedLogIds: () => void;
|
clearSelectedLogIds: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,12 +25,14 @@ export const createLogFiltersSlice: SliceCreator<LogFiltersSlice> = (set, get) =
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
state.logFilters.filters.push(filter);
|
state.logFilters.filters.push(filter);
|
||||||
}),
|
}),
|
||||||
updateFilter: (index: number, filter: LogFilter) =>
|
updateFilter: (filter: LogFilter) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const index = state.logFilters.filters.findIndex((f) => f.id === filter.id);
|
||||||
state.logFilters.filters[index] = filter;
|
state.logFilters.filters[index] = filter;
|
||||||
}),
|
}),
|
||||||
deleteFilter: (index: number) =>
|
deleteFilter: (id: string) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const index = state.logFilters.filters.findIndex((f) => f.id === id);
|
||||||
state.logFilters.filters.splice(index, 1);
|
state.logFilters.filters.splice(index, 1);
|
||||||
}),
|
}),
|
||||||
clearSelectedLogIds: () =>
|
clearSelectedLogIds: () =>
|
||||||
|
|||||||
Reference in New Issue
Block a user