diff --git a/web/package.json b/web/package.json index 0a85563..5195e6d 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "lucide-react": "^0.544.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-timeago": "^8.3.0", "recharts": "^3.2.0" }, "devDependencies": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 553ffc0..bf7251f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.1(react@19.1.1) + react-timeago: + specifier: ^8.3.0 + version: 8.3.0(react@19.1.1) recharts: 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) @@ -1977,6 +1980,11 @@ packages: '@types/react': 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: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -4269,6 +4277,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + react-timeago@8.3.0(react@19.1.1): + dependencies: + react: 19.1.1 + react@19.1.1: {} readdirp@3.6.0: diff --git a/web/src/App.css b/web/src/App.css index 74b5e05..cd57c5d 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,38 +1,7 @@ .App { - text-align: center; -} - -.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; + background-color: #f8fafc; min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - 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); - } + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index b000793..051500d 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,22 +1,24 @@ -import { Outlet, createRootRoute } from '@tanstack/react-router' -import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' -import { TanstackDevtools } from '@tanstack/react-devtools' +import { Outlet, createRootRoute } from "@tanstack/react-router"; +import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import { TanstackDevtools } from "@tanstack/react-devtools"; +import { Theme } from "@radix-ui/themes"; +import "@radix-ui/themes/styles.css"; export const Route = createRootRoute({ component: () => ( - <> + , }, ]} /> - + ), -}) +}); diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index bf418c2..9052188 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,87 +1,240 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState, useEffect } from "react"; +import { apiClient, type StatusResponse } from "../lib/api"; +import { Card, Flex, Text, Tooltip } from "@radix-ui/themes"; import { - apiClient, - type HealthResponse, - type StatusResponse, -} from "../lib/api"; -import logo from "../logo.svg"; + CheckCircle, + AlertCircle, + XCircle, + Clock, + Bot, + Database, + Globe, + Hourglass, + Activity, +} from "lucide-react"; +import TimeAgo from "react-timeago"; import "../App.css"; export const Route = createFileRoute("/")({ 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 = { + 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 ( + + + + {service.name} + + + + {service.status} + + + ); +}; + +// Timing Row Component +const TimingRow = ({ + icon: Icon, + name, + children, +}: { + icon: React.ComponentType<{ size?: number }>; + name: string; + children: React.ReactNode; +}) => ( + + + + + {name} + + + {children} + +); + function App() { - const [health, setHealth] = useState(null); const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [timing, setTiming] = useState({ + health: null, + status: null, + }); + const [lastFetch, setLastFetch] = useState(null); useEffect(() => { const fetchData = async () => { try { - setLoading(true); - const [healthData, statusData] = await Promise.all([ - apiClient.getHealth(), - apiClient.getStatus(), - ]); - setHealth(healthData); + const startTime = Date.now(); + const statusData = await apiClient.getStatus(); + const endTime = Date.now(); + const responseTime = endTime - startTime; + setStatus(statusData); + setTiming({ health: responseTime, status: responseTime }); + setLastFetch(new Date()); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch data"); - } finally { - setLoading(false); + setLastFetch(new Date()); } }; 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 (
-
- logo -

Banner Discord Bot Dashboard

- - {loading &&

Loading...

} - - {error && ( -
-

Error: {error}

-
- )} - - {health && ( -
-

Health Status

-

Status: {health.status}

-

Timestamp: {new Date(health.timestamp).toLocaleString()}

-
- )} - + {status && ( -
-

System Status

-

Overall: {status.status}

-

Bot: {status.bot.status}

-

Cache: {status.cache.status}

-

Banner API: {status.banner_api.status}

-
- )} + + + {/* Overall Status */} + + + + System Status + + - -
+ {/* Individual Services */} + + {services.map((service) => ( + + ))} + + + + {timing.health && ( + + {timing.health}ms + + )} + + {lastFetch && ( + + + + + + + + + + )} + + + + )} +
); }