diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05bab..714a3fd4cab7 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1109,21 +1109,11 @@ "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": { "react/display-name": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx new file mode 100644 index 000000000000..df65d5b534f9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { MemoryRow } from "@/components/networking"; + +import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; + +const makeMemory = (overrides: Partial = {}): MemoryRow => ({ + memory_id: "mem-1", + key: "user:profile", + value: "The user prefers concise answers.", + metadata: null, + user_id: "user-42", + team_id: "team-7", + created_at: "2024-05-01T12:00:00Z", + updated_at: "2024-05-02T12:00:00Z", + created_by: "alice", + updated_by: "bob", + ...overrides, +}); + +describe("MemoryDetailDrawer", () => { + it("renders nothing until a row is selected", () => { + render(); + + expect(screen.queryByText("Memory ID")).not.toBeInTheDocument(); + expect(screen.queryByText("Value")).not.toBeInTheDocument(); + }); + + it("shows the selected row's key, identifiers and value", () => { + render(); + + expect(screen.getByText("user:profile")).toBeInTheDocument(); + expect(screen.getByText("Memory ID")).toBeInTheDocument(); + expect(screen.getByText("mem-1")).toBeInTheDocument(); + expect(screen.getByText("User ID")).toBeInTheDocument(); + expect(screen.getByText("user-42")).toBeInTheDocument(); + expect(screen.getByText("Team ID")).toBeInTheDocument(); + expect(screen.getByText("team-7")).toBeInTheDocument(); + expect(screen.getByText("Value")).toBeInTheDocument(); + expect(screen.getByText("The user prefers concise answers.")).toBeInTheDocument(); + }); + + it("falls back to a dash for a memory with no owning user or team", () => { + render(); + + expect(screen.getAllByText("-")).toHaveLength(2); + expect(screen.queryByText("user-42")).not.toBeInTheDocument(); + }); + + it("omits the metadata block when the row carries no metadata", () => { + render(); + + expect(screen.queryByText("Metadata")).not.toBeInTheDocument(); + }); + + it("pretty-prints metadata as JSON when present", () => { + render(); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument(); + }); + + it("attributes the created and updated timestamps to their actors", () => { + render(); + + expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument(); + expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument(); + }); + + it("renders an em dash for a timestamp the backend did not send", () => { + render(); + + expect(screen.getByText("Created —")).toBeInTheDocument(); + }); + + it("closes through the close control", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 970e088ec00d..5c2fac2d5a97 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -1,17 +1,19 @@ "use client"; -import { Drawer, Space, Typography } from "antd"; import React from "react"; import { MemoryRow } from "@/components/networking"; - -const { Text, Paragraph } = Typography; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; interface MemoryDetailDrawerProps { row: MemoryRow | null; onClose: () => void; } +const CODE_CLASS = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground"; +const BLOCK_CLASS = "mt-1 rounded-md bg-muted p-3 font-mono whitespace-pre-wrap text-foreground"; +const LABEL_CLASS = "text-sm font-semibold text-foreground"; + function formatTimestamp(ts?: string): string { if (!ts) return "—"; try { @@ -24,90 +26,61 @@ function formatTimestamp(ts?: string): string { export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { return ( - - {row.key} - - ) : ( - "Memory" - ) - } - width={720} - destroyOnClose + onOpenChange={(open) => { + if (!open) onClose(); + }} > - {row && ( - - -
- - Memory ID - - - {row.memory_id} - + + + {row ? {row.key} : "Memory"} + + {row && ( +
+
+
+ Memory ID + {row.memory_id} +
+
+ User ID + + {row.user_id ?? "-"} + +
+
+ Team ID + + {row.team_id ?? "-"} + +
- - User ID - - {row.user_id ?? "-"} + Value +

{row.value}

-
- - Team ID - - {row.team_id ?? "-"} + {row.metadata !== undefined && row.metadata !== null && ( +
+ Metadata +

{JSON.stringify(row.metadata, null, 2)}

+
+ )} +
+ + Created {formatTimestamp(row.created_at)} + {row.created_by ? ` by ${row.created_by}` : ""} + + + + Updated {formatTimestamp(row.updated_at)} + {row.updated_by ? ` by ${row.updated_by}` : ""} +
- -
- Value - - {row.value} -
- {row.metadata !== undefined && row.metadata !== null && ( -
- Metadata - - {JSON.stringify(row.metadata, null, 2)} - -
- )} - ·} wrap size="small" style={{ color: "rgba(0,0,0,0.45)" }}> - - Created {formatTimestamp(row.created_at)} - {row.created_by ? ` by ${row.created_by}` : ""} - - - Updated {formatTimestamp(row.updated_at)} - {row.updated_by ? ` by ${row.updated_by}` : ""} - - - - )} - + )} + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx index f415c99225a9..9ccef5357b90 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, it, vi } from "vitest"; @@ -12,6 +13,7 @@ interface CapturedTableProps { rowCount: number; data: MemoryRow[]; hasActiveSearch: boolean; + onViewClick: (row: MemoryRow) => void; } const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null })); @@ -42,4 +44,47 @@ describe("MemoryView", () => { expect(captured.current?.rowCount).toBe(0); expect(captured.current?.hasActiveSearch).toBe(false); }); + + it("heads the page with the Memory title and the /v1/memory scope note", () => { + renderView(null); + + expect(screen.getByRole("heading", { name: "Memory" })).toBeInTheDocument(); + expect(screen.getByText("/v1/memory")).toBeInTheDocument(); + expect(screen.getByText(/Scoped to memories visible to your user \/ team \(admins see all\)/)).toBeInTheDocument(); + }); + + it("opens the create modal from the New memory button", async () => { + const user = userEvent.setup(); + renderView(null); + + expect(screen.queryByText("Create memory")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /new memory/i })); + + expect(await screen.findByText("Create memory")).toBeInTheDocument(); + }); + + it("opens the detail drawer for the row the table hands back, and closes it again", async () => { + const user = userEvent.setup(); + renderView(null); + + expect(screen.queryByText("Memory ID")).not.toBeInTheDocument(); + + const row: MemoryRow = { + memory_id: "mem-drawer", + key: "user:profile", + value: "remembered", + metadata: null, + user_id: null, + team_id: null, + }; + act(() => captured.current?.onViewClick(row)); + + expect(await screen.findByText("Memory ID")).toBeInTheDocument(); + expect(screen.getByText("mem-drawer")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByText("mem-drawer")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx index fcb15978f477..a91110cf7803 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx @@ -3,20 +3,19 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; -import { PlusOutlined } from "@ant-design/icons"; -import { Button, Space, Typography, message } from "antd"; +import { Plus } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import MessageManager from "@/components/molecules/message_manager"; +import { Button } from "@/components/ui/button"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; import { MemoryEditModal } from "./MemoryEditModal"; import { MemoryTable } from "./MemoryTable"; -const { Text, Paragraph, Title } = Typography; - interface MemoryViewProps { accessToken: string | null; userID: string | null; @@ -62,7 +61,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { // All three write endpoints share the same success/error plumbing: // - on success: invalidate the list query so every cached page // refetches from scratch (pagination + filter-aware). - // - on error: surface the message via antd `message.error`. + // - on error: surface the message via `MessageManager.error`. const invalidateList = useCallback( () => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }), @@ -75,11 +74,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return createMemory(accessToken, args); }, onSuccess: (row) => { - message.success(`Created ${row.key}`); + MessageManager.success(`Created ${row.key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Save failed: ${err.message}`); + MessageManager.error(`Save failed: ${err.message}`); }, }); @@ -90,11 +89,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return updateMemory(accessToken, key, payload); }, onSuccess: (row) => { - message.success(`Updated ${row.key}`); + MessageManager.success(`Updated ${row.key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Save failed: ${err.message}`); + MessageManager.error(`Save failed: ${err.message}`); }, }); @@ -104,11 +103,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return deleteMemory(accessToken, key).then(() => key); }, onSuccess: (key) => { - message.success(`Deleted ${key}`); + MessageManager.success(`Deleted ${key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Delete failed: ${err.message}`); + MessageManager.error(`Delete failed: ${err.message}`); }, }); @@ -150,7 +149,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { try { metadataPayload = JSON.parse(metadataText); } catch { - message.error("Metadata must be valid JSON (or leave empty)."); + MessageManager.error("Metadata must be valid JSON (or leave empty)."); return false; } } @@ -177,19 +176,21 @@ export const MemoryView: React.FC = ({ accessToken }) => { }; return ( -
- -
+
+
+
- - Memory - - - Inspect what your agents have stored under /v1/memory. Scoped to memories visible to - your user / team (admins see all). - +

Memory

+

+ Inspect what your agents have stored under{" "} + + /v1/memory + + . Scoped to memories visible to your user / team (admins see all). +

-
@@ -209,7 +210,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { onEditClick={handleEdit} onDeleteClick={handleDelete} /> - +
{/* Detail drawer */} setDetailRow(null)} />