From 888c04af504bcced4a4294fa70e2f97525d3b4de Mon Sep 17 00:00:00 2001 From: arcticfly <41524992+arcticfly@users.noreply.github.com> Date: Mon, 21 Aug 2023 23:13:29 -0700 Subject: [PATCH] Allow user to toggle visible columns (#182) * Maintain tag casing * Persist column visibility in zustand * Persist only visibleColumns key * merge persisted state * Only show ColumnVisibilityDropdown after rehydration * Record storage rehydrated * Add useIsClientRehydrated hook * Hide ActionButton text on mobile * Condense Paginator on mobile --------- Co-authored-by: Kyle Corbitt --- app/src/components/Paginator.tsx | 19 ++- app/src/components/nav/UserMenu.tsx | 88 ++++++------ .../components/requestLogs/ActionButton.tsx | 2 +- .../requestLogs/ColumnVisiblityDropdown.tsx | 117 ++++++++++++++++ .../requestLogs/LoggedCallsTable.tsx | 4 +- app/src/components/requestLogs/TableRow.tsx | 127 +++++++++++------- app/src/pages/request-logs/index.tsx | 2 + app/src/state/columnVisiblitySlice.ts | 37 +++++ app/src/state/persist.ts | 22 ++- app/src/state/store.ts | 13 +- app/src/utils/hooks.ts | 9 ++ 11 files changed, 327 insertions(+), 113 deletions(-) create mode 100644 app/src/components/requestLogs/ColumnVisiblityDropdown.tsx create mode 100644 app/src/state/columnVisiblitySlice.ts diff --git a/app/src/components/Paginator.tsx b/app/src/components/Paginator.tsx index a9b4076..daa22b2 100644 --- a/app/src/components/Paginator.tsx +++ b/app/src/components/Paginator.tsx @@ -1,15 +1,19 @@ -import { HStack, IconButton, Text, Select, type StackProps, Icon } from "@chakra-ui/react"; +import { + HStack, + IconButton, + Text, + Select, + type StackProps, + Icon, + useBreakpointValue, +} from "@chakra-ui/react"; import React, { useCallback } from "react"; import { FiChevronsLeft, FiChevronsRight, FiChevronLeft, FiChevronRight } from "react-icons/fi"; import { usePageParams } from "~/utils/hooks"; const pageSizeOptions = [10, 25, 50, 100]; -const Paginator = ({ - count, - condense, - ...props -}: { count: number; condense?: boolean } & StackProps) => { +const Paginator = ({ count, ...props }: { count: number; condense?: boolean } & StackProps) => { const { page, pageSize, setPageParams } = usePageParams(); const lastPage = Math.ceil(count / pageSize); @@ -37,6 +41,9 @@ const Paginator = ({ const goToLastPage = () => setPageParams({ page: lastPage }, "replace"); const goToFirstPage = () => setPageParams({ page: 1 }, "replace"); + const isMobile = useBreakpointValue({ base: true, md: false }); + const condense = isMobile || props.condense; + if (count === 0) return null; return ( diff --git a/app/src/components/nav/UserMenu.tsx b/app/src/components/nav/UserMenu.tsx index 6fcf5a4..9aafe24 100644 --- a/app/src/components/nav/UserMenu.tsx +++ b/app/src/components/nav/UserMenu.tsx @@ -23,50 +23,48 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro ); return ( - <> - - - - - {profileImage} - - - {user.user.name} - - - {/* {user.user.email} */} - - - - - - - - - {/* sign out */} - { - signOut().catch(console.error); - }} - px={4} - py={2} - spacing={4} - color="gray.500" - fontSize="sm" - > - - Sign out - - - - - + + + + + {profileImage} + + + {user.user.name} + + + {/* {user.user.email} */} + + + + + + + + + {/* sign out */} + { + signOut().catch(console.error); + }} + px={4} + py={2} + spacing={4} + color="gray.500" + fontSize="sm" + > + + Sign out + + + + ); } diff --git a/app/src/components/requestLogs/ActionButton.tsx b/app/src/components/requestLogs/ActionButton.tsx index 14306b6..f42b599 100644 --- a/app/src/components/requestLogs/ActionButton.tsx +++ b/app/src/components/requestLogs/ActionButton.tsx @@ -21,7 +21,7 @@ const ActionButton = ({ > {icon && } - {label} + {label} ); diff --git a/app/src/components/requestLogs/ColumnVisiblityDropdown.tsx b/app/src/components/requestLogs/ColumnVisiblityDropdown.tsx new file mode 100644 index 0000000..1155ec8 --- /dev/null +++ b/app/src/components/requestLogs/ColumnVisiblityDropdown.tsx @@ -0,0 +1,117 @@ +import { + Icon, + Popover, + PopoverTrigger, + PopoverContent, + VStack, + HStack, + Button, + Text, + useDisclosure, + Box, +} from "@chakra-ui/react"; +import { BiCheck } from "react-icons/bi"; +import { BsToggles } from "react-icons/bs"; +import { useMemo } from "react"; + +import { useIsClientRehydrated, useTagNames } from "~/utils/hooks"; +import { useAppStore } from "~/state/store"; +import { StaticColumnKeys } from "~/state/columnVisiblitySlice"; +import ActionButton from "./ActionButton"; + +const ColumnVisiblityDropdown = () => { + const tagNames = useTagNames().data; + + const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns); + const toggleColumnVisibility = useAppStore((s) => s.columnVisibility.toggleColumnVisibility); + const totalColumns = Object.keys(StaticColumnKeys).length + (tagNames?.length ?? 0); + + const popover = useDisclosure(); + + const columnVisiblityOptions = useMemo(() => { + const options: { label: string; key: string }[] = [ + { + label: "Sent At", + key: StaticColumnKeys.SENT_AT, + }, + { + label: "Model", + key: StaticColumnKeys.MODEL, + }, + { + label: "Duration", + key: StaticColumnKeys.DURATION, + }, + { + label: "Input Tokens", + key: StaticColumnKeys.INPUT_TOKENS, + }, + { + label: "Output Tokens", + key: StaticColumnKeys.OUTPUT_TOKENS, + }, + { + label: "Status Code", + key: StaticColumnKeys.STATUS_CODE, + }, + ]; + for (const tagName of tagNames ?? []) { + options.push({ + label: tagName, + key: tagName, + }); + } + return options; + }, [tagNames]); + + const isClientRehydrated = useIsClientRehydrated(); + if (!isClientRehydrated) return null; + + return ( + + + + + + + + + {columnVisiblityOptions?.map((option, index) => ( + toggleColumnVisibility(option.key)} + w="full" + minH={10} + variant="ghost" + justifyContent="space-between" + fontWeight="semibold" + borderRadius={0} + colorScheme="blue" + color="black" + fontSize="sm" + borderBottomWidth={1} + > + {option.label} + + {visibleColumns.has(option.key) && ( + + )} + + + ))} + + + + ); +}; + +export default ColumnVisiblityDropdown; diff --git a/app/src/components/requestLogs/LoggedCallsTable.tsx b/app/src/components/requestLogs/LoggedCallsTable.tsx index caad5e1..913ee28 100644 --- a/app/src/components/requestLogs/LoggedCallsTable.tsx +++ b/app/src/components/requestLogs/LoggedCallsTable.tsx @@ -10,7 +10,7 @@ export default function LoggedCallsTable() { return ( - + {loggedCalls?.calls?.map((loggedCall) => { return ( @@ -25,7 +25,7 @@ export default function LoggedCallsTable() { setExpandedRow(loggedCall.id); } }} - showCheckbox + isSimple /> ); })} diff --git a/app/src/components/requestLogs/TableRow.tsx b/app/src/components/requestLogs/TableRow.tsx index fecf4fb..a024d48 100644 --- a/app/src/components/requestLogs/TableRow.tsx +++ b/app/src/components/requestLogs/TableRow.tsx @@ -21,14 +21,15 @@ import Link from "next/link"; import { type RouterOutputs } from "~/utils/api"; import { FormattedJson } from "./FormattedJson"; import { useAppStore } from "~/state/store"; -import { useLoggedCalls, useTagNames } from "~/utils/hooks"; +import { useIsClientRehydrated, useLoggedCalls, useTagNames } from "~/utils/hooks"; import { useMemo } from "react"; +import { StaticColumnKeys } from "~/state/columnVisiblitySlice"; dayjs.extend(relativeTime); type LoggedCall = RouterOutputs["loggedCalls"]["list"]["calls"][0]; -export const TableHeader = ({ showCheckbox }: { showCheckbox?: boolean }) => { +export const TableHeader = ({ isSimple }: { isSimple?: boolean }) => { const matchingLogIds = useLoggedCalls().data?.matchingLogIds; const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds); const addAll = useAppStore((s) => s.selectedLogs.addSelectedLogIds); @@ -38,10 +39,14 @@ export const TableHeader = ({ showCheckbox }: { showCheckbox?: boolean }) => { return matchingLogIds.every((id) => selectedLogIds.has(id)); }, [selectedLogIds, matchingLogIds]); const tagNames = useTagNames().data; + const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns); + const isClientRehydrated = useIsClientRehydrated(); + if (!isClientRehydrated) return null; + return ( - {showCheckbox && ( + {isSimple && ( )} - - - {tagNames?.map((tagName) => )} - - - - + {visibleColumns.has(StaticColumnKeys.SENT_AT) && } + {visibleColumns.has(StaticColumnKeys.MODEL) && } + {tagNames + ?.filter((tagName) => visibleColumns.has(tagName)) + .map((tagName) => ( + + ))} + {visibleColumns.has(StaticColumnKeys.DURATION) && } + {visibleColumns.has(StaticColumnKeys.INPUT_TOKENS) && } + {visibleColumns.has(StaticColumnKeys.OUTPUT_TOKENS) && } + {visibleColumns.has(StaticColumnKeys.STATUS_CODE) && } ); @@ -73,12 +84,12 @@ export const TableRow = ({ loggedCall, isExpanded, onToggle, - showCheckbox, + isSimple, }: { loggedCall: LoggedCall; isExpanded: boolean; onToggle: () => void; - showCheckbox?: boolean; + isSimple?: boolean; }) => { const isError = loggedCall.modelResponse?.statusCode !== 200; const requestedAt = dayjs(loggedCall.requestedAt).format("MMMM D h:mm A"); @@ -88,6 +99,10 @@ export const TableRow = ({ const toggleChecked = useAppStore((s) => s.selectedLogs.toggleSelectedLogId); const tagNames = useTagNames().data; + const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns); + + const isClientRehydrated = useIsClientRehydrated(); + if (!isClientRehydrated) return null; return ( <> @@ -100,47 +115,61 @@ export const TableRow = ({ }} fontSize="sm" > - {showCheckbox && ( + {isSimple && ( )} - - - {tagNames?.map((tagName) => )} - - - - + {visibleColumns.has(StaticColumnKeys.SENT_AT) && ( + + )} + {visibleColumns.has(StaticColumnKeys.MODEL) && ( + + )} + {tagNames + ?.filter((tagName) => visibleColumns.has(tagName)) + .map((tagName) => )} + {visibleColumns.has(StaticColumnKeys.DURATION) && ( + + )} + {visibleColumns.has(StaticColumnKeys.INPUT_TOKENS) && ( + + )} + {visibleColumns.has(StaticColumnKeys.OUTPUT_TOKENS) && ( + + )} + {visibleColumns.has(StaticColumnKeys.STATUS_CODE) && ( + + )}
{ Sent AtModel{tagName}DurationInput tokensOutput tokensStatusSent AtModel + {tagName} + DurationInput tokensOutput tokensStatus
toggleChecked(loggedCall.id)} /> - - - {requestedAt} - - - - - - {loggedCall.model} - - - {loggedCall.tags[tagName]} - {loggedCall.cacheHit ? ( - Cached - ) : ( - ((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2) + "s" - )} - {loggedCall.modelResponse?.inputTokens}{loggedCall.modelResponse?.outputTokens} - {loggedCall.modelResponse?.statusCode ?? "No response"} - + + + {requestedAt} + + + + + + {loggedCall.model} + + + {loggedCall.tags[tagName]} + {loggedCall.cacheHit ? ( + Cached + ) : ( + ((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2) + "s" + )} + {loggedCall.modelResponse?.inputTokens}{loggedCall.modelResponse?.outputTokens} + {loggedCall.modelResponse?.statusCode ?? "No response"} +
diff --git a/app/src/pages/request-logs/index.tsx b/app/src/pages/request-logs/index.tsx index 8338ee1..d2e2916 100644 --- a/app/src/pages/request-logs/index.tsx +++ b/app/src/pages/request-logs/index.tsx @@ -9,6 +9,7 @@ import { useAppStore } from "~/state/store"; import { RiFlaskLine } from "react-icons/ri"; import { FiFilter } from "react-icons/fi"; import LogFilters from "~/components/requestLogs/LogFilters/LogFilters"; +import ColumnVisiblityDropdown from "~/components/requestLogs/ColumnVisiblityDropdown"; export default function LoggedCalls() { const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds); @@ -23,6 +24,7 @@ export default function LoggedCalls() { + { setFiltersShown(!filtersShown); diff --git a/app/src/state/columnVisiblitySlice.ts b/app/src/state/columnVisiblitySlice.ts new file mode 100644 index 0000000..4bc4b79 --- /dev/null +++ b/app/src/state/columnVisiblitySlice.ts @@ -0,0 +1,37 @@ +import { type SliceCreator } from "./store"; + +export const comparators = ["=", "!=", "CONTAINS", "NOT_CONTAINS"] as const; + +export const defaultFilterableFields = ["Request", "Response", "Model", "Status Code"] as const; + +export enum StaticColumnKeys { + SENT_AT = "sentAt", + MODEL = "model", + DURATION = "duration", + INPUT_TOKENS = "inputTokens", + OUTPUT_TOKENS = "outputTokens", + STATUS_CODE = "statusCode", +} + +export type ColumnVisibilitySlice = { + visibleColumns: Set; + toggleColumnVisibility: (columnKey: string) => void; + showAllColumns: (columnKeys: string[]) => void; +}; + +export const createColumnVisibilitySlice: SliceCreator = (set, get) => ({ + // initialize with all static columns visible + visibleColumns: new Set(Object.values(StaticColumnKeys)), + toggleColumnVisibility: (columnKey: string) => + set((state) => { + if (state.columnVisibility.visibleColumns.has(columnKey)) { + state.columnVisibility.visibleColumns.delete(columnKey); + } else { + state.columnVisibility.visibleColumns.add(columnKey); + } + }), + showAllColumns: (columnKeys: string[]) => + set((state) => { + state.columnVisibility.visibleColumns = new Set(columnKeys); + }), +}); diff --git a/app/src/state/persist.ts b/app/src/state/persist.ts index c7baa10..70aa5eb 100644 --- a/app/src/state/persist.ts +++ b/app/src/state/persist.ts @@ -1,13 +1,27 @@ import { type PersistOptions } from "zustand/middleware/persist"; import { type State } from "./store"; +import SuperJSON from "superjson"; +import { merge, pick } from "lodash-es"; +import { type PartialDeep } from "type-fest"; -export const stateToPersist = { - selectedProjectId: null as string | null, -}; +export type PersistedState = PartialDeep; -export const persistOptions: PersistOptions = { +export const persistOptions: PersistOptions = { name: "persisted-app-store", partialize: (state) => ({ selectedProjectId: state.selectedProjectId, + columnVisibility: pick(state.columnVisibility, ["visibleColumns"]), }), + merge: (saved, state) => merge(state, saved), + storage: { + getItem: (key) => { + const data = localStorage.getItem(key); + return data ? SuperJSON.parse(data) : null; + }, + setItem: (key, value) => localStorage.setItem(key, SuperJSON.stringify(value)), + removeItem: (key) => localStorage.removeItem(key), + }, + onRehydrateStorage: (state) => { + if (state) state.isRehydrated = true; + }, }; diff --git a/app/src/state/store.ts b/app/src/state/store.ts index d1d3f52..d6d7fba 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -8,13 +8,15 @@ import { createVariantEditorSlice, } from "./sharedVariantEditor.slice"; import { type APIClient } from "~/utils/api"; -import { persistOptions, type stateToPersist } from "./persist"; +import { type PersistedState, persistOptions } from "./persist"; import { type SelectedLogsSlice, createSelectedLogsSlice } from "./selectedLogsSlice"; import { type LogFiltersSlice, createLogFiltersSlice } from "./logFiltersSlice"; +import { createColumnVisibilitySlice, type ColumnVisibilitySlice } from "./columnVisiblitySlice"; enableMapSet(); export type State = { + isRehydrated: boolean; drawerOpen: boolean; openDrawer: () => void; closeDrawer: () => void; @@ -25,6 +27,7 @@ export type State = { setSelectedProjectId: (id: string) => void; selectedLogs: SelectedLogsSlice; logFilters: LogFiltersSlice; + columnVisibility: ColumnVisibilitySlice; }; export type SliceCreator = StateCreator; @@ -32,18 +35,15 @@ export type SliceCreator = StateCreator>[0]; export type GetFn = Parameters>[1]; -const useBaseStore = create< - State, - [["zustand/persist", typeof stateToPersist], ["zustand/immer", never]] ->( +const useBaseStore = create( persist( immer((set, get, ...rest) => ({ + isRehydrated: false, api: null, setApi: (api) => set((state) => { state.api = api; }), - drawerOpen: false, openDrawer: () => set((state) => { @@ -61,6 +61,7 @@ const useBaseStore = create< }), selectedLogs: createSelectedLogsSlice(set, get, ...rest), logFilters: createLogFiltersSlice(set, get, ...rest), + columnVisibility: createColumnVisibilitySlice(set, get, ...rest), })), persistOptions, ), diff --git a/app/src/utils/hooks.ts b/app/src/utils/hooks.ts index 23d1777..5e508f7 100644 --- a/app/src/utils/hooks.ts +++ b/app/src/utils/hooks.ts @@ -205,3 +205,12 @@ export const useTagNames = () => { { enabled: !!selectedProjectId }, ); }; + +export const useIsClientRehydrated = () => { + const isRehydrated = useAppStore((state) => state.isRehydrated); + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + return isRehydrated && isMounted; +};