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",
"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 { 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<Status, StatusIcon> = {
const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const statusMap: Record<Status | "Unreachable", StatusIcon> = {
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]) => ({
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 (
<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
const TimingRow = ({
icon: Icon,
@@ -146,40 +183,82 @@ const TimingRow = ({
);
function App() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [timing, setTiming] = useState<ResponseTiming>({
health: null,
status: null,
});
const [lastFetch, setLastFetch] = useState<Date | null>(null);
const [state, setState] = useState<StatusState>({ 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<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 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 (
<div className="App">
@@ -189,36 +268,65 @@ function App() {
justify="center"
style={{ minHeight: "100vh", padding: "20px" }}
>
{status && (
<Card style={CARD_STYLES}>
<Flex direction="column" gap="4">
{/* Overall Status */}
<Flex align="center" justify="between">
<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>
</Flex>
{isLoading ? (
<Skeleton height="20px" width="80px" />
) : (
<StatusDisplay status={overallHealth} />
)}
</Flex>
{/* Individual Services */}
<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} />
))}
</Flex>
<Flex direction="column" gap="2" style={BORDER_STYLES}>
{timing.health && (
{isLoading ? (
<TimingRow icon={Hourglass} name="Response Time">
<Text size="2">{timing.health}ms</Text>
<Skeleton height="18px" width="50px" />
</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">
{isLoading ? (
<Text
size="2"
style={{ paddingBottom: "2px" }}
color="gray"
>
Loading...
</Text>
) : (
<Tooltip
content={`as of ${lastFetch.toLocaleTimeString()}`}
content={`as of ${state.lastFetch.toLocaleTimeString()}`}
>
<abbr
style={{
@@ -230,34 +338,39 @@ function App() {
}}
>
<Text size="2">
<TimeAgo date={lastFetch} />
<TimeAgo date={state.lastFetch} />
</Text>
</abbr>
</Tooltip>
</TimingRow>
)}
</TimingRow>
) : isLoading ? (
<TimingRow icon={Clock} name="Last Updated">
<Text size="2" color="gray">
Loading...
</Text>
</TimingRow>
) : null}
</Flex>
</Flex>
</Card>
)}
{(status?.commit || status?.version) && (
<Flex
justify="center"
style={{ marginTop: "12px" }}
gap="2"
align="center"
>
{status?.version && (
{__APP_VERSION__ && (
<Text
size="1"
style={{
color: "#8B949E",
}}
>
v{status.version}
v{__APP_VERSION__}
</Text>
)}
{status?.version && status?.commit && (
{__APP_VERSION__ && (
<div
style={{
width: "1px",
@@ -267,7 +380,6 @@ function App() {
}}
/>
)}
{status?.commit && (
<Text
size="1"
style={{
@@ -276,7 +388,11 @@ function App() {
}}
>
<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"
rel="noopener noreferrer"
style={{
@@ -287,9 +403,7 @@ function App() {
GitHub
</a>
</Text>
)}
</Flex>
)}
</Flex>
</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 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),
},
});