feat: better frontend state implementation, acquire version in frontend build time

This commit is contained in:
2025-09-13 20:11:39 -05:00
parent bfcd868337
commit a732ff9a15
4 changed files with 267 additions and 117 deletions

View File

@@ -5,3 +5,17 @@
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; "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;
}

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { apiClient, type StatusResponse, type Status } from "../lib/api"; 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 { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
@@ -12,6 +12,7 @@ import {
Activity, Activity,
MessageCircle, MessageCircle,
Circle, Circle,
WifiOff,
} from "lucide-react"; } from "lucide-react";
import TimeAgo from "react-timeago"; import TimeAgo from "react-timeago";
import "../App.css"; import "../App.css";
@@ -22,6 +23,7 @@ export const Route = createFileRoute("/")({
// Constants // Constants
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000; const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
const REQUEST_TIMEOUT = 10000; // 10 seconds
const CARD_STYLES = { const CARD_STYLES = {
padding: "24px", padding: "24px",
maxWidth: "400px", maxWidth: "400px",
@@ -58,41 +60,60 @@ interface Service {
icon: typeof Bot; 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 // Helper functions
const getStatusIcon = (status: Status): StatusIcon => { const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const statusMap: Record<Status, StatusIcon> = { const statusMap: Record<Status | "Unreachable", StatusIcon> = {
Active: { icon: CheckCircle, color: "green" }, Active: { icon: CheckCircle, color: "green" },
Connected: { icon: CheckCircle, color: "green" }, Connected: { icon: CheckCircle, color: "green" },
Healthy: { icon: CheckCircle, color: "green" }, Healthy: { icon: CheckCircle, color: "green" },
Disabled: { icon: Circle, color: "gray" }, Disabled: { icon: Circle, color: "gray" },
Error: { icon: XCircle, color: "red" }, Error: { icon: XCircle, color: "red" },
Unreachable: { icon: WifiOff, color: "red" },
}; };
return statusMap[status]; return statusMap[status];
}; };
const getOverallHealth = ( const getOverallHealth = (state: StatusState): Status | "Unreachable" => {
status: StatusResponse | null, if (state.mode === "timeout") return "Unreachable";
error: string | null if (state.mode === "error") return "Error";
): Status => { if (state.mode === "response") return state.status.status;
if (error) return "Error"; return "Error";
if (!status) return "Error";
return status.status;
}; };
const getServices = (status: StatusResponse | null): Service[] => { const getServices = (state: StatusState): Service[] => {
if (!status) return []; if (state.mode !== "response") return [];
return Object.entries(status.services).map(([serviceId, serviceInfo]) => ({ return Object.entries(state.status.services).map(
([serviceId, serviceInfo]) => ({
name: serviceInfo.name, name: serviceInfo.name,
status: serviceInfo.status, status: serviceInfo.status,
icon: SERVICE_ICONS[serviceId] || SERVICE_ICONS.default, icon: SERVICE_ICONS[serviceId] || SERVICE_ICONS.default,
})); })
);
}; };
// Status Component // Status Component
const StatusDisplay = ({ status }: { status: Status }) => { const StatusDisplay = ({ status }: { status: Status | "Unreachable" }) => {
const { icon: Icon, color } = getStatusIcon(status); const { icon: Icon, color } = getStatusIcon(status);
return ( return (
@@ -124,6 +145,22 @@ const ServiceStatus = ({ service }: { service: Service }) => {
); );
}; };
// Skeleton Service Component
const SkeletonService = () => {
return (
<Flex align="center" justify="between">
<Flex align="center" gap="2">
<Skeleton height="24px" width="18px" />
<Skeleton height="24px" width="60px" />
</Flex>
<Flex align="center" gap="2">
<Skeleton height="20px" width="50px" />
<Skeleton height="20px" width="16px" />
</Flex>
</Flex>
);
};
// Timing Row Component // Timing Row Component
const TimingRow = ({ const TimingRow = ({
icon: Icon, icon: Icon,
@@ -146,40 +183,82 @@ const TimingRow = ({
); );
function App() { function App() {
const [status, setStatus] = useState<StatusResponse | null>(null); const [state, setState] = useState<StatusState>({ mode: "loading" });
const [error, setError] = useState<string | null>(null);
const [timing, setTiming] = useState<ResponseTiming>({ // Helper variables for state checking
health: null, const isLoading = state.mode === "loading";
status: null, const hasError = state.mode === "error";
}); const hasTimeout = state.mode === "timeout";
const [lastFetch, setLastFetch] = useState<Date | null>(null); const hasResponse = state.mode === "response";
const shouldShowSkeleton = isLoading || hasError;
const shouldShowTiming = hasResponse && state.timing.health !== null;
const shouldShowLastFetch = hasResponse || hasError || hasTimeout;
useEffect(() => { useEffect(() => {
let timeoutId: NodeJS.Timeout;
const fetchData = async () => { const fetchData = async () => {
try { try {
const startTime = Date.now(); const startTime = Date.now();
const statusData = await apiClient.getStatus();
// Create a timeout promise
const timeoutPromise = new Promise<never>((_, 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 endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
setStatus(statusData); setState({
setTiming({ health: responseTime, status: responseTime }); mode: "response",
setLastFetch(new Date()); status: statusData,
setError(null); timing: { health: responseTime, status: responseTime },
lastFetch: new Date(),
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch data"); const errorMessage =
setLastFetch(new Date()); 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(); 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 { color: overallColor } = getStatusIcon(overallHealth);
const services = getServices(status); const services = getServices(state);
return ( return (
<div className="App"> <div className="App">
@@ -189,36 +268,65 @@ function App() {
justify="center" justify="center"
style={{ minHeight: "100vh", padding: "20px" }} style={{ minHeight: "100vh", padding: "20px" }}
> >
{status && (
<Card style={CARD_STYLES}> <Card style={CARD_STYLES}>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
{/* Overall Status */} {/* Overall Status */}
<Flex align="center" justify="between"> <Flex align="center" justify="between">
<Flex align="center" gap="2"> <Flex align="center" gap="2">
<Activity color={overallColor} size={18} /> <Activity
color={isLoading ? undefined : overallColor}
size={18}
className={isLoading ? "animate-pulse" : ""}
style={{
opacity: isLoading ? 0.3 : 1,
transition: "opacity 2s ease-in-out, color 2s ease-in-out",
}}
/>
<Text size="4">System Status</Text> <Text size="4">System Status</Text>
</Flex> </Flex>
{isLoading ? (
<Skeleton height="20px" width="80px" />
) : (
<StatusDisplay status={overallHealth} /> <StatusDisplay status={overallHealth} />
)}
</Flex> </Flex>
{/* Individual Services */} {/* Individual Services */}
<Flex direction="column" gap="3" style={{ marginTop: "16px" }}> <Flex direction="column" gap="3" style={{ marginTop: "16px" }}>
{services.map((service) => ( {shouldShowSkeleton
? // Show skeleton for 3 services during initial loading only
Array.from({ length: 3 }).map((_, index) => (
<SkeletonService key={index} />
))
: services.map((service) => (
<ServiceStatus key={service.name} service={service} /> <ServiceStatus key={service.name} service={service} />
))} ))}
</Flex> </Flex>
<Flex direction="column" gap="2" style={BORDER_STYLES}> <Flex direction="column" gap="2" style={BORDER_STYLES}>
{timing.health && ( {isLoading ? (
<TimingRow icon={Hourglass} name="Response Time"> <TimingRow icon={Hourglass} name="Response Time">
<Text size="2">{timing.health}ms</Text> <Skeleton height="18px" width="50px" />
</TimingRow> </TimingRow>
)} ) : shouldShowTiming ? (
<TimingRow icon={Hourglass} name="Response Time">
<Text size="2">{state.timing.health}ms</Text>
</TimingRow>
) : null}
{lastFetch && ( {shouldShowLastFetch ? (
<TimingRow icon={Clock} name="Last Updated"> <TimingRow icon={Clock} name="Last Updated">
{isLoading ? (
<Text
size="2"
style={{ paddingBottom: "2px" }}
color="gray"
>
Loading...
</Text>
) : (
<Tooltip <Tooltip
content={`as of ${lastFetch.toLocaleTimeString()}`} content={`as of ${state.lastFetch.toLocaleTimeString()}`}
> >
<abbr <abbr
style={{ style={{
@@ -230,34 +338,39 @@ function App() {
}} }}
> >
<Text size="2"> <Text size="2">
<TimeAgo date={lastFetch} /> <TimeAgo date={state.lastFetch} />
</Text> </Text>
</abbr> </abbr>
</Tooltip> </Tooltip>
</TimingRow>
)} )}
</TimingRow>
) : isLoading ? (
<TimingRow icon={Clock} name="Last Updated">
<Text size="2" color="gray">
Loading...
</Text>
</TimingRow>
) : null}
</Flex> </Flex>
</Flex> </Flex>
</Card> </Card>
)}
{(status?.commit || status?.version) && (
<Flex <Flex
justify="center" justify="center"
style={{ marginTop: "12px" }} style={{ marginTop: "12px" }}
gap="2" gap="2"
align="center" align="center"
> >
{status?.version && ( {__APP_VERSION__ && (
<Text <Text
size="1" size="1"
style={{ style={{
color: "#8B949E", color: "#8B949E",
}} }}
> >
v{status.version} v{__APP_VERSION__}
</Text> </Text>
)} )}
{status?.version && status?.commit && ( {__APP_VERSION__ && (
<div <div
style={{ style={{
width: "1px", width: "1px",
@@ -267,7 +380,6 @@ function App() {
}} }}
/> />
)} )}
{status?.commit && (
<Text <Text
size="1" size="1"
style={{ style={{
@@ -276,7 +388,11 @@ function App() {
}} }}
> >
<a <a
href={`https://github.com/Xevion/banner/commit/${status.commit}`} href={
hasResponse && state.status.commit
? `https://github.com/Xevion/banner/commit/${state.status.commit}`
: "https://github.com/Xevion/banner"
}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ style={{
@@ -287,9 +403,7 @@ function App() {
GitHub GitHub
</a> </a>
</Text> </Text>
)}
</Flex> </Flex>
)}
</Flex> </Flex>
</div> </div>
); );

3
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -2,6 +2,22 @@ import { defineConfig } from "vite";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import tanstackRouter from "@tanstack/router-plugin/vite"; import tanstackRouter from "@tanstack/router-plugin/vite";
import { resolve } from "node:path"; 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -29,4 +45,7 @@ export default defineConfig({
outDir: "dist", outDir: "dist",
sourcemap: true, sourcemap: true,
}, },
define: {
__APP_VERSION__: JSON.stringify(version),
},
}); });