feat: setup pretty frontend for system status

This commit is contained in:
2025-09-13 17:49:35 -05:00
parent 2e0fefa5ee
commit 64449e8976
5 changed files with 234 additions and 97 deletions

View File

@@ -18,6 +18,7 @@
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-timeago": "^8.3.0",
"recharts": "^3.2.0" "recharts": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {

12
web/pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.1.1(react@19.1.1) version: 19.1.1(react@19.1.1)
react-timeago:
specifier: ^8.3.0
version: 8.3.0(react@19.1.1)
recharts: recharts:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react-is@17.0.2)(react@19.1.1)(redux@5.0.1) version: 3.2.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react-is@17.0.2)(react@19.1.1)(redux@5.0.1)
@@ -1977,6 +1980,11 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-timeago@8.3.0:
resolution: {integrity: sha512-BeR0hj/5qqTc2+zxzBSQZMky6MmqwOtKseU3CSmcjKR5uXerej2QY34v2d+cdz11PoeVfAdWLX+qjM/UdZkUUg==}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@19.1.1: react@19.1.1:
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4269,6 +4277,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.13 '@types/react': 19.1.13
react-timeago@8.3.0(react@19.1.1):
dependencies:
react: 19.1.1
react@19.1.1: {} react@19.1.1: {}
readdirp@3.6.0: readdirp@3.6.0:

View File

@@ -1,38 +1,7 @@
.App { .App {
text-align: center; background-color: #f8fafc;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh; min-height: 100vh;
display: flex; font-family:
flex-direction: column; -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
align-items: center; "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }

View File

@@ -1,22 +1,24 @@
import { Outlet, createRootRoute } from '@tanstack/react-router' import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanstackDevtools } from '@tanstack/react-devtools' import { TanstackDevtools } from "@tanstack/react-devtools";
import { Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => ( component: () => (
<> <Theme appearance="light" accentColor="blue" grayColor="gray">
<Outlet /> <Outlet />
<TanstackDevtools <TanstackDevtools
config={{ config={{
position: 'bottom-left', position: "bottom-left",
}} }}
plugins={[ plugins={[
{ {
name: 'Tanstack Router', name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />, render: <TanStackRouterDevtoolsPanel />,
}, },
]} ]}
/> />
</> </Theme>
), ),
}) });

View File

@@ -1,87 +1,240 @@
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 } from "../lib/api";
import { Card, Flex, Text, Tooltip } from "@radix-ui/themes";
import { import {
apiClient, CheckCircle,
type HealthResponse, AlertCircle,
type StatusResponse, XCircle,
} from "../lib/api"; Clock,
import logo from "../logo.svg"; Bot,
Database,
Globe,
Hourglass,
Activity,
} from "lucide-react";
import TimeAgo from "react-timeago";
import "../App.css"; import "../App.css";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: App, component: App,
}); });
// Constants
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
const CARD_STYLES = {
padding: "24px",
maxWidth: "400px",
width: "100%",
} as const;
const BORDER_STYLES = {
marginTop: "16px",
paddingTop: "16px",
borderTop: "1px solid #e2e8f0",
} as const;
// Types
type HealthStatus = "healthy" | "warning" | "error" | "unknown";
type ServiceStatus = "running" | "connected" | "disconnected" | "error";
interface ResponseTiming {
health: number | null;
status: number | null;
}
interface StatusIcon {
icon: typeof CheckCircle;
color: string;
}
interface Service {
name: string;
status: ServiceStatus;
icon: typeof Bot;
}
// Helper functions
const getStatusIcon = (status: string): StatusIcon => {
const statusMap: Record<string, StatusIcon> = {
healthy: { icon: CheckCircle, color: "green" },
running: { icon: CheckCircle, color: "green" },
connected: { icon: CheckCircle, color: "green" },
warning: { icon: AlertCircle, color: "orange" },
error: { icon: XCircle, color: "red" },
disconnected: { icon: XCircle, color: "red" },
};
return statusMap[status] || { icon: XCircle, color: "red" };
};
const getOverallHealth = (
status: StatusResponse | null,
error: string | null
): HealthStatus => {
if (error) return "error";
if (!status) return "unknown";
const allHealthy =
status.bot.status === "running" &&
status.cache.status === "connected" &&
status.banner_api.status === "connected";
return allHealthy ? "healthy" : "warning";
};
const getServices = (status: StatusResponse | null): Service[] => {
if (!status) return [];
return [
{ name: "Bot", status: status.bot.status as ServiceStatus, icon: Bot },
{
name: "Cache",
status: status.cache.status as ServiceStatus,
icon: Database,
},
{
name: "Banner API",
status: status.banner_api.status as ServiceStatus,
icon: Globe,
},
];
};
// Service Status Component
const ServiceStatus = ({ service }: { service: Service }) => {
const { icon: Icon, color } = getStatusIcon(service.status);
return (
<Flex align="center" justify="between">
<Flex align="center" gap="2">
<service.icon size={18} />
<Text>{service.name}</Text>
</Flex>
<Flex align="center" gap="2">
<Icon color={color} size={16} />
<Text size="2">{service.status}</Text>
</Flex>
</Flex>
);
};
// Timing Row Component
const TimingRow = ({
icon: Icon,
name,
children,
}: {
icon: React.ComponentType<{ size?: number }>;
name: string;
children: React.ReactNode;
}) => (
<Flex align="center" justify="between">
<Flex align="center" gap="2">
<Icon size={13} />
<Text size="2" color="gray">
{name}
</Text>
</Flex>
{children}
</Flex>
);
function App() { function App() {
const [health, setHealth] = useState<HealthResponse | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null); const [status, setStatus] = useState<StatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | 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);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); const startTime = Date.now();
const [healthData, statusData] = await Promise.all([ const statusData = await apiClient.getStatus();
apiClient.getHealth(), const endTime = Date.now();
apiClient.getStatus(), const responseTime = endTime - startTime;
]);
setHealth(healthData);
setStatus(statusData); setStatus(statusData);
setTiming({ health: responseTime, status: responseTime });
setLastFetch(new Date());
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch data"); setError(err instanceof Error ? err.message : "Failed to fetch data");
} finally { setLastFetch(new Date());
setLoading(false);
} }
}; };
fetchData(); fetchData();
const interval = setInterval(fetchData, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, []); }, []);
const overallHealth = getOverallHealth(status, error);
const { color: overallColor } = getStatusIcon(overallHealth);
const services = getServices(status);
return ( return (
<div className="App"> <div className="App">
<header className="App-header"> <Flex
<img src={logo} className="App-logo" alt="logo" /> direction="column"
<h1>Banner Discord Bot Dashboard</h1> align="center"
justify="center"
{loading && <p>Loading...</p>} style={{ minHeight: "100vh", padding: "20px" }}
{error && (
<div style={{ color: "red", margin: "20px 0" }}>
<p>Error: {error}</p>
</div>
)}
{health && (
<div style={{ margin: "20px 0", textAlign: "left" }}>
<h3>Health Status</h3>
<p>Status: {health.status}</p>
<p>Timestamp: {new Date(health.timestamp).toLocaleString()}</p>
</div>
)}
{status && (
<div style={{ margin: "20px 0", textAlign: "left" }}>
<h3>System Status</h3>
<p>Overall: {status.status}</p>
<p>Bot: {status.bot.status}</p>
<p>Cache: {status.cache.status}</p>
<p>Banner API: {status.banner_api.status}</p>
</div>
)}
<div style={{ marginTop: "40px" }}>
<a
className="App-link"
href="https://tanstack.com"
target="_blank"
rel="noopener noreferrer"
> >
Learn TanStack Router {status && (
</a> <Card style={CARD_STYLES}>
</div> <Flex direction="column" gap="4">
</header> {/* Overall Status */}
<Flex align="center" justify="between">
<Flex align="center" gap="2">
<Activity color={overallColor} size={18} />
<Text size="4">System Status</Text>
</Flex>
</Flex>
{/* Individual Services */}
<Flex direction="column" gap="3" style={{ marginTop: "16px" }}>
{services.map((service) => (
<ServiceStatus key={service.name} service={service} />
))}
</Flex>
<Flex direction="column" gap="2" style={BORDER_STYLES}>
{timing.health && (
<TimingRow icon={Hourglass} name="Response Time">
<Text size="2">{timing.health}ms</Text>
</TimingRow>
)}
{lastFetch && (
<TimingRow icon={Clock} name="Last Updated">
<Tooltip
content={`as of ${lastFetch.toLocaleTimeString()}`}
>
<abbr
style={{
cursor: "pointer",
textDecoration: "underline",
textDecorationStyle: "dotted",
textDecorationColor: "#CBCED1",
textUnderlineOffset: "6px",
}}
>
<Text size="2">
<TimeAgo date={lastFetch} />
</Text>
</abbr>
</Tooltip>
</TimingRow>
)}
</Flex>
</Flex>
</Card>
)}
</Flex>
</div> </div>
); );
} }