diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b6493b1..12dc322 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,88 +1,78 @@ import { useEffect, useCallback } from "react"; import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom"; import { useTheme } from "./contexts/ThemeProvider"; -import { APIProvider } from "./contexts/APIProvider"; +import { useAPI } from "./contexts/APIProvider"; import LogViewerPage from "./pages/LogViewer"; import ModelPage from "./pages/Models"; import ActivityPage from "./pages/Activity"; -import ConnectionStatus from "./components/ConnectionStatus"; +import ConnectionStatusIcon from "./components/ConnectionStatus"; import { RiSunFill, RiMoonFill } from "react-icons/ri"; -import { usePersistentState } from "./hooks/usePersistentState"; function App() { - const { isNarrow, toggleTheme, isDarkMode } = useTheme(); - const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap"); - + const { isNarrow, toggleTheme, isDarkMode, appTitle, setAppTitle, setConnectionState } = useTheme(); const handleTitleChange = useCallback( (newTitle: string) => { - setAppTitle(newTitle); - document.title = newTitle; + setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap"); }, [setAppTitle] ); + const { connectionStatus } = useAPI(); + + // Synchronize the window.title connections state with the actual connection state useEffect(() => { - document.title = appTitle; // Set initial title - }, [appTitle]); + setConnectionState(connectionStatus); + }, [connectionStatus]); return ( - -
- +
+ -
- - } /> - } /> - } /> - } /> - -
- -
+
+ + } /> + } /> + } /> + } /> + +
+
); } diff --git a/ui/src/components/ConnectionStatus.tsx b/ui/src/components/ConnectionStatus.tsx index 56a684f..0f0589c 100644 --- a/ui/src/components/ConnectionStatus.tsx +++ b/ui/src/components/ConnectionStatus.tsx @@ -1,21 +1,11 @@ import { useAPI } from "../contexts/APIProvider"; -import { useEffect, useState, useMemo } from "react"; +import { useMemo } from "react"; -type ConnectionStatus = "disconnected" | "connecting" | "connected"; - -const ConnectionStatus = () => { - const { getConnectionStatus } = useAPI(); - const [eventStreamStatus, setEventStreamStatus] = useState("disconnected"); - - useEffect(() => { - const interval = setInterval(() => { - setEventStreamStatus(getConnectionStatus()); - }, 1000); - return () => clearInterval(interval); - }); +const ConnectionStatusIcon = () => { + const { connectionStatus } = useAPI(); const eventStatusColor = useMemo(() => { - switch (eventStreamStatus) { + switch (connectionStatus) { case "connected": return "bg-green-500"; case "connecting": @@ -24,13 +14,13 @@ const ConnectionStatus = () => { default: return "bg-red-500"; } - }, [eventStreamStatus]); + }, [connectionStatus]); return ( -
+
); }; -export default ConnectionStatus; +export default ConnectionStatusIcon; diff --git a/ui/src/contexts/APIProvider.tsx b/ui/src/contexts/APIProvider.tsx index 20bd81b..8365cb3 100644 --- a/ui/src/contexts/APIProvider.tsx +++ b/ui/src/contexts/APIProvider.tsx @@ -1,4 +1,5 @@ import { useRef, createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react"; +import type { ConnectionState } from "../lib/types"; type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown"; const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ @@ -20,7 +21,7 @@ interface APIProviderType { proxyLogs: string; upstreamLogs: string; metrics: Metrics[]; - getConnectionStatus: () => "connected" | "connecting" | "disconnected"; + connectionStatus: ConnectionState; } interface Metrics { @@ -53,6 +54,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider const [proxyLogs, setProxyLogs] = useState(""); const [upstreamLogs, setUpstreamLogs] = useState(""); const [metrics, setMetrics] = useState([]); + const [connectionStatus, setConnectionState] = useState("disconnected"); const apiEventSource = useRef(null); const [models, setModels] = useState([]); @@ -64,16 +66,6 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider }); }, []); - const getConnectionStatus = useCallback(() => { - if (apiEventSource.current?.readyState === EventSource.OPEN) { - return "connected"; - } else if (apiEventSource.current?.readyState === EventSource.CONNECTING) { - return "connecting"; - } else { - return "disconnected"; - } - }, []); - const enableAPIEvents = useCallback((enabled: boolean) => { if (!enabled) { apiEventSource.current?.close(); @@ -86,7 +78,9 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider const initialDelay = 1000; // 1 second const connect = () => { + apiEventSource.current = null; const eventSource = new EventSource("/api/events"); + setConnectionState("connecting"); eventSource.onopen = () => { // clear everything out on connect to keep things in sync @@ -94,6 +88,9 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider setUpstreamLogs(""); setMetrics([]); // clear metrics on reconnect setModels([]); // clear models on reconnect + apiEventSource.current = eventSource; + retryCount = 0; + setConnectionState("connected"); }; eventSource.onmessage = (e: MessageEvent) => { @@ -138,14 +135,14 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider console.error(e.data, err); } }; + eventSource.onerror = () => { eventSource.close(); retryCount++; const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000); + setConnectionState("disconnected"); setTimeout(connect, delay); }; - - apiEventSource.current = eventSource; }; connect(); @@ -213,7 +210,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider proxyLogs, upstreamLogs, metrics, - getConnectionStatus, + connectionStatus, }), [models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics] ); diff --git a/ui/src/contexts/ThemeProvider.tsx b/ui/src/contexts/ThemeProvider.tsx index b662bd2..bfbba85 100644 --- a/ui/src/contexts/ThemeProvider.tsx +++ b/ui/src/contexts/ThemeProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react"; import { usePersistentState } from "../hooks/usePersistentState"; +import type { ConnectionState } from "../lib/types"; type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; type ThemeContextType = { @@ -7,6 +8,11 @@ type ThemeContextType = { screenWidth: ScreenWidth; isNarrow: boolean; toggleTheme: () => void; + + // for managing the window title and connection state information + appTitle: string; + setAppTitle: (title: string) => void; + setConnectionState: (state: ConnectionState) => void; }; const ThemeContext = createContext(undefined); @@ -16,6 +22,17 @@ type ThemeProviderProps = { }; export function ThemeProvider({ children }: ThemeProviderProps) { + const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap"); + const [connectionState, setConnectionState] = useState("disconnected"); + + /** + * Set the document.title with informative information + */ + useEffect(() => { + const connectionIcon = connectionState === "connecting" ? "🟡" : connectionState === "connected" ? "🟢" : "🔴"; + document.title = connectionIcon + " " + appTitle; // Set initial title + }, [appTitle, connectionState]); + const [isDarkMode, setIsDarkMode] = usePersistentState("theme", false); const [screenWidth, setScreenWidth] = useState("md"); // Default to md @@ -55,7 +72,19 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }, [screenWidth]); return ( - {children} + + {children} + ); } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 0000000..1d55c13 --- /dev/null +++ b/ui/src/lib/types.ts @@ -0,0 +1 @@ +export type ConnectionState = "connected" | "connecting" | "disconnected"; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 272f710..f26d521 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; import { ThemeProvider } from "./contexts/ThemeProvider"; +import { APIProvider } from "./contexts/APIProvider"; createRoot(document.getElementById("root")!).render( - + + + );