diff --git a/web/src/App.css b/web/src/App.css index cd57c5d..53e76a0 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -5,3 +5,17 @@ -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } + +@keyframes pulse { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.4; + } +} + +.animate-pulse { + animation: pulse 2s ease-in-out infinite; +} diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 10bbdaa..948864b 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState, useEffect } from "react"; import { apiClient, type StatusResponse, type Status } from "../lib/api"; -import { Card, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Card, Flex, Text, Tooltip, Skeleton } from "@radix-ui/themes"; import { CheckCircle, XCircle, @@ -12,6 +12,7 @@ import { Activity, MessageCircle, Circle, + WifiOff, } from "lucide-react"; import TimeAgo from "react-timeago"; import "../App.css"; @@ -22,6 +23,7 @@ export const Route = createFileRoute("/")({ // Constants const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000; +const REQUEST_TIMEOUT = 10000; // 10 seconds const CARD_STYLES = { padding: "24px", maxWidth: "400px", @@ -58,41 +60,60 @@ interface Service { icon: typeof Bot; } +type StatusState = + | { + mode: "loading"; + } + | { + mode: "response"; + timing: ResponseTiming; + lastFetch: Date; + status: StatusResponse; + } + | { + mode: "error"; + lastFetch: Date; + } + | { + mode: "timeout"; + lastFetch: Date; + }; + // Helper functions -const getStatusIcon = (status: Status): StatusIcon => { - const statusMap: Record = { +const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => { + const statusMap: Record = { Active: { icon: CheckCircle, color: "green" }, Connected: { icon: CheckCircle, color: "green" }, Healthy: { icon: CheckCircle, color: "green" }, Disabled: { icon: Circle, color: "gray" }, Error: { icon: XCircle, color: "red" }, + Unreachable: { icon: WifiOff, color: "red" }, }; return statusMap[status]; }; -const getOverallHealth = ( - status: StatusResponse | null, - error: string | null -): Status => { - if (error) return "Error"; - if (!status) return "Error"; - - return status.status; +const getOverallHealth = (state: StatusState): Status | "Unreachable" => { + if (state.mode === "timeout") return "Unreachable"; + if (state.mode === "error") return "Error"; + if (state.mode === "response") return state.status.status; + return "Error"; }; -const getServices = (status: StatusResponse | null): Service[] => { - if (!status) return []; +const getServices = (state: StatusState): Service[] => { + if (state.mode !== "response") return []; - return Object.entries(status.services).map(([serviceId, serviceInfo]) => ({ - name: serviceInfo.name, - status: serviceInfo.status, - icon: SERVICE_ICONS[serviceId] || SERVICE_ICONS.default, - })); + return Object.entries(state.status.services).map( + ([serviceId, serviceInfo]) => ({ + name: serviceInfo.name, + status: serviceInfo.status, + icon: SERVICE_ICONS[serviceId] || SERVICE_ICONS.default, + }) + ); }; // Status Component -const StatusDisplay = ({ status }: { status: Status }) => { +const StatusDisplay = ({ status }: { status: Status | "Unreachable" }) => { const { icon: Icon, color } = getStatusIcon(status); return ( @@ -124,6 +145,22 @@ const ServiceStatus = ({ service }: { service: Service }) => { ); }; +// Skeleton Service Component +const SkeletonService = () => { + return ( + + + + + + + + + + + ); +}; + // Timing Row Component const TimingRow = ({ icon: Icon, @@ -146,40 +183,82 @@ const TimingRow = ({ ); function App() { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [timing, setTiming] = useState({ - health: null, - status: null, - }); - const [lastFetch, setLastFetch] = useState(null); + const [state, setState] = useState({ mode: "loading" }); + + // Helper variables for state checking + const isLoading = state.mode === "loading"; + const hasError = state.mode === "error"; + const hasTimeout = state.mode === "timeout"; + const hasResponse = state.mode === "response"; + const shouldShowSkeleton = isLoading || hasError; + const shouldShowTiming = hasResponse && state.timing.health !== null; + const shouldShowLastFetch = hasResponse || hasError || hasTimeout; useEffect(() => { + let timeoutId: NodeJS.Timeout; + const fetchData = async () => { try { const startTime = Date.now(); - const statusData = await apiClient.getStatus(); + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Request timeout")), + REQUEST_TIMEOUT + ); + }); + + // Race between the API call and timeout + const statusData = await Promise.race([ + apiClient.getStatus(), + timeoutPromise, + ]); + const endTime = Date.now(); const responseTime = endTime - startTime; - setStatus(statusData); - setTiming({ health: responseTime, status: responseTime }); - setLastFetch(new Date()); - setError(null); + setState({ + mode: "response", + status: statusData, + timing: { health: responseTime, status: responseTime }, + lastFetch: new Date(), + }); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch data"); - setLastFetch(new Date()); + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch data"; + + // Check if it's a timeout error + if (errorMessage === "Request timeout") { + setState({ + mode: "timeout", + lastFetch: new Date(), + }); + } else { + setState({ + mode: "error", + lastFetch: new Date(), + }); + } } + + // Schedule the next request after the current one completes + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL); }; + // Start the first request immediately fetchData(); - const interval = setInterval(fetchData, REFRESH_INTERVAL); - return () => clearInterval(interval); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; }, []); - const overallHealth = getOverallHealth(status, error); + const overallHealth = getOverallHealth(state); const { color: overallColor } = getStatusIcon(overallHealth); - const services = getServices(status); + const services = getServices(state); return (
@@ -189,36 +268,65 @@ function App() { justify="center" style={{ minHeight: "100vh", padding: "20px" }} > - {status && ( - - - {/* Overall Status */} - - - - System Status - + + + {/* Overall Status */} + + + + System Status + + {isLoading ? ( + + ) : ( - + )} + - {/* Individual Services */} - - {services.map((service) => ( - - ))} - + {/* Individual Services */} + + {shouldShowSkeleton + ? // Show skeleton for 3 services during initial loading only + Array.from({ length: 3 }).map((_, index) => ( + + )) + : services.map((service) => ( + + ))} + - - {timing.health && ( - - {timing.health}ms - - )} + + {isLoading ? ( + + + + ) : shouldShowTiming ? ( + + {state.timing.health}ms + + ) : null} - {lastFetch && ( - + {shouldShowLastFetch ? ( + + {isLoading ? ( + + Loading... + + ) : ( - + - - )} - + )} + + ) : isLoading ? ( + + + Loading... + + + ) : null} - - )} - {(status?.commit || status?.version) && ( - - {status?.version && ( - - v{status.version} - - )} - {status?.version && status?.commit && ( -
- )} - {status?.commit && ( - - - GitHub - - - )} - )} + + + {__APP_VERSION__ && ( + + v{__APP_VERSION__} + + )} + {__APP_VERSION__ && ( +
+ )} + + + GitHub + + +
); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..dbb4c62 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string; diff --git a/web/vite.config.ts b/web/vite.config.ts index e8c4b0d..521cacf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,22 @@ import { defineConfig } from "vite"; import viteReact from "@vitejs/plugin-react"; import tanstackRouter from "@tanstack/router-plugin/vite"; import { resolve } from "node:path"; +import { readFileSync } from "fs"; + +// Extract version from Cargo.toml +function getVersion() { + try { + const cargoTomlPath = resolve(__dirname, "..", "Cargo.toml"); + const cargoTomlContent = readFileSync(cargoTomlPath, "utf8"); + const versionMatch = cargoTomlContent.match(/^version\s*=\s*"([^"]+)"/m); + return versionMatch ? versionMatch[1] : "unknown"; + } catch (error) { + console.warn("Could not read version from Cargo.toml:", error); + return "unknown"; + } +} + +const version = getVersion(); // https://vitejs.dev/config/ export default defineConfig({ @@ -29,4 +45,7 @@ export default defineConfig({ outDir: "dist", sourcemap: true, }, + define: { + __APP_VERSION__: JSON.stringify(version), + }, });