mirror of
https://github.com/Xevion/banner.git
synced 2025-12-06 23:14:22 -06:00
feat: setup pretty frontend for system status
This commit is contained in:
@@ -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
12
web/pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user