diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7e9d388 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +public/app.js +public/app.css +npm-debug.log* diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d596df9 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React device dashboard + +Minimal manager-facing frontend for the RPi-Monitoring `GET /devices` endpoint. + +## Develop and build + +From `frontend/` (Node 22 or newer): + +```sh +npm install +npm test +npm run build +python -m http.server 8080 --directory public +``` + +Open http://localhost:8080. API defaults to http://localhost:8000. +Build-time override: + +```sh +REACT_APP_API_BASE=https://monitor.example.com npm run build +``` + +On Windows PowerShell: `$env:REACT_APP_API_BASE="http://localhost:8000"; npm run build`. + +**Browser CORS:** A separate frontend origin such as localhost:8080 requires +the FastAPI server to allow that origin or a same-origin reverse proxy. +This frontend does not disable browser security or send credentials. + +The application is read-only. It handles initial loading, empty devices, +HTTP errors, manual refresh, responsive layout and online/offline badges. +`npm test` runs 7 isolated API tests with no hardware or backend. +Generated `public/app.js` and `public/app.css` are gitignored. diff --git a/frontend/build.mjs b/frontend/build.mjs new file mode 100644 index 0000000..d31696b --- /dev/null +++ b/frontend/build.mjs @@ -0,0 +1,12 @@ +import { build } from "esbuild"; +const apiBase = process.env.REACT_APP_API_BASE || "http://localhost:8000"; +await build({ + entryPoints: ["src/index.jsx"], + outfile: "public/app.js", + bundle: true, + minify: true, + target: ["es2020"], + define: { "process.env.REACT_APP_API_BASE": JSON.stringify(apiBase) }, + logLevel: "info", +}); +console.log(`Built dashboard with API: ${apiBase}`); diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..edf6f12 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,530 @@ +{ + "name": "rpi-monitoring-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpi-monitoring-dashboard", + "version": "0.1.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a403754 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1 @@ +{"name":"rpi-monitoring-dashboard","version":"0.1.0","private":true,"type":"module","scripts":{"build":"node build.mjs","test":"node --test tests/*.test.mjs"},"dependencies":{"react":"^19.2.0","react-dom":"^19.2.0"},"devDependencies":{"esbuild":"^0.25.0"}} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..e7ba6fc --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,5 @@ + + +RPi Monitor — Device Dashboard + +
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..abca0e9 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,59 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { fetchDevices } from "./api.js"; + +export default function App() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [updated, setUpdated] = useState(null); + + const refresh = useCallback(async ({ signal } = {}) => { + setLoading(true); + setError(""); + try { + const rows = await fetchDevices({ signal }); + if (!signal?.aborted) { + setDevices(rows); + setUpdated(new Date()); + } + } catch (cause) { + if (!signal?.aborted) setError(cause instanceof Error ? cause.message : "Unable to fetch devices"); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + refresh({ signal: controller.signal }); + return () => controller.abort(); + }, [refresh]); + + const online = devices.filter((row) => row.alive).length; + return
+
+

RPi NETWORK MONITORING

Device dashboard

+

Latest known connectivity of your Raspberry Pis

+ +
+
+
Total{devices.length}
+
Online{online}
+
Offline{devices.length - online}
+
+
+

All devices

+

{updated ? `Last updated: ${updated.toLocaleTimeString()}` : "Awaiting first refresh"}

+ {error &&

{error}

} + {loading && devices.length === 0 ?

Loading devices…

+ : devices.length === 0 ?

{error ? "Data unavailable." : "No devices discovered yet."}

+ :
+ {devices.map((row) => + + + )}
DeviceIP addressStatus
{row.name}{row.ip} + {row.alive ? "Online" : "Offline"}
} +
+
; +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..96753d7 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,24 @@ +/** Environment variable REACT_APP_API_BASE is embedded by the build script. */ +export const DEFAULT_API_BASE = process.env.REACT_APP_API_BASE || "http://localhost:8000"; + +export async function fetchDevices({ apiBase = DEFAULT_API_BASE, fetchImpl = globalThis.fetch, signal } = {}) { + if (typeof fetchImpl !== "function") throw new TypeError("fetch unavailable"); + const response = await fetchImpl(`${apiBase.replace(/\/+$/, "")}/devices`, { + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) throw new Error(`Device API error HTTP ${response.status}`); + const rows = await response.json(); + if (!Array.isArray(rows)) throw new TypeError("Device API must return an array"); + return rows.map((row) => { + if (!row || typeof row !== "object" || typeof row.ip !== "string") { + throw new TypeError("Malformed device in API response"); + } + return { + id: row.id, + ip: row.ip, + name: row.name || "Unnamed device", + alive: Boolean(row.alive), + }; + }); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..1612256 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0b1120;color:#e2e8f0}*{box-sizing:border-box}body{margin:0;min-height:100vh}button{font:inherit}.layout{max-width:1100px;padding:44px 20px;margin:auto}.topbar{display:flex;gap:24px;justify-content:space-between;align-items:center}.eyebrow{font-size:.76rem;font-weight:700;color:#7dd3fc;letter-spacing:.12em}h1{font-size:clamp(1.8rem,4vw,2.7rem);margin:10px 0}h2{font-size:1.12rem;margin:0}.muted{color:#94a3b8;font-size:.92rem}.refresh{border:1px solid #64748b;background:#1e293b;color:#e2e8f0;border-radius:9px;padding:12px 18px;cursor:pointer}.refresh:disabled{opacity:.6;cursor:default}.refresh:focus-visible{outline:3px solid #7dd3fc}.summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px;margin:32px 0}.stat,.panel{border:1px solid #334155;background:#17233a;border-radius:13px}.stat{padding:22px}.stat span{color:#94a3b8;display:block}.stat strong{font-size:2.3rem;display:block;margin-top:9px}.online{color:#86efac}.offline{color:#fda4af}.panel{overflow:hidden}.panel-heading{display:flex;justify-content:space-between;align-items:baseline;gap:18px;padding:24px}table{width:100%;border-collapse:collapse;text-align:left}th,td{border-top:1px solid #334155;padding:17px 24px}th{font-size:.78rem;text-transform:uppercase;letter-spacing:.06em;color:#94a3b8}.mono{font-family:ui-monospace,Consolas,monospace}.pill{display:inline-block;border-radius:30px;font-size:.82rem;font-weight:700;padding:6px 12px}.up{background:#064e3b;color:#a7f3d0}.down{background:#881337;color:#fecdd3}.error{margin:0 24px 10px;padding:12px;border-radius:8px;background:#881337;color:#fecdd3}.message{padding:0 24px 20px;color:#94a3b8}.table-wrap{overflow-x:auto}@media(max-width:650px){.topbar,.panel-heading{align-items:flex-start;flex-direction:column}.stat{padding:14px}.stat strong{font-size:1.8rem}.layout{padding:24px 12px}.summary{gap:6px}th,td{padding:12px}} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx new file mode 100644 index 0000000..940bf68 --- /dev/null +++ b/frontend/src/index.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./index.css"; + +createRoot(document.getElementById("root")).render( + +); diff --git a/frontend/tests/api.test.mjs b/frontend/tests/api.test.mjs new file mode 100644 index 0000000..98665ab --- /dev/null +++ b/frontend/tests/api.test.mjs @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fetchDevices } from "../src/api.js"; + +test("normalizes server data and strips API base trailing slash", async () => { + const rows = await fetchDevices({ + apiBase: "http://localhost:8000/", + fetchImpl: async (url, options) => { + assert.equal(url, "http://localhost:8000/devices"); + assert.equal(options.headers.Accept, "application/json"); + return { ok: true, json: async () => [ + { id: 1, ip: "192.0.2.1", name: "Pi", alive: true }, + { id: 2, ip: "192.0.2.2", name: null, alive: 0 }, + ] }; + }, + }); + assert.deepEqual(rows, [ + { id: 1, ip: "192.0.2.1", name: "Pi", alive: true }, + { id: 2, ip: "192.0.2.2", name: "Unnamed device", alive: false }, + ]); +}); +test("accepts empty records", async () => assert.deepEqual(await fetchDevices({ + fetchImpl: async () => ({ ok: true, json: async () => [] }), +}), [])); +test("reports HTTP error", async () => assert.rejects(fetchDevices({ + fetchImpl: async () => ({ ok: false, status: 503 }), +}), /503/)); +test("rejects malformed outer payload", async () => assert.rejects(fetchDevices({ + fetchImpl: async () => ({ ok: true, json: async () => ({ devices: [] }) }), +}), /array/)); +test("rejects malformed entries", async () => assert.rejects(fetchDevices({ + fetchImpl: async () => ({ ok: true, json: async () => [{ id: 1 }] }), +}), /Malformed/)); +test("passes abort signal", async () => { + const controller = new AbortController(); + await fetchDevices({ signal: controller.signal, fetchImpl: async (_, opts) => { + assert.equal(opts.signal, controller.signal); + return { ok: true, json: async () => [] }; + } }); +}); +test("requires fetch implementation", async () => assert.rejects(fetchDevices({ + fetchImpl: null, +}), /unavailable/));