feat(dashboard): impl server monitoring

This commit is contained in:
2025-10-25 01:09:53 +00:00
parent 189a6c4401
commit 220d25ccab
9 changed files with 339 additions and 3 deletions

View 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

View 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
}
}

View File

@@ -5,5 +5,8 @@ declare namespace NodeJS {
ADP_KEY_ID: string ADP_KEY_ID: string
ADP_KEY_PATH: string ADP_KEY_PATH: string
GEMINI_API_KEY: string GEMINI_API_KEY: string
BESZEL_HOST?: string
BESZEL_EMAIL?: string
BESZEL_PASSWORD?: string
} }
} }

View File

@@ -3,6 +3,7 @@ import { cors } from "hono/cors"
import { logger } from "hono/logger" import { logger } from "hono/logger"
import weather from "./weather" import weather from "./weather"
import tfl from "./tfl" import tfl from "./tfl"
import beszel from "./beszel"
const app = new Hono() const app = new Hono()
@@ -23,6 +24,9 @@ app.route("/api/weather", weather)
// Mount TfL routes // Mount TfL routes
app.route("/api/tfl", tfl) app.route("/api/tfl", tfl)
// Mount Beszel routes
app.route("/api/beszel", beszel)
export default { export default {
port: 8000, port: 8000,
fetch: app.fetch, fetch: app.fetch,

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.1",
"jotai": "^2.10.3", "jotai": "^2.10.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -1,5 +1,7 @@
import { useQuery } from "@tanstack/react-query" 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 cn from "./components/lib/cn"
import { StatusSeverity, formatLineName, getLineColor, getStatusBorderColor, tflDisruptionsQuery } from "./tfl" import { StatusSeverity, formatLineName, getLineColor, getStatusBorderColor, tflDisruptionsQuery } from "./tfl"
import { import {
@@ -17,6 +19,8 @@ function App() {
<DateTimeTile /> <DateTimeTile />
<WeatherTile /> <WeatherTile />
<TFLTile /> <TFLTile />
<SystemTile systemName="helian" displayName="Helian" />
<SystemTile systemName="akira" displayName="Akira" />
</div> </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 export default App

View 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"
}

View File

@@ -96,13 +96,13 @@ export function formatLineName(lineId: string): string {
central: "Central", central: "Central",
circle: "Circle", circle: "Circle",
district: "District", district: "District",
"hammersmith-city": "Hammersmith & City", "hammersmith-city": "H&C",
jubilee: "Jubilee", jubilee: "Jubilee",
metropolitan: "Metropolitan", metropolitan: "Metropolitan",
northern: "Northern", northern: "Northern",
piccadilly: "Piccadilly", piccadilly: "Piccadilly",
victoria: "Victoria", victoria: "Victoria",
"waterloo-city": "Waterloo & City", "waterloo-city": "W&C",
"london-overground": "London Overground", "london-overground": "London Overground",
dlr: "DLR", dlr: "DLR",
"elizabeth-line": "Elizabeth Line", "elizabeth-line": "Elizabeth Line",

View File

@@ -27,6 +27,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.1",
"jotai": "^2.10.3", "jotai": "^2.10.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -167,6 +168,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -279,6 +282,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],