diff --git a/ui/package-lock.json b/ui/package-lock.json index 34ac2f6..e2026cf 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,6 +12,8 @@ "@tanstack/react-query": "^5.80.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "react-resizable-panels": "^3.0.4", "react-router-dom": "^7.6.2", "tailwindcss": "^4.1.8" }, @@ -3460,6 +3462,15 @@ "react": "^19.1.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3470,6 +3481,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable-panels": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.4.tgz", + "integrity": "sha512-8Y4KNgV94XhUvI2LeByyPIjoUJb71M/0hyhtzkHaqpVHs+ZQs8b627HmzyhmVYi3C9YP6R+XD1KmG7hHjEZXFQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-router": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", diff --git a/ui/package.json b/ui/package.json index d803cf8..6a68a0a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,8 @@ "@tanstack/react-query": "^5.80.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "react-resizable-panels": "^3.0.4", "react-router-dom": "^7.6.2", "tailwindcss": "^4.1.8" }, @@ -30,4 +32,4 @@ "typescript-eslint": "^8.30.1", "vite": "^6.3.5" } -} \ No newline at end of file +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f36c56c..206d399 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,16 +4,18 @@ import { APIProvider } from "./contexts/APIProvider"; import LogViewerPage from "./pages/LogViewer"; import ModelPage from "./pages/Models"; import ActivityPage from "./pages/Activity"; +import { RiSunFill, RiMoonFill } from "react-icons/ri"; function App() { - const theme = useTheme(); + const { isNarrow, toggleTheme, isDarkMode } = useTheme(); + return ( -
+
-
+
} /> } /> diff --git a/ui/src/contexts/ThemeProvider.tsx b/ui/src/contexts/ThemeProvider.tsx index ec364aa..b662bd2 100644 --- a/ui/src/contexts/ThemeProvider.tsx +++ b/ui/src/contexts/ThemeProvider.tsx @@ -1,8 +1,11 @@ -import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react"; import { usePersistentState } from "../hooks/usePersistentState"; +type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; type ThemeContextType = { isDarkMode: boolean; + screenWidth: ScreenWidth; + isNarrow: boolean; toggleTheme: () => void; }; @@ -14,14 +17,46 @@ type ThemeProviderProps = { export function ThemeProvider({ children }: ThemeProviderProps) { const [isDarkMode, setIsDarkMode] = usePersistentState("theme", false); + const [screenWidth, setScreenWidth] = useState("md"); // Default to md + + // matches tailwind classes + // https://tailwindcss.com/docs/responsive-design + useEffect(() => { + const checkInnerWidth = () => { + const innerWidth = window.innerWidth; + if (innerWidth < 640) { + setScreenWidth("xs"); + } else if (innerWidth < 768) { + setScreenWidth("sm"); + } else if (innerWidth < 1024) { + setScreenWidth("md"); + } else if (innerWidth < 1280) { + setScreenWidth("lg"); + } else if (innerWidth < 1536) { + setScreenWidth("xl"); + } else { + setScreenWidth("2xl"); + } + }; + + checkInnerWidth(); + window.addEventListener("resize", checkInnerWidth); + + return () => window.removeEventListener("resize", checkInnerWidth); + }, []); useEffect(() => { document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); }, [isDarkMode]); const toggleTheme = () => setIsDarkMode((prev) => !prev); + const isNarrow = useMemo(() => { + return screenWidth === "xs" || screenWidth === "sm" || screenWidth === "md"; + }, [screenWidth]); - return {children}; + return ( + {children} + ); } export function useTheme(): ThemeContextType { diff --git a/ui/src/pages/LogViewer.tsx b/ui/src/pages/LogViewer.tsx index 215c027..285ca9b 100644 --- a/ui/src/pages/LogViewer.tsx +++ b/ui/src/pages/LogViewer.tsx @@ -1,15 +1,38 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useAPI } from "../contexts/APIProvider"; import { usePersistentState } from "../hooks/usePersistentState"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { + RiTextWrap, + RiAlignJustify, + RiFontSize, + RiMenuSearchLine, + RiMenuSearchFill, + RiCloseCircleFill, +} from "react-icons/ri"; +import { useTheme } from "../contexts/ThemeProvider"; const LogViewer = () => { const { proxyLogs, upstreamLogs } = useAPI(); + const { isNarrow } = useTheme(); + const direction = isNarrow ? "vertical" : "horizontal"; return ( -
- - -
+ + + + + + + + + ); }; @@ -17,17 +40,15 @@ interface LogPanelProps { id: string; title: string; logData: string; - className?: string; } - -export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => { - const [isCollapsed, setIsCollapsed] = usePersistentState(`logPanel-${id}-isCollapsed`, false); +export const LogPanel = ({ id, title, logData }: LogPanelProps) => { const [filterRegex, setFilterRegex] = useState(""); const [fontSize, setFontSize] = usePersistentState<"xxs" | "xs" | "small" | "normal">( `logPanel-${id}-fontSize`, "normal" ); const [wrapText, setTextWrap] = usePersistentState(`logPanel-${id}-wrapText`, false); + const [showFilter, setShowFilter] = usePersistentState(`logPanel-${id}-showFilter`, false); const textWrapClass = useMemo(() => { return wrapText ? "whitespace-pre-wrap" : "whitespace-pre"; @@ -48,6 +69,19 @@ export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => { }); }, []); + const toggleWrapText = useCallback(() => { + setTextWrap((prev) => !prev); + }, []); + + const toggleFilter = useCallback(() => { + if (showFilter) { + setShowFilter(false); + setFilterRegex(""); // Clear filter when closing + } else { + setShowFilter(true); + } + }, [filterRegex, setFilterRegex, showFilter]); + const fontSizeClass = useMemo(() => { switch (fontSize) { case "xxs": @@ -81,58 +115,47 @@ export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => { }, [filteredLogs]); return ( -
+
-
- {/* Title - Always full width on mobile, normal on desktop */} -
setIsCollapsed(!isCollapsed)}> -

{title}

+
+

{title}

+ +
+ + +
+
- {!isCollapsed && ( -
- {/* Sizing Buttons - Stacks vertically on mobile */} -
- - -
- - {/* Filtering Options - Full width on mobile, normal on desktop */} -
- setFilterRegex(e.target.value)} - /> - -
+ {/* Filtering Options - Full width on mobile, normal on desktop */} + {showFilter && ( +
+
+ setFilterRegex(e.target.value)} + /> +
- )} -
+
+ )} +
+
+
+          {filteredLogs}
+        
- - {!isCollapsed && ( -
-
-            {filteredLogs}
-          
-
- )}
); }; diff --git a/ui/src/pages/Models.tsx b/ui/src/pages/Models.tsx index 57e11bb..da501b3 100644 --- a/ui/src/pages/Models.tsx +++ b/ui/src/pages/Models.tsx @@ -2,9 +2,42 @@ import { useState, useCallback, useMemo } from "react"; import { useAPI } from "../contexts/APIProvider"; import { LogPanel } from "./LogViewer"; import { usePersistentState } from "../hooks/usePersistentState"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { useTheme } from "../contexts/ThemeProvider"; +import { RiEyeFill, RiEyeOffFill, RiStopCircleLine } from "react-icons/ri"; export default function ModelsPage() { - const { models, unloadAllModels, loadModel, upstreamLogs, metrics } = useAPI(); + const { isNarrow } = useTheme(); + const direction = isNarrow ? "vertical" : "horizontal"; + const { upstreamLogs } = useAPI(); + + return ( + + + + + + + +
+ {direction === "horizontal" && } +
+ +
+
+
+
+ ); +} + +function ModelsPanel() { + const { models, loadModel, unloadAllModels } = useAPI(); const [isUnloading, setIsUnloading] = useState(false); const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true); @@ -19,12 +52,75 @@ export default function ModelsPage() { } catch (e) { console.error(e); } finally { - // at least give it a second to show the unloading message setTimeout(() => { setIsUnloading(false); }, 1000); } - }, []); + }, [unloadAllModels]); + + return ( +
+
+

Models

+
+ + +
+
+ +
+ + + + + + + + + + {filteredModels.map((model) => ( + + + + + + ))} + +
NameState
+ + {model.name !== "" ? model.name : model.id} + + {model.description !== "" && ( +

+ {model.description} +

+ )} +
+ + + {model.state} +
+
+
+ ); +} + +function StatsPanel() { + const { metrics } = useAPI(); const [totalRequests, totalTokens, avgTokensPerSecond] = useMemo(() => { const totalRequests = metrics.length; @@ -37,86 +133,24 @@ export default function ModelsPage() { }, [metrics]); return ( -
-
- {/* Left Column */} -
-
-

Models

-
- - -
- - - - - - - - - - - {filteredModels.map((model) => ( - - - - - - ))} - -
NameState
- - {model.name !== "" ? model.name : model.id} - - {model.description != "" && ( -

- {model.description} -

- )} -
- - - {model.state} -
-
-
- - {/* Right Column */} -
-
-

Chat Activity

- - - - - - - - - - - - - - - -
Requests{totalRequests}
Total Tokens Generated{totalTokens}
Average Tokens/Second{avgTokensPerSecond}
-
- - -
-
+
+

Chat Activity

+ + + + + + + + + + + + + + + +
Requests{totalRequests}
Total Tokens Generated{totalTokens}
Average Tokens/Second{avgTokensPerSecond}
); }