feat(dashboard): impl server monitoring
This commit is contained in:
76
apps/backend/src/beszel.ts
Normal file
76
apps/backend/src/beszel.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Hono } from "hono"
|
||||
import { type BeszelContext, beszelAuth } from "./beszel/middleware"
|
||||
|
||||
const beszel = new Hono<BeszelContext>()
|
||||
|
||||
// Apply middleware to all beszel routes
|
||||
beszel.use("*", beszelAuth())
|
||||
|
||||
interface BeszelSystemInfo {
|
||||
name: string
|
||||
info: {
|
||||
cpu: number
|
||||
ram: number
|
||||
disk: number
|
||||
}
|
||||
}
|
||||
|
||||
interface BeszelApiSystem {
|
||||
name: string
|
||||
info: {
|
||||
cpu: number
|
||||
mp: number // memory percentage
|
||||
dp: number // disk percentage
|
||||
}
|
||||
}
|
||||
|
||||
beszel.get("/systems", async (c) => {
|
||||
try {
|
||||
const beszelHost = process.env.BESZEL_HOST
|
||||
const token = c.get("beszelToken")
|
||||
|
||||
if (!beszelHost) {
|
||||
return c.json({ error: "BESZEL_HOST environment variable not set" }, 500)
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${beszelHost}/api/collections/systems/records`, {
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to fetch Beszel data",
|
||||
status: response.status,
|
||||
}),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { items: BeszelApiSystem[] }
|
||||
|
||||
const systems: BeszelSystemInfo[] = data.items.map((system) => ({
|
||||
name: system.name,
|
||||
info: {
|
||||
cpu: system.info.cpu,
|
||||
ram: system.info.mp,
|
||||
disk: system.info.dp,
|
||||
},
|
||||
}))
|
||||
|
||||
return c.json({
|
||||
lastUpdated: new Date().toISOString(),
|
||||
systems,
|
||||
totalSystems: systems.length,
|
||||
})
|
||||
} catch (error) {
|
||||
return c.json({ error: "Internal server error", message: String(error) }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
export default beszel
|
||||
61
apps/backend/src/beszel/middleware.ts
Normal file
61
apps/backend/src/beszel/middleware.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
|
||||
interface BeszelAuthResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export function beszelAuth(): MiddlewareHandler {
|
||||
let cachedToken: string | null = null
|
||||
|
||||
const authenticate = async (): Promise<string> => {
|
||||
if (cachedToken) {
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
const beszelHost = process.env.BESZEL_HOST
|
||||
const beszelEmail = process.env.BESZEL_EMAIL
|
||||
const beszelPassword = process.env.BESZEL_PASSWORD
|
||||
|
||||
if (!beszelHost || !beszelEmail || !beszelPassword) {
|
||||
throw new Error(
|
||||
"Beszel configuration missing. Set BESZEL_HOST, BESZEL_EMAIL, and BESZEL_PASSWORD environment variables.",
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${beszelHost}/api/collections/users/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identity: beszelEmail,
|
||||
password: beszelPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Beszel authentication failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse
|
||||
cachedToken = data.token
|
||||
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
return async (c, next) => {
|
||||
try {
|
||||
const token = await authenticate()
|
||||
c.set("beszelToken", token)
|
||||
await next()
|
||||
} catch (error) {
|
||||
return c.json({ error: "Authentication failed", message: String(error) }, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type BeszelContext = {
|
||||
Variables: {
|
||||
beszelToken: string
|
||||
}
|
||||
}
|
||||
3
apps/backend/src/env.d.ts
vendored
3
apps/backend/src/env.d.ts
vendored
@@ -5,5 +5,8 @@ declare namespace NodeJS {
|
||||
ADP_KEY_ID: string
|
||||
ADP_KEY_PATH: string
|
||||
GEMINI_API_KEY: string
|
||||
BESZEL_HOST?: string
|
||||
BESZEL_EMAIL?: string
|
||||
BESZEL_PASSWORD?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cors } from "hono/cors"
|
||||
import { logger } from "hono/logger"
|
||||
import weather from "./weather"
|
||||
import tfl from "./tfl"
|
||||
import beszel from "./beszel"
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
@@ -23,6 +24,9 @@ app.route("/api/weather", weather)
|
||||
// Mount TfL routes
|
||||
app.route("/api/tfl", tfl)
|
||||
|
||||
// Mount Beszel routes
|
||||
app.route("/api/beszel", beszel)
|
||||
|
||||
export default {
|
||||
port: 8000,
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"jotai": "^2.10.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Fragment, useEffect, useState } from "react"
|
||||
import Chart from "chart.js/auto"
|
||||
import { Fragment, useEffect, useId, useLayoutEffect, useRef, useState } from "react"
|
||||
import { beszelSystemsQuery } from "./beszel"
|
||||
import cn from "./components/lib/cn"
|
||||
import { StatusSeverity, formatLineName, getLineColor, getStatusBorderColor, tflDisruptionsQuery } from "./tfl"
|
||||
import {
|
||||
@@ -17,6 +19,8 @@ function App() {
|
||||
<DateTimeTile />
|
||||
<WeatherTile />
|
||||
<TFLTile />
|
||||
<SystemTile systemName="helian" displayName="Helian" />
|
||||
<SystemTile systemName="akira" displayName="Akira" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -297,4 +301,119 @@ function TFLDistruptionItem({ lineId, reason, severity }: { lineId: string; reas
|
||||
)
|
||||
}
|
||||
|
||||
function SystemTile({
|
||||
className,
|
||||
systemName,
|
||||
displayName,
|
||||
}: { className?: string; systemName: string; displayName: string }) {
|
||||
const { data } = useQuery({
|
||||
...beszelSystemsQuery(),
|
||||
refetchInterval: 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
const chartRef = useRef<Chart | null>(null)
|
||||
|
||||
const beszelSystemsData = data?.systems.find((system) => system.name === systemName)
|
||||
|
||||
const onCanvasRef = (elem: HTMLCanvasElement | null) => {
|
||||
if (!elem || chartRef.current) return
|
||||
|
||||
const fillGradient = elem?.getContext("2d")?.createLinearGradient(0, 0, 0, elem.height)
|
||||
fillGradient?.addColorStop(0, "#2dd4bf")
|
||||
fillGradient?.addColorStop(0.5, "rgba(45, 212, 191, 0)")
|
||||
fillGradient?.addColorStop(1, "rgba(45, 212, 191, 0)")
|
||||
chartRef.current = new Chart(elem, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: Array.from({ length: 20 }, (_, index) => index),
|
||||
datasets: [
|
||||
{
|
||||
data: Array.from({ length: 20 }, (_, i) => null),
|
||||
fill: true,
|
||||
backgroundColor: fillGradient,
|
||||
borderColor: "#2dd4bf",
|
||||
tension: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false, min: 0, max: 100 },
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
elements: {
|
||||
point: { radius: 0 },
|
||||
line: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log("chartRef.current", chartRef.current)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const cpu = beszelSystemsData?.info.cpu
|
||||
if (!chartRef.current || cpu === undefined) return
|
||||
|
||||
const dataset = chartRef.current.data.datasets[0]
|
||||
|
||||
const nextData = Array.from({ length: 20 }, (_, i) => {
|
||||
if (i === 19) {
|
||||
return null
|
||||
}
|
||||
return dataset.data[i + 1]
|
||||
})
|
||||
nextData[19] = cpu
|
||||
|
||||
dataset.data = nextData
|
||||
chartRef.current.update()
|
||||
})
|
||||
|
||||
if (!beszelSystemsData) {
|
||||
return (
|
||||
<Tile className={cn("h-full row-start-2 flex flex-row justify-start items-center p-8", className)}>
|
||||
<p className="text-2xl font-light">No system status available</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile
|
||||
decorations={false}
|
||||
className={cn("h-full row-start-2 flex flex-col justify-start items-start", className)}
|
||||
>
|
||||
<div className="grid grid-cols-6 px-4 pt-3 w-full">
|
||||
<div className="col-span-3 flex flex-row items-center space-x-2">
|
||||
<p className="text-2xl">{displayName}</p>
|
||||
<div className="size-2 border border-green-300 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col font-mono">
|
||||
<p className="text-neutral-400 text-right leading-none">CPU</p>
|
||||
<p className="text-right">{beszelSystemsData.info.cpu.toFixed(0).padStart(3, "0")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col font-mono">
|
||||
<p className="text-neutral-400 text-right leading-none">RAM</p>
|
||||
<p className="text-right">{beszelSystemsData.info.ram.toFixed(0).padStart(3, "0")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col font-mono">
|
||||
<p className="text-neutral-400 text-right leading-none">DSK</p>
|
||||
<p className="text-right">{beszelSystemsData.info.disk.toFixed(0).padStart(3, "0")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 min-w-0 basis-0 relative mb-2">
|
||||
<canvas ref={onCanvasRef} className="min-h-0 absolute top-0 left-0 w-full h-full" />
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
67
apps/dashboard/src/beszel.ts
Normal file
67
apps/dashboard/src/beszel.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Beszel System Stats API TypeScript Types
|
||||
* For server monitoring and system statistics
|
||||
*/
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
||||
|
||||
// System Info
|
||||
export interface BeszelSystemInfo {
|
||||
name: string
|
||||
info: {
|
||||
cpu: number
|
||||
ram: number
|
||||
disk: number
|
||||
}
|
||||
}
|
||||
|
||||
// Systems Response
|
||||
export interface BeszelSystemsResponse {
|
||||
lastUpdated: string
|
||||
systems: BeszelSystemInfo[]
|
||||
totalSystems: number
|
||||
}
|
||||
|
||||
// TanStack Query Options
|
||||
|
||||
/**
|
||||
* Query options for fetching Beszel system stats
|
||||
* Returns CPU, RAM, and disk usage for all monitored systems
|
||||
*/
|
||||
export function beszelSystemsQuery() {
|
||||
return queryOptions({
|
||||
queryKey: ["beszel", "systems"],
|
||||
queryFn: async (): Promise<BeszelSystemsResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/beszel/systems`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch Beszel system stats")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 5 * 1000, // 5 seconds (system stats update frequently)
|
||||
gcTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to format percentage
|
||||
export function formatPercentage(value: number): string {
|
||||
return `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
// Helper function to get usage color based on percentage
|
||||
export function getUsageColor(percentage: number): string {
|
||||
if (percentage >= 90) return "text-red-500"
|
||||
if (percentage >= 75) return "text-orange-500"
|
||||
if (percentage >= 50) return "text-yellow-500"
|
||||
return "text-green-500"
|
||||
}
|
||||
|
||||
// Helper function to get usage background color
|
||||
export function getUsageBackgroundColor(percentage: number): string {
|
||||
if (percentage >= 90) return "bg-red-500"
|
||||
if (percentage >= 75) return "bg-orange-500"
|
||||
if (percentage >= 50) return "bg-yellow-500"
|
||||
return "bg-green-500"
|
||||
}
|
||||
@@ -96,13 +96,13 @@ export function formatLineName(lineId: string): string {
|
||||
central: "Central",
|
||||
circle: "Circle",
|
||||
district: "District",
|
||||
"hammersmith-city": "Hammersmith & City",
|
||||
"hammersmith-city": "H&C",
|
||||
jubilee: "Jubilee",
|
||||
metropolitan: "Metropolitan",
|
||||
northern: "Northern",
|
||||
piccadilly: "Piccadilly",
|
||||
victoria: "Victoria",
|
||||
"waterloo-city": "Waterloo & City",
|
||||
"waterloo-city": "W&C",
|
||||
"london-overground": "London Overground",
|
||||
dlr: "DLR",
|
||||
"elizabeth-line": "Elizabeth Line",
|
||||
|
||||
Reference in New Issue
Block a user