Add connection status indicator in UI (#260)

* show connection status as icon in UI title
* make connection status event driven
This commit is contained in:
Benson Wong
2025-08-20 13:58:24 -07:00
committed by GitHub
parent fcc5ad135a
commit 7acbaf4712
6 changed files with 106 additions and 96 deletions

View File

@@ -1,88 +1,78 @@
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom";
import { useTheme } from "./contexts/ThemeProvider"; import { useTheme } from "./contexts/ThemeProvider";
import { APIProvider } from "./contexts/APIProvider"; import { useAPI } from "./contexts/APIProvider";
import LogViewerPage from "./pages/LogViewer"; import LogViewerPage from "./pages/LogViewer";
import ModelPage from "./pages/Models"; import ModelPage from "./pages/Models";
import ActivityPage from "./pages/Activity"; import ActivityPage from "./pages/Activity";
import ConnectionStatus from "./components/ConnectionStatus"; import ConnectionStatusIcon from "./components/ConnectionStatus";
import { RiSunFill, RiMoonFill } from "react-icons/ri"; import { RiSunFill, RiMoonFill } from "react-icons/ri";
import { usePersistentState } from "./hooks/usePersistentState";
function App() { function App() {
const { isNarrow, toggleTheme, isDarkMode } = useTheme(); const { isNarrow, toggleTheme, isDarkMode, appTitle, setAppTitle, setConnectionState } = useTheme();
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
const handleTitleChange = useCallback( const handleTitleChange = useCallback(
(newTitle: string) => { (newTitle: string) => {
setAppTitle(newTitle); setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
document.title = newTitle;
}, },
[setAppTitle] [setAppTitle]
); );
const { connectionStatus } = useAPI();
// Synchronize the window.title connections state with the actual connection state
useEffect(() => { useEffect(() => {
document.title = appTitle; // Set initial title setConnectionState(connectionStatus);
}, [appTitle]); }, [connectionStatus]);
return ( return (
<Router basename="/ui/"> <Router basename="/ui/">
<APIProvider> <div className="flex flex-col h-screen">
<div className="flex flex-col h-screen"> <nav className="bg-surface border-b border-border p-2 h-[75px]">
<nav className="bg-surface border-b border-border p-2 h-[75px]"> <div className="flex items-center justify-between mx-auto px-4 h-full">
<div className="flex items-center justify-between mx-auto px-4 h-full"> {!isNarrow && (
{!isNarrow && ( <h1
<h1 contentEditable
contentEditable suppressContentEditableWarning
suppressContentEditableWarning className="flex items-center p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1"
className="flex items-center p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1" onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")}
onBlur={(e) => onKeyDown={(e) => {
handleTitleChange(e.currentTarget.textContent?.replace(/\n/g, "").trim() || "llama-swap") if (e.key === "Enter") {
e.preventDefault();
handleTitleChange(e.currentTarget.textContent || "(set title)");
e.currentTarget.blur();
} }
onKeyDown={(e) => { }}
if (e.key === "Enter") { >
e.preventDefault(); {appTitle}
const sanitizedText = </h1>
e.currentTarget.textContent?.replace(/\n/g, "").trim().substring(0, 25) || "llama-swap"; )}
handleTitleChange(sanitizedText); <div className="flex items-center space-x-4">
e.currentTarget.textContent = sanitizedText; <NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
e.currentTarget.blur(); Logs
} </NavLink>
}} <NavLink to="/models" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
> Models
{appTitle} </NavLink>
</h1> <NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
)} Activity
<div className="flex items-center space-x-4"> </NavLink>
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}> <button className="" onClick={toggleTheme}>
Logs {isDarkMode ? <RiMoonFill /> : <RiSunFill />}
</NavLink> </button>
<ConnectionStatusIcon />
<NavLink to="/models" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
Models
</NavLink>
<NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
Activity
</NavLink>
<button className="" onClick={toggleTheme}>
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
</button>
<ConnectionStatus />
</div>
</div> </div>
</nav> </div>
</nav>
<main className="flex-1 overflow-auto p-4"> <main className="flex-1 overflow-auto p-4">
<Routes> <Routes>
<Route path="/" element={<LogViewerPage />} /> <Route path="/" element={<LogViewerPage />} />
<Route path="/models" element={<ModelPage />} /> <Route path="/models" element={<ModelPage />} />
<Route path="/activity" element={<ActivityPage />} /> <Route path="/activity" element={<ActivityPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</main> </main>
</div> </div>
</APIProvider>
</Router> </Router>
); );
} }

View File

@@ -1,21 +1,11 @@
import { useAPI } from "../contexts/APIProvider"; import { useAPI } from "../contexts/APIProvider";
import { useEffect, useState, useMemo } from "react"; import { useMemo } from "react";
type ConnectionStatus = "disconnected" | "connecting" | "connected"; const ConnectionStatusIcon = () => {
const { connectionStatus } = useAPI();
const ConnectionStatus = () => {
const { getConnectionStatus } = useAPI();
const [eventStreamStatus, setEventStreamStatus] = useState<ConnectionStatus>("disconnected");
useEffect(() => {
const interval = setInterval(() => {
setEventStreamStatus(getConnectionStatus());
}, 1000);
return () => clearInterval(interval);
});
const eventStatusColor = useMemo(() => { const eventStatusColor = useMemo(() => {
switch (eventStreamStatus) { switch (connectionStatus) {
case "connected": case "connected":
return "bg-green-500"; return "bg-green-500";
case "connecting": case "connecting":
@@ -24,13 +14,13 @@ const ConnectionStatus = () => {
default: default:
return "bg-red-500"; return "bg-red-500";
} }
}, [eventStreamStatus]); }, [connectionStatus]);
return ( return (
<div className="flex items-center" title={`event stream: ${eventStreamStatus}`}> <div className="flex items-center" title={`event stream: ${connectionStatus}`}>
<span className={`inline-block w-3 h-3 rounded-full ${eventStatusColor} mr-2`}></span> <span className={`inline-block w-3 h-3 rounded-full ${eventStatusColor} mr-2`}></span>
</div> </div>
); );
}; };
export default ConnectionStatus; export default ConnectionStatusIcon;

View File

@@ -1,4 +1,5 @@
import { useRef, createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react"; 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"; type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown";
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
@@ -20,7 +21,7 @@ interface APIProviderType {
proxyLogs: string; proxyLogs: string;
upstreamLogs: string; upstreamLogs: string;
metrics: Metrics[]; metrics: Metrics[];
getConnectionStatus: () => "connected" | "connecting" | "disconnected"; connectionStatus: ConnectionState;
} }
interface Metrics { interface Metrics {
@@ -53,6 +54,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
const [proxyLogs, setProxyLogs] = useState(""); const [proxyLogs, setProxyLogs] = useState("");
const [upstreamLogs, setUpstreamLogs] = useState(""); const [upstreamLogs, setUpstreamLogs] = useState("");
const [metrics, setMetrics] = useState<Metrics[]>([]); const [metrics, setMetrics] = useState<Metrics[]>([]);
const [connectionStatus, setConnectionState] = useState<ConnectionState>("disconnected");
const apiEventSource = useRef<EventSource | null>(null); const apiEventSource = useRef<EventSource | null>(null);
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
@@ -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) => { const enableAPIEvents = useCallback((enabled: boolean) => {
if (!enabled) { if (!enabled) {
apiEventSource.current?.close(); apiEventSource.current?.close();
@@ -86,7 +78,9 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
const initialDelay = 1000; // 1 second const initialDelay = 1000; // 1 second
const connect = () => { const connect = () => {
apiEventSource.current = null;
const eventSource = new EventSource("/api/events"); const eventSource = new EventSource("/api/events");
setConnectionState("connecting");
eventSource.onopen = () => { eventSource.onopen = () => {
// clear everything out on connect to keep things in sync // clear everything out on connect to keep things in sync
@@ -94,6 +88,9 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
setUpstreamLogs(""); setUpstreamLogs("");
setMetrics([]); // clear metrics on reconnect setMetrics([]); // clear metrics on reconnect
setModels([]); // clear models on reconnect setModels([]); // clear models on reconnect
apiEventSource.current = eventSource;
retryCount = 0;
setConnectionState("connected");
}; };
eventSource.onmessage = (e: MessageEvent) => { eventSource.onmessage = (e: MessageEvent) => {
@@ -138,14 +135,14 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
console.error(e.data, err); console.error(e.data, err);
} }
}; };
eventSource.onerror = () => { eventSource.onerror = () => {
eventSource.close(); eventSource.close();
retryCount++; retryCount++;
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000); const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
setConnectionState("disconnected");
setTimeout(connect, delay); setTimeout(connect, delay);
}; };
apiEventSource.current = eventSource;
}; };
connect(); connect();
@@ -213,7 +210,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
proxyLogs, proxyLogs,
upstreamLogs, upstreamLogs,
metrics, metrics,
getConnectionStatus, connectionStatus,
}), }),
[models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics] [models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics]
); );

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react"; import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import type { ConnectionState } from "../lib/types";
type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
type ThemeContextType = { type ThemeContextType = {
@@ -7,6 +8,11 @@ type ThemeContextType = {
screenWidth: ScreenWidth; screenWidth: ScreenWidth;
isNarrow: boolean; isNarrow: boolean;
toggleTheme: () => void; toggleTheme: () => void;
// for managing the window title and connection state information
appTitle: string;
setAppTitle: (title: string) => void;
setConnectionState: (state: ConnectionState) => void;
}; };
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -16,6 +22,17 @@ type ThemeProviderProps = {
}; };
export function ThemeProvider({ children }: ThemeProviderProps) { export function ThemeProvider({ children }: ThemeProviderProps) {
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
const [connectionState, setConnectionState] = useState<ConnectionState>("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<boolean>("theme", false); const [isDarkMode, setIsDarkMode] = usePersistentState<boolean>("theme", false);
const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md
@@ -55,7 +72,19 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
}, [screenWidth]); }, [screenWidth]);
return ( return (
<ThemeContext.Provider value={{ isDarkMode, toggleTheme, screenWidth, isNarrow }}>{children}</ThemeContext.Provider> <ThemeContext.Provider
value={{
isDarkMode,
toggleTheme,
screenWidth,
isNarrow,
appTitle,
setAppTitle,
setConnectionState,
}}
>
{children}
</ThemeContext.Provider>
); );
} }

1
ui/src/lib/types.ts Normal file
View File

@@ -0,0 +1 @@
export type ConnectionState = "connected" | "connecting" | "disconnected";

View File

@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider"; import { ThemeProvider } from "./contexts/ThemeProvider";
import { APIProvider } from "./contexts/APIProvider";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<App /> <APIProvider>
<App />
</APIProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode> </StrictMode>
); );