mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(ui): make the audit log detail drawer wider and resizable (#42808)
* feat(ui): make the audit log drawer wider and resizable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): scope drawer width classes to the sheet side variant The base SheetContent variant data-[side=right]:sm:max-w-sm beats a plain sm:max-w-none: the compound data+sm variant sorts later in the Tailwind v4 output and twMerge does not treat them as conflicting, so the sheet stays capped at max-w-sm. That is also why the old w-[60%] sm:max-w-none on main rendered at 384px. Expressing every width class under the same data-[side=right] variant chain lets twMerge dedupe and makes CSS order deterministic. Also removes the drag listeners on unmount mid-drag. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keyboard resizing and re-grab guard for the resizable drawer Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): query the sheet by dialog role instead of document.querySelector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep drawer resize controls pinned and honor the 720px floor Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): announce the rendered drawer width when the 720px floor overrides the stored percent Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: mrinal <mrinal@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
a76f23ac4f
commit
67d7ac58cd
4 changed files with 327 additions and 3 deletions
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Sheet, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ResizableSheetContent } from "./ResizableSheetContent";
|
||||
|
||||
function sheetContent(): HTMLElement {
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
describe("ResizableSheetContent", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
it("resizes by dragging the handle, clamps to the minimum, and persists on release", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<ResizableSheetContent storageKey="k">
|
||||
<SheetTitle>t</SheetTitle>
|
||||
</ResizableSheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("separator"));
|
||||
fireEvent.pointerMove(window, { clientX: window.innerWidth * 0.5 });
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("50%");
|
||||
|
||||
fireEvent.pointerMove(window, { clientX: window.innerWidth * 0.9 });
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("40%");
|
||||
|
||||
fireEvent.pointerUp(window);
|
||||
expect(localStorage.getItem("k")).toBe("40");
|
||||
});
|
||||
|
||||
it("resizes from the keyboard and exposes the width via aria-valuenow", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<ResizableSheetContent storageKey="k">
|
||||
<SheetTitle>t</SheetTitle>
|
||||
</ResizableSheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
|
||||
const sep = screen.getByRole("separator");
|
||||
sep.focus();
|
||||
|
||||
fireEvent.keyDown(sep, { key: "ArrowLeft" });
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("80%");
|
||||
expect(localStorage.getItem("k")).toBe("80");
|
||||
|
||||
fireEvent.keyDown(sep, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(sep, { key: "ArrowRight" });
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("70%");
|
||||
|
||||
fireEvent.keyDown(sep, { key: "End" });
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("40%");
|
||||
expect(sep).toHaveAttribute("aria-valuenow", "40");
|
||||
});
|
||||
|
||||
it("clamps the End key to the 720px floor when it exceeds the percentage minimum", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1000, configurable: true, writable: true });
|
||||
render(
|
||||
<Sheet open>
|
||||
<ResizableSheetContent storageKey="k">
|
||||
<SheetTitle>t</SheetTitle>
|
||||
</ResizableSheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
|
||||
const sep = screen.getByRole("separator");
|
||||
fireEvent.keyDown(sep, { key: "End" });
|
||||
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("72%");
|
||||
expect(sep).toHaveAttribute("aria-valuenow", "72");
|
||||
});
|
||||
|
||||
it("announces the rendered width when the 720px floor overrides the stored percentage", () => {
|
||||
localStorage.setItem("k", "40");
|
||||
Object.defineProperty(window, "innerWidth", { value: 1000, configurable: true, writable: true });
|
||||
render(
|
||||
<Sheet open>
|
||||
<ResizableSheetContent storageKey="k">
|
||||
<SheetTitle>t</SheetTitle>
|
||||
</ResizableSheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
|
||||
const sep = screen.getByRole("separator");
|
||||
expect(sep).toHaveAttribute("aria-valuenow", "72");
|
||||
expect(sep).toHaveAttribute("aria-valuemin", "72");
|
||||
|
||||
Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true });
|
||||
fireEvent(window, new Event("resize"));
|
||||
expect(sep).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(sep).toHaveAttribute("aria-valuemin", "40");
|
||||
|
||||
Object.defineProperty(window, "innerWidth", { value: 800, configurable: true, writable: true });
|
||||
fireEvent(window, new Event("resize"));
|
||||
expect(sep).toHaveAttribute("aria-valuenow", "90");
|
||||
expect(localStorage.getItem("k")).toBe("40");
|
||||
});
|
||||
});
|
||||
160
ui/litellm-dashboard/src/components/ui/ResizableSheetContent.tsx
Normal file
160
ui/litellm-dashboard/src/components/ui/ResizableSheetContent.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetContent } from "@/components/ui/sheet";
|
||||
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
|
||||
type ResizableSheetContentProps = Omit<React.ComponentProps<typeof SheetContent>, "side" | "style"> & {
|
||||
storageKey: string;
|
||||
defaultWidthPercent?: number;
|
||||
minWidthPercent?: number;
|
||||
};
|
||||
|
||||
const MIN_WIDTH_PX = 720;
|
||||
|
||||
function clampWidth(value: number, min: number) {
|
||||
return Math.min(100, Math.max(min, value));
|
||||
}
|
||||
|
||||
function effectiveMinPercent(minWidthPercent: number, viewportWidth: number) {
|
||||
if (!viewportWidth) return minWidthPercent;
|
||||
return Math.min(100, Math.max(minWidthPercent, (MIN_WIDTH_PX / viewportWidth) * 100));
|
||||
}
|
||||
|
||||
function readViewportWidth() {
|
||||
return typeof window === "undefined" ? 0 : window.innerWidth;
|
||||
}
|
||||
|
||||
function readStoredWidth(storageKey: string, defaultWidthPercent: number, minWidthPercent: number) {
|
||||
const stored = Number(getLocalStorageItem(storageKey));
|
||||
if (!Number.isFinite(stored)) return defaultWidthPercent;
|
||||
if (stored < minWidthPercent || stored > 100) return defaultWidthPercent;
|
||||
return stored;
|
||||
}
|
||||
|
||||
function ResizableSheetContent({
|
||||
className,
|
||||
children,
|
||||
storageKey,
|
||||
defaultWidthPercent = 75,
|
||||
minWidthPercent = 40,
|
||||
...props
|
||||
}: ResizableSheetContentProps) {
|
||||
const [width, setWidth] = React.useState(() => readStoredWidth(storageKey, defaultWidthPercent, minWidthPercent));
|
||||
const widthRef = React.useRef(width);
|
||||
const lastNonFullWidthRef = React.useRef(defaultWidthPercent);
|
||||
const cleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const [viewportWidth, setViewportWidth] = React.useState(readViewportWidth);
|
||||
const isFull = width >= 100;
|
||||
const minPercent = effectiveMinPercent(minWidthPercent, viewportWidth);
|
||||
const renderedWidth = clampWidth(width, minPercent);
|
||||
|
||||
React.useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onResize = () => setViewportWidth(window.innerWidth);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const applyWidth = (next: number, persist: boolean) => {
|
||||
const clamped = clampWidth(next, effectiveMinPercent(minWidthPercent, window.innerWidth));
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
if (persist) setLocalStorageItem(storageKey, String(clamped));
|
||||
};
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
cleanupRef.current?.();
|
||||
const onMove = (e: PointerEvent) => {
|
||||
applyWidth(((window.innerWidth - e.clientX) / window.innerWidth) * 100, false);
|
||||
};
|
||||
const onUp = () => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
if (widthRef.current < 100) setLocalStorageItem(storageKey, String(widthRef.current));
|
||||
};
|
||||
cleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (isFull) {
|
||||
applyWidth(lastNonFullWidthRef.current, true);
|
||||
return;
|
||||
}
|
||||
lastNonFullWidthRef.current = width;
|
||||
applyWidth(100, false);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
applyWidth(widthRef.current + 5, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
applyWidth(widthRef.current - 5, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
applyWidth(100, false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
applyWidth(effectiveMinPercent(minWidthPercent, window.innerWidth), true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn(
|
||||
"data-[side=right]:w-full data-[side=right]:sm:w-(--sheet-width) data-[side=right]:sm:min-w-[min(720px,100%)] data-[side=right]:sm:max-w-none",
|
||||
className,
|
||||
)}
|
||||
style={{ "--sheet-width": `${width}%` } as React.CSSProperties}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize drawer"
|
||||
data-slot="sheet-resize-handle"
|
||||
className="absolute inset-y-0 left-0 hidden w-1.5 cursor-col-resize touch-none select-none hover:bg-border focus-visible:bg-border focus-visible:outline-none sm:block"
|
||||
tabIndex={0}
|
||||
aria-valuenow={Math.round(renderedWidth)}
|
||||
aria-valuemin={Math.round(minPercent)}
|
||||
aria-valuemax={100}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-4 right-14 hidden sm:inline-flex"
|
||||
aria-label={isFull ? "Collapse drawer" : "Expand drawer"}
|
||||
onClick={toggle}
|
||||
>
|
||||
{isFull ? <Minimize2Icon /> : <Maximize2Icon />}
|
||||
</Button>
|
||||
</SheetContent>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableSheetContent };
|
||||
|
|
@ -131,4 +131,64 @@ describe("AuditLogDrawer", () => {
|
|||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(JSON.stringify({ max_budget: 10 }, null, 2)));
|
||||
});
|
||||
|
||||
describe("width", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, "innerWidth", { value: 2000, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
function sheetContent(): HTMLElement {
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
it("defaults to 75% width", () => {
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%");
|
||||
});
|
||||
|
||||
it("expands to full width and collapses back to the previous width", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /expand drawer/i }));
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("100%");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /collapse drawer/i }));
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%");
|
||||
expect(screen.getByRole("button", { name: /expand drawer/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores a stored width", () => {
|
||||
localStorage.setItem("litellm:auditLogDrawerWidth", "58");
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("58%");
|
||||
});
|
||||
|
||||
it("keeps the saved width while expanded and restores it on collapse", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem("litellm:auditLogDrawerWidth", "58");
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /expand drawer/i }));
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("100%");
|
||||
expect(localStorage.getItem("litellm:auditLogDrawerWidth")).toBe("58");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /collapse drawer/i }));
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("58%");
|
||||
expect(localStorage.getItem("litellm:auditLogDrawerWidth")).toBe("58");
|
||||
});
|
||||
|
||||
it("falls back to the default width for a non-numeric stored value", () => {
|
||||
localStorage.setItem("litellm:auditLogDrawerWidth", "abc");
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%");
|
||||
});
|
||||
|
||||
it("falls back to the default width for an out-of-range stored value", () => {
|
||||
localStorage.setItem("litellm:auditLogDrawerWidth", "10");
|
||||
render(<AuditLogDrawer {...defaultProps} />);
|
||||
expect(sheetContent().style.getPropertyValue("--sheet-width")).toBe("75%");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag";
|
|||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Sheet, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ResizableSheetContent } from "@/components/ui/ResizableSheetContent";
|
||||
|
||||
interface AuditLogDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -176,7 +177,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
|||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<SheetContent side="right" className="w-[60%] gap-0 overflow-y-auto p-0 sm:max-w-none">
|
||||
<ResizableSheetContent storageKey="litellm:auditLogDrawerWidth" className="gap-0 p-0">
|
||||
<SheetTitle className="sr-only">Audit log details</SheetTitle>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-6 py-4">
|
||||
|
|
@ -217,7 +218,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
|||
|
||||
<DiffSection log={log} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</ResizableSheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue