UI styling / code improvements (#307)
Clean up and improve UI styling * fix: UI - dependency cleanup * chore: UI - start script * refactor: UI - Extract Header * fix: UI - Header styling * fix: UI - LogViewer styling * fix: UI - Models styling * fix: UI - Activity styling * fix: UI - ConnectionStatus colors * review: UI - table border colors
This commit is contained in:
@@ -1,21 +1,14 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom";
|
||||
import { useTheme } from "./contexts/ThemeProvider";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom";
|
||||
import { Header } from "./components/Header";
|
||||
import { useAPI } from "./contexts/APIProvider";
|
||||
import { useTheme } from "./contexts/ThemeProvider";
|
||||
import ActivityPage from "./pages/Activity";
|
||||
import LogViewerPage from "./pages/LogViewer";
|
||||
import ModelPage from "./pages/Models";
|
||||
import ActivityPage from "./pages/Activity";
|
||||
import ConnectionStatusIcon from "./components/ConnectionStatus";
|
||||
import { RiSunFill, RiMoonFill } from "react-icons/ri";
|
||||
|
||||
function App() {
|
||||
const { isNarrow, toggleTheme, isDarkMode, appTitle, setAppTitle, setConnectionState } = useTheme();
|
||||
const handleTitleChange = useCallback(
|
||||
(newTitle: string) => {
|
||||
setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
|
||||
},
|
||||
[setAppTitle]
|
||||
);
|
||||
const { setConnectionState } = useTheme();
|
||||
|
||||
const { connectionStatus } = useAPI();
|
||||
|
||||
@@ -27,42 +20,7 @@ function App() {
|
||||
return (
|
||||
<Router basename="/ui/">
|
||||
<div className="flex flex-col h-screen">
|
||||
<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">
|
||||
{!isNarrow && (
|
||||
<h1
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
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)")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTitleChange(e.currentTarget.textContent || "(set title)");
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{appTitle}
|
||||
</h1>
|
||||
)}
|
||||
<div className="flex items-center space-x-4">
|
||||
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
|
||||
Logs
|
||||
</NavLink>
|
||||
<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>
|
||||
<ConnectionStatusIcon />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
<Routes>
|
||||
|
||||
@@ -7,9 +7,9 @@ const ConnectionStatusIcon = () => {
|
||||
const eventStatusColor = useMemo(() => {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
return "bg-emerald-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500";
|
||||
return "bg-amber-500";
|
||||
case "disconnected":
|
||||
default:
|
||||
return "bg-red-500";
|
||||
|
||||
56
ui/src/components/Header.tsx
Normal file
56
ui/src/components/Header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from "react";
|
||||
import { RiMoonFill, RiSunFill } from "react-icons/ri";
|
||||
import { NavLink, type NavLinkRenderProps } from "react-router-dom";
|
||||
import { useTheme } from "../contexts/ThemeProvider";
|
||||
import ConnectionStatusIcon from "./ConnectionStatus";
|
||||
|
||||
export function Header() {
|
||||
const { screenWidth, toggleTheme, isDarkMode, appTitle, setAppTitle } = useTheme();
|
||||
const handleTitleChange = useCallback(
|
||||
(newTitle: string) => {
|
||||
setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
|
||||
},
|
||||
[setAppTitle]
|
||||
);
|
||||
|
||||
const navLinkClass = ({ isActive }: NavLinkRenderProps) =>
|
||||
`text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 ${isActive ? "font-semibold" : ""}`;
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between bg-surface border-b border-border p-2 px-4 h-[75px]">
|
||||
{screenWidth !== "xs" && screenWidth !== "sm" && (
|
||||
<h1
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTitleChange(e.currentTarget.textContent || "(set title)");
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{appTitle}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<menu className="flex items-center gap-4">
|
||||
<NavLink to="/" className={navLinkClass} type="button">
|
||||
Logs
|
||||
</NavLink>
|
||||
<NavLink to="/models" className={navLinkClass} type="button">
|
||||
Models
|
||||
</NavLink>
|
||||
<NavLink to="/activity" className={navLinkClass} type="button">
|
||||
Activity
|
||||
</NavLink>
|
||||
<button className="" onClick={toggleTheme}>
|
||||
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
|
||||
</button>
|
||||
<ConnectionStatusIcon />
|
||||
</menu>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,14 @@
|
||||
@apply px-4;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table th {
|
||||
@apply p-2 font-semibold;
|
||||
}
|
||||
table td {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
/* Navigation Header */
|
||||
|
||||
.navlink {
|
||||
@@ -122,7 +130,7 @@
|
||||
|
||||
/* Status Badges */
|
||||
.status {
|
||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-full;
|
||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-lg;
|
||||
}
|
||||
|
||||
.status--ready {
|
||||
@@ -140,7 +148,7 @@
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply bg-surface p-2 px-4 text-sm rounded-full border border-2 transition-colors duration-200 border-btn-border;
|
||||
@apply bg-surface py-2 px-4 text-sm rounded-md border transition-colors duration-200 border-btn-border;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
|
||||
@@ -43,47 +43,46 @@ const ActivityPage = () => {
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Activity</h1>
|
||||
<div className="p-2">
|
||||
<h1 className="text-2xl font-bold">Activity</h1>
|
||||
|
||||
{metrics.length === 0 ? (
|
||||
{metrics.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">No metrics data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
)}
|
||||
{metrics.length > 0 && (
|
||||
<div className="card overflow-auto">
|
||||
<table className="min-w-full divide-y">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Time</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Model</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
<thead className="border-gray-200 dark:border-white/10">
|
||||
<tr className="text-left text-xs uppercase tracking-wider">
|
||||
<th className="px-6 py-3">ID</th>
|
||||
<th className="px-6 py-3">Time</th>
|
||||
<th className="px-6 py-3">Model</th>
|
||||
<th className="px-6 py-3">
|
||||
Cached <Tooltip content="prompt tokens from cache" />
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
<th className="px-6 py-3">
|
||||
Prompt <Tooltip content="new prompt tokens processed" />
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Generated</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Prompt Processing</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Generation Speed</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">Duration</th>
|
||||
<th className="px-6 py-3">Generated</th>
|
||||
<th className="px-6 py-3">Prompt Processing</th>
|
||||
<th className="px-6 py-3">Generation Speed</th>
|
||||
<th className="px-6 py-3">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sortedMetrics.map((metric) => (
|
||||
<tr key={`metric_${metric.id}`}>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm">{metric.id + 1 /* un-zero index */}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{formatRelativeTime(metric.timestamp)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{metric.model}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{metric.input_tokens.toLocaleString()}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{metric.output_tokens.toLocaleString()}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{formatSpeed(metric.prompt_per_second)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{formatSpeed(metric.tokens_per_second)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">{formatDuration(metric.duration_ms)}</td>
|
||||
<tr key={metric.id} className="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
|
||||
<td className="px-4 py-4">{metric.id + 1 /* un-zero index */}</td>
|
||||
<td className="px-6 py-4">{formatRelativeTime(metric.timestamp)}</td>
|
||||
<td className="px-6 py-4">{metric.model}</td>
|
||||
<td className="px-6 py-4">{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}</td>
|
||||
<td className="px-6 py-4">{metric.input_tokens.toLocaleString()}</td>
|
||||
<td className="px-6 py-4">{metric.output_tokens.toLocaleString()}</td>
|
||||
<td className="px-6 py-4">{formatSpeed(metric.prompt_per_second)}</td>
|
||||
<td className="px-6 py-4">{formatSpeed(metric.tokens_per_second)}</td>
|
||||
<td className="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useTheme } from "../contexts/ThemeProvider";
|
||||
|
||||
const LogViewer = () => {
|
||||
const { proxyLogs, upstreamLogs } = useAPI();
|
||||
const { isNarrow } = useTheme();
|
||||
const direction = isNarrow ? "vertical" : "horizontal";
|
||||
const { screenWidth } = useTheme();
|
||||
const direction = screenWidth === "xs" || screenWidth === "sm" ? "vertical" : "horizontal";
|
||||
|
||||
return (
|
||||
<PanelGroup direction={direction} className="gap-2" autoSaveId="logviewer-panel-group">
|
||||
@@ -115,19 +115,19 @@ export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
|
||||
}, [filteredLogs]);
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-lg overflow-hidden flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border bg-secondary">
|
||||
<div className="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full p-1">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="m-0 text-lg p-0">{title}</h3>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<button className="btn" onClick={toggleFontSize}>
|
||||
<button className="btn border-0" onClick={toggleFontSize}>
|
||||
<RiFontSize />
|
||||
</button>
|
||||
<button className="btn" onClick={toggleWrapText}>
|
||||
<button className="btn border-0" onClick={toggleWrapText}>
|
||||
{wrapText ? <RiTextWrap /> : <RiAlignJustify />}
|
||||
</button>
|
||||
<button className="btn" onClick={toggleFilter}>
|
||||
<button className="btn border-0" onClick={toggleFilter}>
|
||||
{showFilter ? <RiMenuSearchFill /> : <RiMenuSearchLine />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@ export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full text-sm border p-2 rounded"
|
||||
className="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
|
||||
placeholder="Filter logs..."
|
||||
value={filterRegex}
|
||||
onChange={(e) => setFilterRegex(e.target.value)}
|
||||
@@ -151,7 +151,7 @@ export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-background font-mono text-sm flex-1 overflow-hidden">
|
||||
<div className="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden">
|
||||
<pre ref={preTagRef} className={`${textWrapClass} ${fontSizeClass} h-full overflow-auto p-4`}>
|
||||
{filteredLogs}
|
||||
</pre>
|
||||
|
||||
@@ -69,19 +69,27 @@ function ModelsPanel() {
|
||||
<h2>Models</h2>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<button className="btn flex items-center gap-2" onClick={toggleIdorName} style={{ lineHeight: "1.2" }}>
|
||||
<RiSwapBoxFill /> {showIdorName === "id" ? "ID" : "Name"}
|
||||
<button
|
||||
className="btn text-base flex items-center gap-2"
|
||||
onClick={toggleIdorName}
|
||||
style={{ lineHeight: "1.2" }}
|
||||
>
|
||||
<RiSwapBoxFill size="20" /> {showIdorName === "id" ? "ID" : "Name"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn flex items-center gap-2"
|
||||
className="btn text-base flex items-center gap-2"
|
||||
onClick={() => setShowUnlisted(!showUnlisted)}
|
||||
style={{ lineHeight: "1.2" }}
|
||||
>
|
||||
{showUnlisted ? <RiEyeFill /> : <RiEyeOffFill />} unlisted
|
||||
{showUnlisted ? <RiEyeFill size="20" /> : <RiEyeOffFill size="20" />} unlisted
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn flex items-center gap-2" onClick={handleUnloadAllModels} disabled={isUnloading}>
|
||||
<button
|
||||
className="btn text-base flex items-center gap-2"
|
||||
onClick={handleUnloadAllModels}
|
||||
disabled={isUnloading}
|
||||
>
|
||||
<RiStopCircleLine size="24" /> {isUnloading ? "Unloading..." : "Unload"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -90,26 +98,27 @@ function ModelsPanel() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-card z-10">
|
||||
<tr className="border-b border-primary bg-surface">
|
||||
<th className="text-left p-2">{showIdorName === "id" ? "Model ID" : "Name"}</th>
|
||||
<th className="text-left p-2"></th>
|
||||
<th className="text-left p-2">State</th>
|
||||
<tr className="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
|
||||
<th>{showIdorName === "id" ? "Model ID" : "Name"}</th>
|
||||
<th></th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredModels.map((model) => (
|
||||
<tr key={model.id} className="border-b hover:bg-secondary-hover border-border">
|
||||
<td className={`p-2 ${model.unlisted ? "text-txtsecondary" : ""}`}>
|
||||
<a href={`/upstream/${model.id}/`} className={`underline`} target="_blank">
|
||||
<tr key={model.id} className="border-b hover:bg-secondary-hover border-gray-200">
|
||||
<td className={`${model.unlisted ? "text-txtsecondary" : ""}`}>
|
||||
<a href={`/upstream/${model.id}/`} className="font-semibold" target="_blank">
|
||||
{showIdorName === "id" ? model.id : model.name !== "" ? model.name : model.id}
|
||||
</a>
|
||||
{model.description !== "" && (
|
||||
|
||||
{!!model.description && (
|
||||
<p className={model.unlisted ? "text-opacity-70" : ""}>
|
||||
<em>{model.description}</em>
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 w-[50px]">
|
||||
<td className="w-12">
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
disabled={model.state !== "stopped"}
|
||||
@@ -118,8 +127,8 @@ function ModelsPanel() {
|
||||
Load
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-2 w-[75px]">
|
||||
<span className={`status status--${model.state}`}>{model.state}</span>
|
||||
<td className="w-20">
|
||||
<span className={`w-16 text-center status status--${model.state}`}>{model.state}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -146,24 +155,26 @@ function StatsPanel() {
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="rounded-lg overflow-hidden border border-gray-200">
|
||||
<div className="rounded-lg overflow-hidden border border-gray-200 dark:border-white/10">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="p-2 font-medium border-b border-gray-200 text-right">Requests</th>
|
||||
<th className="p-2 font-medium border-l border-b border-gray-200 text-right">Processed</th>
|
||||
<th className="p-2 font-medium border-l border-b border-gray-200 text-right">Generated</th>
|
||||
<th className="p-2 font-medium border-l border-b border-gray-200 text-right">Tokens/Sec</th>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-white/10 text-right">
|
||||
<th>Requests</th>
|
||||
<th className="border-l border-gray-200 dark:border-white/10">Processed</th>
|
||||
<th className="border-l border-gray-200 dark:border-white/10">Generated</th>
|
||||
<th className="border-l border-gray-200 dark:border-white/10">Tokens/Sec</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 text-right border-r border-gray-200">{totalRequests}</td>
|
||||
<td className="p-2 text-right border-r border-gray-200">
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="text-right">
|
||||
<td className="border-r border-gray-200 dark:border-white/10">{totalRequests}</td>
|
||||
<td className="border-r border-gray-200 dark:border-white/10">
|
||||
{new Intl.NumberFormat().format(totalInputTokens)}
|
||||
</td>
|
||||
<td className="p-2 text-right border-r border-gray-200">
|
||||
<td className="border-r border-gray-200 dark:border-white/10">
|
||||
{new Intl.NumberFormat().format(totalOutputTokens)}
|
||||
</td>
|
||||
<td className="p-2 text-right">{avgTokensPerSecond}</td>
|
||||
<td>{avgTokensPerSecond}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user