diff --git a/apps/backend/src/beszel.ts b/apps/backend/src/beszel.ts new file mode 100644 index 0000000..d5fd540 --- /dev/null +++ b/apps/backend/src/beszel.ts @@ -0,0 +1,76 @@ +import { Hono } from "hono" +import { type BeszelContext, beszelAuth } from "./beszel/middleware" + +const beszel = new Hono() + +// 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 diff --git a/apps/backend/src/beszel/middleware.ts b/apps/backend/src/beszel/middleware.ts new file mode 100644 index 0000000..dcf113e --- /dev/null +++ b/apps/backend/src/beszel/middleware.ts @@ -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 => { + 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 + } +} diff --git a/apps/backend/src/env.d.ts b/apps/backend/src/env.d.ts index a2827f3..73750d5 100644 --- a/apps/backend/src/env.d.ts +++ b/apps/backend/src/env.d.ts @@ -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 } } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6b68f7a..bd0a8a7 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c27daea..1c087e1 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 6c80810..dff38ee 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -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() { + + ) } @@ -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(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 ( + +

No system status available

+
+ ) + } + + return ( + +
+
+

{displayName}

+
+
+
+

CPU

+

{beszelSystemsData.info.cpu.toFixed(0).padStart(3, "0")}

+
+
+

RAM

+

{beszelSystemsData.info.ram.toFixed(0).padStart(3, "0")}

+
+
+

DSK

+

{beszelSystemsData.info.disk.toFixed(0).padStart(3, "0")}

+
+
+
+ +
+ + ) +} + export default App diff --git a/apps/dashboard/src/beszel.ts b/apps/dashboard/src/beszel.ts new file mode 100644 index 0000000..9f4a1c3 --- /dev/null +++ b/apps/dashboard/src/beszel.ts @@ -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 => { + 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" +} diff --git a/apps/dashboard/src/tfl.ts b/apps/dashboard/src/tfl.ts index f99b20f..024829e 100644 --- a/apps/dashboard/src/tfl.ts +++ b/apps/dashboard/src/tfl.ts @@ -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", diff --git a/bun.lock b/bun.lock index ad9900e..eb3b3bb 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "version": "1.0.0", "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", @@ -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=="], + "@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.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=="], + "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=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],