Skip to content

Commit 7acbaf4

Browse files
authored
Add connection status indicator in UI (#260)
* show connection status as icon in UI title * make connection status event driven
1 parent fcc5ad1 commit 7acbaf4

File tree

6 files changed

+106
-96
lines changed

6 files changed

+106
-96
lines changed

ui/src/App.tsx

Lines changed: 53 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,78 @@
11
import { useEffect, useCallback } from "react";
22
import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom";
33
import { useTheme } from "./contexts/ThemeProvider";
4-
import { APIProvider } from "./contexts/APIProvider";
4+
import { useAPI } from "./contexts/APIProvider";
55
import LogViewerPage from "./pages/LogViewer";
66
import ModelPage from "./pages/Models";
77
import ActivityPage from "./pages/Activity";
8-
import ConnectionStatus from "./components/ConnectionStatus";
8+
import ConnectionStatusIcon from "./components/ConnectionStatus";
99
import { RiSunFill, RiMoonFill } from "react-icons/ri";
10-
import { usePersistentState } from "./hooks/usePersistentState";
1110

1211
function App() {
13-
const { isNarrow, toggleTheme, isDarkMode } = useTheme();
14-
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
15-
12+
const { isNarrow, toggleTheme, isDarkMode, appTitle, setAppTitle, setConnectionState } = useTheme();
1613
const handleTitleChange = useCallback(
1714
(newTitle: string) => {
18-
setAppTitle(newTitle);
19-
document.title = newTitle;
15+
setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
2016
},
2117
[setAppTitle]
2218
);
2319

20+
const { connectionStatus } = useAPI();
21+
22+
// Synchronize the window.title connections state with the actual connection state
2423
useEffect(() => {
25-
document.title = appTitle; // Set initial title
26-
}, [appTitle]);
24+
setConnectionState(connectionStatus);
25+
}, [connectionStatus]);
2726

2827
return (
2928
<Router basename="/ui/">
30-
<APIProvider>
31-
<div className="flex flex-col h-screen">
32-
<nav className="bg-surface border-b border-border p-2 h-[75px]">
33-
<div className="flex items-center justify-between mx-auto px-4 h-full">
34-
{!isNarrow && (
35-
<h1
36-
contentEditable
37-
suppressContentEditableWarning
38-
className="flex items-center p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1"
39-
onBlur={(e) =>
40-
handleTitleChange(e.currentTarget.textContent?.replace(/\n/g, "").trim() || "llama-swap")
29+
<div className="flex flex-col h-screen">
30+
<nav className="bg-surface border-b border-border p-2 h-[75px]">
31+
<div className="flex items-center justify-between mx-auto px-4 h-full">
32+
{!isNarrow && (
33+
<h1
34+
contentEditable
35+
suppressContentEditableWarning
36+
className="flex items-center p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1"
37+
onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")}
38+
onKeyDown={(e) => {
39+
if (e.key === "Enter") {
40+
e.preventDefault();
41+
handleTitleChange(e.currentTarget.textContent || "(set title)");
42+
e.currentTarget.blur();
4143
}
42-
onKeyDown={(e) => {
43-
if (e.key === "Enter") {
44-
e.preventDefault();
45-
const sanitizedText =
46-
e.currentTarget.textContent?.replace(/\n/g, "").trim().substring(0, 25) || "llama-swap";
47-
handleTitleChange(sanitizedText);
48-
e.currentTarget.textContent = sanitizedText;
49-
e.currentTarget.blur();
50-
}
51-
}}
52-
>
53-
{appTitle}
54-
</h1>
55-
)}
56-
<div className="flex items-center space-x-4">
57-
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
58-
Logs
59-
</NavLink>
60-
61-
<NavLink to="/models" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
62-
Models
63-
</NavLink>
64-
65-
<NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
66-
Activity
67-
</NavLink>
68-
<button className="" onClick={toggleTheme}>
69-
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
70-
</button>
71-
<ConnectionStatus />
72-
</div>
44+
}}
45+
>
46+
{appTitle}
47+
</h1>
48+
)}
49+
<div className="flex items-center space-x-4">
50+
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
51+
Logs
52+
</NavLink>
53+
<NavLink to="/models" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
54+
Models
55+
</NavLink>
56+
<NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
57+
Activity
58+
</NavLink>
59+
<button className="" onClick={toggleTheme}>
60+
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
61+
</button>
62+
<ConnectionStatusIcon />
7363
</div>
74-
</nav>
64+
</div>
65+
</nav>
7566

76-
<main className="flex-1 overflow-auto p-4">
77-
<Routes>
78-
<Route path="/" element={<LogViewerPage />} />
79-
<Route path="/models" element={<ModelPage />} />
80-
<Route path="/activity" element={<ActivityPage />} />
81-
<Route path="*" element={<Navigate to="/" replace />} />
82-
</Routes>
83-
</main>
84-
</div>
85-
</APIProvider>
67+
<main className="flex-1 overflow-auto p-4">
68+
<Routes>
69+
<Route path="/" element={<LogViewerPage />} />
70+
<Route path="/models" element={<ModelPage />} />
71+
<Route path="/activity" element={<ActivityPage />} />
72+
<Route path="*" element={<Navigate to="/" replace />} />
73+
</Routes>
74+
</main>
75+
</div>
8676
</Router>
8777
);
8878
}
Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,11 @@
11
import { useAPI } from "../contexts/APIProvider";
2-
import { useEffect, useState, useMemo } from "react";
2+
import { useMemo } from "react";
33

4-
type ConnectionStatus = "disconnected" | "connecting" | "connected";
5-
6-
const ConnectionStatus = () => {
7-
const { getConnectionStatus } = useAPI();
8-
const [eventStreamStatus, setEventStreamStatus] = useState<ConnectionStatus>("disconnected");
9-
10-
useEffect(() => {
11-
const interval = setInterval(() => {
12-
setEventStreamStatus(getConnectionStatus());
13-
}, 1000);
14-
return () => clearInterval(interval);
15-
});
4+
const ConnectionStatusIcon = () => {
5+
const { connectionStatus } = useAPI();
166

177
const eventStatusColor = useMemo(() => {
18-
switch (eventStreamStatus) {
8+
switch (connectionStatus) {
199
case "connected":
2010
return "bg-green-500";
2111
case "connecting":
@@ -24,13 +14,13 @@ const ConnectionStatus = () => {
2414
default:
2515
return "bg-red-500";
2616
}
27-
}, [eventStreamStatus]);
17+
}, [connectionStatus]);
2818

2919
return (
30-
<div className="flex items-center" title={`event stream: ${eventStreamStatus}`}>
20+
<div className="flex items-center" title={`event stream: ${connectionStatus}`}>
3121
<span className={`inline-block w-3 h-3 rounded-full ${eventStatusColor} mr-2`}></span>
3222
</div>
3323
);
3424
};
3525

36-
export default ConnectionStatus;
26+
export default ConnectionStatusIcon;

ui/src/contexts/APIProvider.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useRef, createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react";
2+
import type { ConnectionState } from "../lib/types";
23

34
type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown";
45
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
@@ -20,7 +21,7 @@ interface APIProviderType {
2021
proxyLogs: string;
2122
upstreamLogs: string;
2223
metrics: Metrics[];
23-
getConnectionStatus: () => "connected" | "connecting" | "disconnected";
24+
connectionStatus: ConnectionState;
2425
}
2526

2627
interface Metrics {
@@ -53,6 +54,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
5354
const [proxyLogs, setProxyLogs] = useState("");
5455
const [upstreamLogs, setUpstreamLogs] = useState("");
5556
const [metrics, setMetrics] = useState<Metrics[]>([]);
57+
const [connectionStatus, setConnectionState] = useState<ConnectionState>("disconnected");
5658
const apiEventSource = useRef<EventSource | null>(null);
5759

5860
const [models, setModels] = useState<Model[]>([]);
@@ -64,16 +66,6 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
6466
});
6567
}, []);
6668

67-
const getConnectionStatus = useCallback(() => {
68-
if (apiEventSource.current?.readyState === EventSource.OPEN) {
69-
return "connected";
70-
} else if (apiEventSource.current?.readyState === EventSource.CONNECTING) {
71-
return "connecting";
72-
} else {
73-
return "disconnected";
74-
}
75-
}, []);
76-
7769
const enableAPIEvents = useCallback((enabled: boolean) => {
7870
if (!enabled) {
7971
apiEventSource.current?.close();
@@ -86,14 +78,19 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
8678
const initialDelay = 1000; // 1 second
8779

8880
const connect = () => {
81+
apiEventSource.current = null;
8982
const eventSource = new EventSource("/api/events");
83+
setConnectionState("connecting");
9084

9185
eventSource.onopen = () => {
9286
// clear everything out on connect to keep things in sync
9387
setProxyLogs("");
9488
setUpstreamLogs("");
9589
setMetrics([]); // clear metrics on reconnect
9690
setModels([]); // clear models on reconnect
91+
apiEventSource.current = eventSource;
92+
retryCount = 0;
93+
setConnectionState("connected");
9794
};
9895

9996
eventSource.onmessage = (e: MessageEvent) => {
@@ -138,14 +135,14 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
138135
console.error(e.data, err);
139136
}
140137
};
138+
141139
eventSource.onerror = () => {
142140
eventSource.close();
143141
retryCount++;
144142
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
143+
setConnectionState("disconnected");
145144
setTimeout(connect, delay);
146145
};
147-
148-
apiEventSource.current = eventSource;
149146
};
150147

151148
connect();
@@ -213,7 +210,7 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
213210
proxyLogs,
214211
upstreamLogs,
215212
metrics,
216-
getConnectionStatus,
213+
connectionStatus,
217214
}),
218215
[models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics]
219216
);

ui/src/contexts/ThemeProvider.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react";
22
import { usePersistentState } from "../hooks/usePersistentState";
3+
import type { ConnectionState } from "../lib/types";
34

45
type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
56
type ThemeContextType = {
67
isDarkMode: boolean;
78
screenWidth: ScreenWidth;
89
isNarrow: boolean;
910
toggleTheme: () => void;
11+
12+
// for managing the window title and connection state information
13+
appTitle: string;
14+
setAppTitle: (title: string) => void;
15+
setConnectionState: (state: ConnectionState) => void;
1016
};
1117

1218
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -16,6 +22,17 @@ type ThemeProviderProps = {
1622
};
1723

1824
export function ThemeProvider({ children }: ThemeProviderProps) {
25+
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
26+
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
27+
28+
/**
29+
* Set the document.title with informative information
30+
*/
31+
useEffect(() => {
32+
const connectionIcon = connectionState === "connecting" ? "🟡" : connectionState === "connected" ? "🟢" : "🔴";
33+
document.title = connectionIcon + " " + appTitle; // Set initial title
34+
}, [appTitle, connectionState]);
35+
1936
const [isDarkMode, setIsDarkMode] = usePersistentState<boolean>("theme", false);
2037
const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md
2138

@@ -55,7 +72,19 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
5572
}, [screenWidth]);
5673

5774
return (
58-
<ThemeContext.Provider value={{ isDarkMode, toggleTheme, screenWidth, isNarrow }}>{children}</ThemeContext.Provider>
75+
<ThemeContext.Provider
76+
value={{
77+
isDarkMode,
78+
toggleTheme,
79+
screenWidth,
80+
isNarrow,
81+
appTitle,
82+
setAppTitle,
83+
setConnectionState,
84+
}}
85+
>
86+
{children}
87+
</ThemeContext.Provider>
5988
);
6089
}
6190

ui/src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type ConnectionState = "connected" | "connecting" | "disconnected";

ui/src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
33
import "./index.css";
44
import App from "./App.tsx";
55
import { ThemeProvider } from "./contexts/ThemeProvider";
6+
import { APIProvider } from "./contexts/APIProvider";
67

78
createRoot(document.getElementById("root")!).render(
89
<StrictMode>
910
<ThemeProvider>
10-
<App />
11+
<APIProvider>
12+
<App />
13+
</APIProvider>
1114
</ThemeProvider>
1215
</StrictMode>
1316
);

0 commit comments

Comments
 (0)