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
+
+
+ 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."}
+ : | Device | IP address | Status |
+ {devices.map((row) =>
+ | {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/));