From f500db971b73acc048d1252b2a3373e9680a6c8d Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 19 Apr 2026 17:05:50 -0400 Subject: [PATCH] Initial commit: Infrastructure host tracking app Fastify + node:sqlite single-process app with vanilla JS UI for looking up hosts by hardware ID, hostname, or asset ID. Includes per-host network interface tracking, sites/rooms/server-types CRUD, Docker packaging, and a Gitea Actions workflow that runs tests then builds and pushes to gitea.thewrightserver.net/josh/infrastructure. Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 10 + .gitea/workflows/build.yml | 43 ++ .gitignore | 5 + Dockerfile | 13 + README.md | 41 ++ docker-compose.yml | 14 + package-lock.json | 1144 ++++++++++++++++++++++++++++++++++++ package.json | 19 + public/app.css | 431 ++++++++++++++ public/app.js | 872 +++++++++++++++++++++++++++ public/index.html | 120 ++++ src/db.js | 253 ++++++++ src/routes/hosts.js | 88 +++ src/routes/interfaces.js | 71 +++ src/routes/rooms.js | 69 +++ src/routes/server-types.js | 57 ++ src/routes/sites.js | 57 ++ src/schemas.js | 140 +++++ src/server.js | 81 +++ src/sqlite-errors.js | 30 + tests/helpers.js | 37 ++ tests/hosts.test.js | 128 ++++ tests/interfaces.test.js | 176 ++++++ tests/rooms.test.js | 57 ++ tests/server-types.test.js | 47 ++ tests/sites.test.js | 54 ++ 26 files changed, 4057 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.css create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 src/db.js create mode 100644 src/routes/hosts.js create mode 100644 src/routes/interfaces.js create mode 100644 src/routes/rooms.js create mode 100644 src/routes/server-types.js create mode 100644 src/routes/sites.js create mode 100644 src/schemas.js create mode 100644 src/server.js create mode 100644 src/sqlite-errors.js create mode 100644 tests/helpers.js create mode 100644 tests/hosts.test.js create mode 100644 tests/interfaces.test.js create mode 100644 tests/rooms.test.js create mode 100644 tests/server-types.test.js create mode 100644 tests/sites.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92d38d0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +data +.git +.gitea +.github +.claude +tests +*.md +.DS_Store +.env diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..57d595f --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,43 @@ +name: build-and-push + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: gitea.thewrightserver.net + username: josh + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + gitea.thewrightserver.net/josh/infrastructure:latest + gitea.thewrightserver.net/josh/infrastructure:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb8d383 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +*.log +.DS_Store +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f9b36d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . +RUN mkdir -p /app/data && chown -R app:app /app +USER app + +EXPOSE 3000 +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cf749b --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Infrastructure + +A small internal tool for tracking servers. Search hosts by hardware ID, hostname, or asset ID; manage where they live (site / room / position) and what kind of server they are. Browser UI plus a JSON API. + +## Run + +```sh +npm install +npm start +``` + +UI on `http://localhost:3000`, API at `http://localhost:3000/api`. + +## Test + +```sh +npm test +``` + +## Docker + +```sh +docker compose up --build +``` + +Data persists in the `infrastructure-data` named volume. + +## API + +| Method | Path | Notes | +|---|---|---| +| GET | `/api/hosts?q=` | Substring search across `hardware_id`, `hostname`, `asset_id`. Capped at 200 results. | +| GET | `/api/hosts/:id` | Fetch one. | +| POST | `/api/hosts` | Create. | +| PUT | `/api/hosts/:id` | Replace. | +| DELETE | `/api/hosts/:id` | Hard delete. | +| `*` | `/api/sites[/:id]` | Site CRUD. | +| `*` | `/api/rooms[/:id]` | Room CRUD (`?site_id=` filter on GET). | +| `*` | `/api/server-types[/:id]` | Server-type CRUD. | + +Errors: `{ error, details? }` with status 400 / 404 / 409 / 500. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..406c0ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + infrastructure: + image: gitea.thewrightserver.net/josh/infrastructure:latest + pull_policy: always + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + volumes: + - infrastructure-data:/app/data + environment: + - NODE_ENV=production + +volumes: + infrastructure-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0dcd75f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1144 @@ +{ + "name": "infrastructure", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "infrastructure", + "version": "0.1.0", + "dependencies": { + "@fastify/sensible": "^6.0.1", + "@fastify/static": "^8.0.3", + "fastify": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/sensible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz", + "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^2.0.1", + "vary": "^1.1.2" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d089a5 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "infrastructure", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "test": "node --test 'tests/**/*.test.js'" + }, + "dependencies": { + "@fastify/sensible": "^6.0.1", + "@fastify/static": "^8.0.3", + "fastify": "^5.1.0" + } +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..cce8949 --- /dev/null +++ b/public/app.css @@ -0,0 +1,431 @@ +:root { + --bg: #ffffff; + --fg: #111827; + --muted: #6b7280; + --border: #e5e7eb; + --border-strong: #d1d5db; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --danger: #dc2626; + --danger-bg: #fef2f2; + --row-hover: #f9fafb; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + --radius: 8px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +button { font-family: inherit; font-size: inherit; } +input, select { font-family: inherit; font-size: inherit; } + +/* Top bar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 10; +} +.brand { + font-weight: 600; + font-size: 16px; + letter-spacing: -0.01em; + color: inherit; + text-decoration: none; +} +.topbar-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Layout */ +main { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +/* Hero */ +.hero { + text-align: center; + padding: 64px 16px 32px; +} +.hero.compact { + text-align: left; + padding: 8px 0 16px; +} +.hero.compact .hero-title, +.hero.compact .hero-sub { display: none; } + +.hero-title { + margin: 0 0 8px; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; +} +.hero-sub { + margin: 0 0 28px; + color: var(--muted); +} +.search-row { + display: flex; + gap: 10px; + max-width: 640px; + margin: 0 auto; +} +.hero.compact .search-row { max-width: 100%; } + +.search-input { + flex: 1; + padding: 12px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + outline: none; + transition: border-color 0.1s, box-shadow 0.1s; +} +.search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +/* Buttons */ +.btn { + border: 1px solid transparent; + border-radius: var(--radius); + padding: 10px 16px; + cursor: pointer; + font-weight: 500; + white-space: nowrap; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} +.btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-ghost { + background: transparent; + border-color: var(--border-strong); + color: var(--fg); +} +.btn-ghost:hover { background: var(--row-hover); } +.btn-danger { + background: var(--danger); + color: #fff; +} +.btn-danger:hover { background: #b91c1c; } +.btn-sm { padding: 6px 10px; font-size: 13px; } +.btn-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + padding: 6px 8px; +} +.btn-link:hover { text-decoration: underline; } +.btn-link-danger { color: var(--danger); } + +/* Results */ +.results { margin-top: 8px; } +.table { + width: 100%; + border-collapse: collapse; + background: var(--bg); +} +.table th, .table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +.table th { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + background: var(--row-hover); +} +.table tbody tr:hover { background: var(--row-hover); } +.actions-col { width: 1%; white-space: nowrap; text-align: right; } +.actions-cell { text-align: right; white-space: nowrap; } + +.results-empty { + padding: 32px; + text-align: center; + color: var(--muted); + border: 1px dashed var(--border); + border-radius: var(--radius); + margin-top: 16px; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 48px 16px; + z-index: 50; + overflow-y: auto; +} +.modal-card { + background: var(--bg); + border-radius: var(--radius); + box-shadow: var(--shadow); + width: 100%; + max-width: 480px; +} +.modal-card-wide { max-width: 720px; } +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.modal-header h2 { + margin: 0; + font-size: 17px; + font-weight: 600; +} +.modal-close { + background: none; + border: none; + cursor: pointer; + font-size: 22px; + line-height: 1; + color: var(--muted); + padding: 0 4px; +} +.modal-close:hover { color: var(--fg); } + +/* Form */ +.form { padding: 20px; display: flex; flex-direction: column; gap: 14px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field span { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +.field input, .field select { + padding: 9px 10px; + border: 1px solid var(--border-strong); + border-radius: 6px; + outline: none; + background: var(--bg); +} +.field input:focus, .field select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} +.field-row { display: flex; gap: 12px; } +.field-row .field { flex: 1; } + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); + margin-top: 6px; + padding-top: 14px; +} + +/* Banners */ +.banner { + margin: 0 20px; + margin-top: 16px; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; +} +.banner-error { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid #fecaca; +} + +/* Inline confirm */ +.confirm-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +/* Tabs */ +.tabs { + display: flex; + gap: 4px; + padding: 0 20px; + border-bottom: 1px solid var(--border); +} +.tab { + background: none; + border: none; + padding: 10px 14px; + cursor: pointer; + color: var(--muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-weight: 500; +} +.tab:hover { color: var(--fg); } +.tab-active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Manage body */ +.manage-body { padding: 16px 20px 20px; } +.manage-list { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.manage-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.manage-row:last-child { border-bottom: none; } +.manage-row.add-row { background: var(--row-hover); } +.manage-row input, .manage-row select { + padding: 7px 9px; + border: 1px solid var(--border-strong); + border-radius: 6px; + outline: none; +} +.manage-row .grow { display: flex; gap: 8px; } +.manage-row .grow > * { flex: 1; } +.manage-row .label { color: var(--fg); } +.manage-row .label .muted { color: var(--muted); margin-right: 6px; } +.manage-empty { + padding: 20px; + color: var(--muted); + text-align: center; +} + +/* Detail page */ +.detail { padding: 8px 0 24px; max-width: 720px; } +.back-link { + display: inline-block; + color: var(--muted); + text-decoration: none; + font-size: 13px; + margin-bottom: 16px; +} +.back-link:hover { color: var(--accent); } + +.detail-title { + margin: 0 0 4px; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.01em; + word-break: break-all; +} +.detail-subtitle { + margin: 0 0 24px; + color: var(--muted); + font-size: 13px; +} + +.detail-list { + display: grid; + grid-template-columns: 160px 1fr; + gap: 0; + margin: 0 0 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.detail-list dt, +.detail-list dd { + margin: 0; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} +.detail-list dt { + background: var(--row-hover); + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + align-content: center; +} +.detail-list dt:nth-last-of-type(1), +.detail-list dd:nth-last-of-type(1) { border-bottom: none; } +.detail-list dd { word-break: break-word; } +.detail-list .empty { color: var(--muted); } + +.detail-actions { display: flex; gap: 10px; } + +/* Hostname link in results */ +.host-link { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} +.host-link:hover { text-decoration: underline; } + +/* Interfaces section on detail page */ +.interfaces { margin: 0 0 24px; } +.interfaces-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.interfaces-header h2 { + margin: 0; + font-size: 17px; + font-weight: 600; +} +.iface-edit-input { + width: 100%; + padding: 5px 7px; + border: 1px solid var(--border-strong); + border-radius: 4px; + outline: none; + font: inherit; +} +.iface-edit-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); +} +.iface-error { + color: var(--danger); + font-size: 12px; + padding: 4px 12px; +} +.muted { color: var(--muted); } + +[hidden] { display: none !important; } diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..b275cd0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,872 @@ +// ── API client ──────────────────────────────────────────────────────────────── + +const api = { + async req(method, path, body) { + const res = await fetch(`/api${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) return null; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `HTTP ${res.status}`); + err.status = res.status; + err.details = data.details; + throw err; + } + return data; + }, + hosts: { + search: (q) => api.req('GET', `/hosts${q ? `?q=${encodeURIComponent(q)}` : ''}`), + get: (id) => api.req('GET', `/hosts/${id}`), + getByHardwareId: (hwid) => api.req('GET', `/hosts/by-hardware-id/${encodeURIComponent(hwid)}`), + create: (h) => api.req('POST', '/hosts', h), + update: (id, h) => api.req('PUT', `/hosts/${id}`, h), + delete: (id) => api.req('DELETE', `/hosts/${id}`), + }, + sites: { + list: () => api.req('GET', '/sites'), + create: (name) => api.req('POST', '/sites', { name }), + update: (id, name) => api.req('PUT', `/sites/${id}`, { name }), + delete: (id) => api.req('DELETE', `/sites/${id}`), + }, + rooms: { + list: (siteId) => api.req('GET', `/rooms${siteId ? `?site_id=${siteId}` : ''}`), + create: (site_id, name) => api.req('POST', '/rooms', { site_id, name }), + update: (id, site_id, name) => api.req('PUT', `/rooms/${id}`, { site_id, name }), + delete: (id) => api.req('DELETE', `/rooms/${id}`), + }, + serverTypes: { + list: () => api.req('GET', '/server-types'), + create: (name) => api.req('POST', '/server-types', { name }), + update: (id, name) => api.req('PUT', `/server-types/${id}`, { name }), + delete: (id) => api.req('DELETE', `/server-types/${id}`), + }, + interfaces: { + list: (hostId) => api.req('GET', `/interfaces?host_id=${hostId}`), + create: (body) => api.req('POST', '/interfaces', body), + update: (id, body) => api.req('PUT', `/interfaces/${id}`, body), + delete: (id) => api.req('DELETE', `/interfaces/${id}`), + }, +}; + +const IFACE_FIELDS = ['name', 'mac_address', 'ip_address', 'subnet', 'link_speed']; +const IFACE_PLACEHOLDERS = { + name: 'eth0', + mac_address: 'aa:bb:cc:dd:ee:ff', + ip_address: '10.0.0.1', + subnet: '10.0.0.0/24', + link_speed: '1000/full', +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') node.className = v; + else if (k === 'dataset') Object.assign(node.dataset, v); + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); + else if (v === true) node.setAttribute(k, ''); + else if (v !== false && v != null) node.setAttribute(k, v); + } + for (const c of children.flat()) { + if (c == null || c === false) continue; + node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; +} + +function openModal(id) { + const m = document.getElementById(id); + m.hidden = false; +} +function closeModal(id) { + const m = document.getElementById(id); + m.hidden = true; +} + +// ── Routing ─────────────────────────────────────────────────────────────────── + +const heroEl = $('#hero'); +const resultsEl = $('#results'); +const detailEl = $('#detail'); +const resultsBody = $('#results-body'); +const resultsEmpty = $('#results-empty'); +const searchInput = $('#search-input'); + +let currentView = 'search'; +let currentHardwareId = null; + +function navigate(path, { replace = false } = {}) { + history[replace ? 'replaceState' : 'pushState']({}, '', path); + handleRoute(); +} + +function handleRoute() { + const m = /^\/hosts\/(.+)$/.exec(location.pathname); + if (m) showDetail(decodeURIComponent(m[1])); + else showSearch(); +} + +function showSearch() { + currentView = 'search'; + currentHardwareId = null; + detailEl.hidden = true; + heroEl.hidden = false; + // Preserve whatever results are already rendered (don't auto-trigger lookup) + const hasInput = searchInput.value.trim().length > 0; + heroEl.classList.toggle('compact', hasInput); + const hasContent = resultsBody.children.length > 0 || !resultsEmpty.hidden; + resultsEl.hidden = !(hasInput && hasContent); +} + +async function showDetail(hardwareId) { + currentView = 'detail'; + currentHardwareId = hardwareId; + heroEl.hidden = true; + resultsEl.hidden = true; + detailEl.hidden = false; + await renderDetail(hardwareId); +} + +window.addEventListener('popstate', handleRoute); + +// ── Lookup ──────────────────────────────────────────────────────────────────── + +async function runLookup() { + if (currentView !== 'search') return; + const q = searchInput.value.trim(); + if (!q) return; + + heroEl.classList.add('compact'); + resultsEl.hidden = false; + resultsBody.replaceChildren(); + resultsEmpty.hidden = false; + resultsEmpty.textContent = 'Looking up…'; + + let rows = []; + try { + rows = await api.hosts.search(q); + } catch (err) { + resultsEmpty.textContent = `Lookup failed: ${err.message}`; + return; + } + + if (rows.length === 1) { + // Clear the lookup-in-progress state so back-navigation lands clean + resultsEmpty.hidden = true; + resultsEl.hidden = true; + navigate(`/hosts/${encodeURIComponent(rows[0].hardware_id)}`); + return; + } + + if (rows.length === 0) { + resultsEmpty.textContent = 'Host not found.'; + return; + } + + renderResults(rows); +} + +function renderResults(rows) { + resultsBody.replaceChildren(); + resultsEmpty.hidden = rows.length > 0; + if (rows.length === 0) { + resultsEmpty.textContent = 'Host not found.'; + return; + } + for (const h of rows) { + resultsBody.appendChild(renderHostRow(h)); + } +} + +function renderHostRow(host) { + const link = el('a', { + class: 'host-link', + href: `/hosts/${encodeURIComponent(host.hardware_id)}`, + 'data-nav': '', + }, host.hostname); + const row = el('tr', + { dataset: { id: host.id } }, + el('td', {}, link), + el('td', {}, host.hardware_id), + el('td', {}, host.asset_id), + el('td', + { class: 'actions-cell' }, + el('button', { class: 'btn-link', onclick: () => openHostModal(host) }, 'Edit'), + el('button', + { class: 'btn-link btn-link-danger', onclick: (e) => beginDelete(e, host) }, + 'Delete'), + ), + ); + return row; +} + +function beginDelete(e, host) { + const cell = e.target.closest('td'); + const original = cell.cloneNode(true); + // re-wire the original's button handlers when we restore it + const restore = () => { + cell.replaceWith(original); + // rebind handlers on the restored cell + const [editBtn, delBtn] = original.querySelectorAll('button'); + editBtn.addEventListener('click', () => openHostModal(host)); + delBtn.addEventListener('click', (ev) => beginDelete(ev, host)); + }; + cell.replaceChildren( + el('span', { class: 'confirm-inline' }, + 'Delete this host?', + el('button', { + class: 'btn-link btn-link-danger', + onclick: async () => { + try { + await api.hosts.delete(host.id); + cell.closest('tr').remove(); + if (resultsBody.children.length === 0) { + resultsEmpty.hidden = false; + resultsEmpty.textContent = 'No hosts match.'; + } + } catch (err) { + alert(`Delete failed: ${err.message}`); + restore(); + } + }, + }, 'Yes'), + el('button', { class: 'btn-link', onclick: restore }, 'No'), + ), + ); +} + +$('#lookup-form').addEventListener('submit', (e) => { + e.preventDefault(); + runLookup(); +}); + +// ── Detail page ─────────────────────────────────────────────────────────────── + +async function renderDetail(hardwareId) { + detailEl.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…')); + let host; + try { + host = await api.hosts.getByHardwareId(hardwareId); + } catch (err) { + detailEl.replaceChildren( + el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search'), + el('div', { class: 'banner banner-error' }, + err.status === 404 ? 'Host not found.' : `Failed to load host: ${err.message}`), + ); + return; + } + + const back = el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search'); + const title = el('h1', { class: 'detail-title' }, host.hostname); + const subtitle = el('p', { class: 'detail-subtitle' }, + `${host.site_name} \u00b7 ${host.room_name}${host.position ? ` \u00b7 ${host.position}` : ''}`); + + const list = el('dl', { class: 'detail-list' }, + el('dt', {}, 'Hardware ID'), el('dd', {}, host.hardware_id), + el('dt', {}, 'Asset ID'), el('dd', {}, host.asset_id), + el('dt', {}, 'Site'), el('dd', {}, host.site_name), + el('dt', {}, 'Room'), el('dd', {}, host.room_name), + el('dt', {}, 'Position'), host.position + ? el('dd', {}, host.position) + : el('dd', { class: 'empty' }, '\u2014'), + el('dt', {}, 'Server Type'), el('dd', {}, host.server_type), + el('dt', {}, 'Created'), el('dd', {}, host.created_at), + el('dt', {}, 'Updated'), el('dd', {}, host.updated_at), + ); + + const actions = el('div', { class: 'detail-actions', id: 'detail-actions' }, + el('button', { class: 'btn btn-primary', onclick: () => openHostModal(host) }, 'Edit'), + el('button', { class: 'btn btn-ghost', onclick: () => beginDeleteFromDetail(host) }, 'Delete'), + ); + + const interfacesSection = await renderInterfacesSection(host); + + detailEl.replaceChildren(back, title, subtitle, list, interfacesSection, actions); +} + +async function renderInterfacesSection(host) { + const tbody = el('tbody', { id: 'interfaces-body' }); + const empty = el('div', { class: 'results-empty', id: 'interfaces-empty', hidden: true }, + 'No interfaces.'); + + const addBtn = el('button', { class: 'btn btn-ghost btn-sm' }, '+ Add interface'); + addBtn.addEventListener('click', () => { + empty.hidden = true; + tbody.appendChild(buildIfaceEditRow(host, null)); + }); + + const section = el('section', { class: 'interfaces' }, + el('div', { class: 'interfaces-header' }, + el('h2', {}, 'Network'), + addBtn, + ), + el('table', { class: 'table' }, + el('thead', {}, + el('tr', {}, + el('th', {}, 'Interface'), + el('th', {}, 'Ethernet'), + el('th', {}, 'IP Address'), + el('th', {}, 'Subnet'), + el('th', {}, 'Link'), + el('th', { class: 'actions-col' }), + ), + ), + tbody, + ), + empty, + ); + + let ifaces = []; + try { + ifaces = await api.interfaces.list(host.id); + } catch (err) { + section.appendChild(el('div', { class: 'iface-error' }, + `Failed to load interfaces: ${err.message}`)); + return section; + } + + if (ifaces.length === 0) { + empty.hidden = false; + } else { + for (const iface of ifaces) tbody.appendChild(buildIfaceRow(host, iface)); + } + return section; +} + +function fieldOrDash(value) { + return value ? value : el('span', { class: 'muted' }, '\u2014'); +} + +function buildIfaceRow(host, iface) { + const row = el('tr', { dataset: { ifaceId: iface.id } }); + const cells = IFACE_FIELDS.map((f) => el('td', {}, fieldOrDash(iface[f]))); + const actions = el('td', { class: 'actions-cell' }); + row.append(...cells, actions); + setIfaceDisplayActions(row, host, iface, actions); + return row; +} + +function setIfaceDisplayActions(row, host, iface, actionsCell) { + actionsCell.replaceChildren( + el('button', { class: 'btn-link', onclick: () => enterIfaceEdit(row, host, iface) }, 'Edit'), + el('button', { + class: 'btn-link btn-link-danger', + onclick: () => beginIfaceDelete(row, host, iface, actionsCell), + }, 'Delete'), + ); +} + +function enterIfaceEdit(row, host, iface) { + const editRow = buildIfaceEditRow(host, iface); + row.replaceWith(editRow); +} + +function buildIfaceEditRow(host, iface) { + const isNew = iface == null; + const row = el('tr', { class: 'iface-edit-row' }); + const inputs = {}; + for (const f of IFACE_FIELDS) { + const input = el('input', { + class: 'iface-edit-input', + type: 'text', + name: f, + placeholder: IFACE_PLACEHOLDERS[f], + value: iface?.[f] ?? '', + }); + inputs[f] = input; + row.appendChild(el('td', {}, input)); + } + const actions = el('td', { class: 'actions-cell' }); + row.appendChild(actions); + + let errorRow = null; + const showError = (msg) => { + clearError(); + errorRow = el('tr', { class: 'iface-error-row' }, + el('td', { colspan: IFACE_FIELDS.length + 1 }, + el('div', { class: 'iface-error' }, msg))); + row.parentNode.insertBefore(errorRow, row); + }; + const clearError = () => { if (errorRow) { errorRow.remove(); errorRow = null; } }; + + const cancel = el('button', { + class: 'btn-link', + onclick: () => { + clearError(); + if (isNew) { + row.remove(); + const tbody = $('#interfaces-body'); + if (tbody && tbody.children.length === 0) { + const emptyEl = $('#interfaces-empty'); + if (emptyEl) emptyEl.hidden = false; + } + } else { + const display = buildIfaceRow(host, iface); + row.replaceWith(display); + } + }, + }, 'Cancel'); + + const save = el('button', { + class: 'btn-link', + onclick: async () => { + clearError(); + const payload = { host_id: host.id }; + for (const f of IFACE_FIELDS) payload[f] = inputs[f].value.trim(); + if (!payload.name) { + showError('Interface name is required.'); + return; + } + try { + const saved = isNew + ? await api.interfaces.create(payload) + : await api.interfaces.update(iface.id, payload); + const display = buildIfaceRow(host, saved); + row.replaceWith(display); + const emptyEl = $('#interfaces-empty'); + if (emptyEl) emptyEl.hidden = true; + } catch (err) { + const detailMsg = err.details?.length ? `: ${err.details.join('; ')}` : ''; + showError(`${err.message}${detailMsg}`); + } + }, + }, 'Save'); + + actions.append(save, cancel); + return row; +} + +function beginIfaceDelete(row, host, iface, actionsCell) { + actionsCell.replaceChildren( + el('span', { class: 'confirm-inline' }, + 'Delete this interface?', + el('button', { + class: 'btn-link btn-link-danger', + onclick: async () => { + try { + await api.interfaces.delete(iface.id); + row.remove(); + const tbody = $('#interfaces-body'); + if (tbody && tbody.children.length === 0) { + const emptyEl = $('#interfaces-empty'); + if (emptyEl) emptyEl.hidden = false; + } + } catch (err) { + alert(`Delete failed: ${err.message}`); + setIfaceDisplayActions(row, host, iface, actionsCell); + } + }, + }, 'Yes'), + el('button', { + class: 'btn-link', + onclick: () => setIfaceDisplayActions(row, host, iface, actionsCell), + }, 'No'), + ), + ); +} + +function beginDeleteFromDetail(host) { + const actions = $('#detail-actions'); + if (!actions) return; + actions.replaceChildren( + el('span', { class: 'confirm-inline' }, + 'Delete this host?', + el('button', { + class: 'btn btn-danger btn-sm', + onclick: async () => { + try { + await api.hosts.delete(host.id); + navigate('/'); + } catch (err) { + alert(`Delete failed: ${err.message}`); + renderDetail(host.hardware_id); + } + }, + }, 'Yes, delete'), + el('button', { class: 'btn btn-ghost btn-sm', onclick: () => renderDetail(host.hardware_id) }, 'Cancel'), + ), + ); +} + +// ── Host modal ──────────────────────────────────────────────────────────────── + +const hostModal = $('#host-modal'); +const hostForm = $('#host-form'); +const hostError = $('#host-error'); +const hostTitle = $('#host-modal-title'); + +let hostBeingEdited = null; +let cachedSites = []; +let cachedServerTypes = []; + +async function openHostModal(host = null) { + hostBeingEdited = host; + hostTitle.textContent = host ? 'Edit host' : 'New host'; + hostError.hidden = true; + hostError.textContent = ''; + hostForm.reset(); + + await loadDropdowns(); + + if (host) { + hostForm.elements.hostname.value = host.hostname; + hostForm.elements.hardware_id.value = host.hardware_id; + hostForm.elements.asset_id.value = host.asset_id; + hostForm.elements.position.value = host.position ?? ''; + hostForm.elements.site_id.value = host.site_id; + await refreshRoomsSelect(host.site_id, host.room_id); + hostForm.elements.server_type_id.value = host.server_type_id; + } else { + if (cachedSites[0]) { + hostForm.elements.site_id.value = cachedSites[0].id; + await refreshRoomsSelect(cachedSites[0].id); + } + if (cachedServerTypes[0]) { + hostForm.elements.server_type_id.value = cachedServerTypes[0].id; + } + } + + openModal('host-modal'); + setTimeout(() => hostForm.elements.hostname.focus(), 50); +} + +async function loadDropdowns() { + const [sites, serverTypes] = await Promise.all([ + api.sites.list(), + api.serverTypes.list(), + ]); + cachedSites = sites; + cachedServerTypes = serverTypes; + + const siteSel = hostForm.elements.site_id; + siteSel.replaceChildren(...sites.map((s) => + el('option', { value: s.id }, s.name))); + + const typeSel = hostForm.elements.server_type_id; + typeSel.replaceChildren(...serverTypes.map((t) => + el('option', { value: t.id }, t.name))); +} + +async function refreshRoomsSelect(siteId, selectedRoomId = null) { + const roomSel = hostForm.elements.room_id; + if (!siteId) { + roomSel.replaceChildren(); + return; + } + const rooms = await api.rooms.list(siteId); + roomSel.replaceChildren(...rooms.map((r) => + el('option', { value: r.id }, r.name))); + if (selectedRoomId != null) roomSel.value = selectedRoomId; +} + +hostForm.elements.site_id.addEventListener('change', (e) => { + refreshRoomsSelect(Number(e.target.value)); +}); + +hostForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hostError.hidden = true; + const fd = new FormData(hostForm); + const payload = { + hostname: fd.get('hostname').trim(), + hardware_id: fd.get('hardware_id').trim(), + asset_id: fd.get('asset_id').trim(), + room_id: Number(fd.get('room_id')), + position: (fd.get('position') ?? '').trim(), + server_type_id: Number(fd.get('server_type_id')), + }; + let saved; + try { + saved = hostBeingEdited + ? await api.hosts.update(hostBeingEdited.id, payload) + : await api.hosts.create(payload); + } catch (err) { + hostError.textContent = err.details?.length + ? `${err.message}: ${err.details.join('; ')}` + : err.message; + hostError.hidden = false; + return; + } + closeModal('host-modal'); + // Always land on the saved host's page. If we were already on detail and the + // hardware_id didn't change, replace so back doesn't bounce. + const url = `/hosts/${encodeURIComponent(saved.hardware_id)}`; + const replace = currentView === 'detail' && currentHardwareId === saved.hardware_id; + navigate(url, { replace }); +}); + +$('#new-host-btn').addEventListener('click', () => openHostModal(null)); + +$('.brand').addEventListener('click', () => { + searchInput.value = ''; + resultsBody.replaceChildren(); + resultsEmpty.hidden = true; + resultsEmpty.textContent = 'No hosts match.'; +}); + +// ── Manage modal ────────────────────────────────────────────────────────────── + +const manageModal = $('#manage-modal'); +const manageBody = $('#manage-body'); +let activeTab = 'sites'; + +$('#manage-btn').addEventListener('click', async () => { + openModal('manage-modal'); + await renderManageTab(); +}); + +$$('.tab').forEach((btn) => { + btn.addEventListener('click', async () => { + $$('.tab').forEach((b) => b.classList.toggle('tab-active', b === btn)); + activeTab = btn.dataset.tab; + await renderManageTab(); + }); +}); + +async function renderManageTab() { + manageBody.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…')); + if (activeTab === 'sites') await renderSitesTab(); + if (activeTab === 'rooms') await renderRoomsTab(); + if (activeTab === 'server-types') await renderTypesTab(); +} + +async function renderSitesTab() { + const items = await api.sites.list(); + manageBody.replaceChildren(buildLookupList({ + items, + placeholder: 'New site name', + onCreate: (name) => api.sites.create(name), + onUpdate: (id, name) => api.sites.update(id, name), + onDelete: (id) => api.sites.delete(id), + rerender: renderSitesTab, + })); +} + +async function renderTypesTab() { + const items = await api.serverTypes.list(); + manageBody.replaceChildren(buildLookupList({ + items, + placeholder: 'New server type', + onCreate: (name) => api.serverTypes.create(name), + onUpdate: (id, name) => api.serverTypes.update(id, name), + onDelete: (id) => api.serverTypes.delete(id), + rerender: renderTypesTab, + })); +} + +function buildLookupList({ items, placeholder, onCreate, onUpdate, onDelete, rerender }) { + const list = el('div', { class: 'manage-list' }); + + // Add row + const addInput = el('input', { type: 'text', placeholder, maxlength: 100 }); + const addBtn = el('button', { + class: 'btn btn-primary btn-sm', + onclick: async () => { + const name = addInput.value.trim(); + if (!name) return; + try { + await onCreate(name); + await rerender(); + } catch (err) { + alert(err.message); + } + }, + }, 'Add'); + list.appendChild(el('div', { class: 'manage-row add-row' }, addInput, addBtn)); + + if (items.length === 0) { + list.appendChild(el('div', { class: 'manage-empty' }, 'No entries yet.')); + return list; + } + + for (const item of items) { + list.appendChild(buildLookupRow(item, onUpdate, onDelete, rerender)); + } + return list; +} + +function buildLookupRow(item, onUpdate, onDelete, rerender) { + const row = el('div', { class: 'manage-row' }); + const labelCell = el('div', { class: 'label' }, item.name); + const actions = el('div', {}); + + const editBtn = el('button', { + class: 'btn-link', + onclick: () => enterEdit(), + }, 'Edit'); + const delBtn = el('button', { + class: 'btn-link btn-link-danger', + onclick: () => confirmDelete(), + }, 'Delete'); + actions.append(editBtn, delBtn); + row.append(labelCell, actions); + + function enterEdit() { + const input = el('input', { type: 'text', value: item.name, maxlength: 100 }); + const save = el('button', { + class: 'btn-link', + onclick: async () => { + const name = input.value.trim(); + if (!name) return; + try { + await onUpdate(item.id, name); + await rerender(); + } catch (err) { alert(err.message); } + }, + }, 'Save'); + const cancel = el('button', { class: 'btn-link', onclick: rerender }, 'Cancel'); + labelCell.replaceChildren(input); + actions.replaceChildren(save, cancel); + } + + function confirmDelete() { + actions.replaceChildren( + el('span', { class: 'confirm-inline' }, + 'Delete?', + el('button', { + class: 'btn-link btn-link-danger', + onclick: async () => { + try { + await onDelete(item.id); + await rerender(); + } catch (err) { alert(err.message); rerender(); } + }, + }, 'Yes'), + el('button', { class: 'btn-link', onclick: rerender }, 'No'), + ), + ); + } + + return row; +} + +async function renderRoomsTab() { + const [rooms, sites] = await Promise.all([api.rooms.list(), api.sites.list()]); + + if (sites.length === 0) { + manageBody.replaceChildren(el('div', { class: 'manage-empty' }, + 'Add a site first.')); + return; + } + + const list = el('div', { class: 'manage-list' }); + + // Add row + const siteSel = el('select', {}, + ...sites.map((s) => el('option', { value: s.id }, s.name))); + const nameInput = el('input', { type: 'text', placeholder: 'New room name', maxlength: 100 }); + const addBtn = el('button', { + class: 'btn btn-primary btn-sm', + onclick: async () => { + const name = nameInput.value.trim(); + if (!name) return; + try { + await api.rooms.create(Number(siteSel.value), name); + await renderRoomsTab(); + } catch (err) { alert(err.message); } + }, + }, 'Add'); + list.appendChild(el('div', { class: 'manage-row add-row' }, + el('div', { class: 'grow' }, siteSel, nameInput), + addBtn, + )); + + if (rooms.length === 0) { + list.appendChild(el('div', { class: 'manage-empty' }, 'No rooms yet.')); + manageBody.replaceChildren(list); + return; + } + + for (const room of rooms) { + list.appendChild(buildRoomRow(room, sites)); + } + manageBody.replaceChildren(list); +} + +function buildRoomRow(room, sites) { + const row = el('div', { class: 'manage-row' }); + const label = el('div', { class: 'label' }, + el('span', { class: 'muted' }, `${room.site_name} ·`), + room.name); + const actions = el('div', {}); + + const editBtn = el('button', { class: 'btn-link', onclick: enterEdit }, 'Edit'); + const delBtn = el('button', { class: 'btn-link btn-link-danger', onclick: confirmDelete }, 'Delete'); + actions.append(editBtn, delBtn); + row.append(label, actions); + + function enterEdit() { + const siteSel = el('select', {}, + ...sites.map((s) => el('option', { value: s.id, selected: s.id === room.site_id }, s.name))); + const input = el('input', { type: 'text', value: room.name, maxlength: 100 }); + const save = el('button', { + class: 'btn-link', + onclick: async () => { + const name = input.value.trim(); + if (!name) return; + try { + await api.rooms.update(room.id, Number(siteSel.value), name); + await renderRoomsTab(); + } catch (err) { alert(err.message); } + }, + }, 'Save'); + const cancel = el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'Cancel'); + label.replaceChildren(el('div', { class: 'grow' }, siteSel, input)); + actions.replaceChildren(save, cancel); + } + + function confirmDelete() { + actions.replaceChildren( + el('span', { class: 'confirm-inline' }, + 'Delete?', + el('button', { + class: 'btn-link btn-link-danger', + onclick: async () => { + try { + await api.rooms.delete(room.id); + await renderRoomsTab(); + } catch (err) { alert(err.message); renderRoomsTab(); } + }, + }, 'Yes'), + el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'No'), + ), + ); + } + + return row; +} + +// ── Modal close handlers ────────────────────────────────────────────────────── + +document.addEventListener('click', (e) => { + const closeId = e.target.dataset?.closeModal; + if (closeId) closeModal(closeId); + // click on backdrop + if (e.target.classList.contains('modal')) { + closeModal(e.target.id); + } + + // SPA navigation for in-app links + const link = e.target.closest('a[data-nav]'); + if (link && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0) { + e.preventDefault(); + navigate(link.getAttribute('href')); + } +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + $$('.modal').forEach((m) => { if (!m.hidden) m.hidden = true; }); + } +}); + +// ── Bootstrap ───────────────────────────────────────────────────────────────── + +handleRoute(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e5c130f --- /dev/null +++ b/public/index.html @@ -0,0 +1,120 @@ + + + + + + Infrastructure + + + + +
+ Infrastructure +
+ + +
+
+ +
+
+

Find a host

+

Search by hostname, hardware ID, or asset ID.

+
+ + +
+
+
+ + + + +
+ + + + + + + + + + diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..c1c459c --- /dev/null +++ b/src/db.js @@ -0,0 +1,253 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100) + ); + + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE RESTRICT, + name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 100), + UNIQUE(site_id, name) + ); + + CREATE TABLE IF NOT EXISTS server_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100) + ); + + CREATE TABLE IF NOT EXISTS hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hardware_id TEXT NOT NULL UNIQUE, + hostname TEXT NOT NULL UNIQUE, + asset_id TEXT NOT NULL UNIQUE, + room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE RESTRICT, + position TEXT NOT NULL DEFAULT '', + server_type_id INTEGER NOT NULL REFERENCES server_types(id) ON DELETE RESTRICT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_hosts_hardware_id ON hosts(hardware_id); + CREATE INDEX IF NOT EXISTS idx_hosts_hostname ON hosts(hostname); + CREATE INDEX IF NOT EXISTS idx_hosts_asset_id ON hosts(asset_id); + + CREATE TABLE IF NOT EXISTS interfaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 50), + mac_address TEXT NOT NULL DEFAULT '' CHECK(length(mac_address) <= 17), + ip_address TEXT NOT NULL DEFAULT '' CHECK(length(ip_address) <= 15), + subnet TEXT NOT NULL DEFAULT '' CHECK(length(subnet) <= 18), + link_speed TEXT NOT NULL DEFAULT '' CHECK(length(link_speed) <= 20), + UNIQUE(host_id, name) + ); + + CREATE INDEX IF NOT EXISTS idx_interfaces_host ON interfaces(host_id); +`; + +const HOST_SELECT = ` + SELECT h.id, h.hardware_id, h.hostname, h.asset_id, h.position, + h.created_at, h.updated_at, + s.id AS site_id, s.name AS site_name, + r.id AS room_id, r.name AS room_name, + st.id AS server_type_id, st.name AS server_type + FROM hosts h + JOIN rooms r ON r.id = h.room_id + JOIN sites s ON s.id = r.site_id + JOIN server_types st ON st.id = h.server_type_id +`; + +export function openDb(path) { + if (path !== ':memory:') { + mkdirSync(dirname(path), { recursive: true }); + } + const db = new DatabaseSync(path); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA synchronous = NORMAL'); + db.exec(SCHEMA); + return makeApi(db); +} + +function makeApi(db) { + const stmts = { + siteList: db.prepare('SELECT * FROM sites ORDER BY name'), + siteGet: db.prepare('SELECT * FROM sites WHERE id = ?'), + siteInsert: db.prepare('INSERT INTO sites (name) VALUES (?)'), + siteUpdate: db.prepare('UPDATE sites SET name = ? WHERE id = ?'), + siteDelete: db.prepare('DELETE FROM sites WHERE id = ?'), + + roomList: db.prepare( + `SELECT r.*, s.name AS site_name FROM rooms r + JOIN sites s ON s.id = r.site_id ORDER BY s.name, r.name`, + ), + roomListBySite: db.prepare( + `SELECT r.*, s.name AS site_name FROM rooms r + JOIN sites s ON s.id = r.site_id WHERE r.site_id = ? ORDER BY r.name`, + ), + roomGet: db.prepare( + `SELECT r.*, s.name AS site_name FROM rooms r + JOIN sites s ON s.id = r.site_id WHERE r.id = ?`, + ), + roomInsert: db.prepare('INSERT INTO rooms (site_id, name) VALUES (?, ?)'), + roomUpdate: db.prepare('UPDATE rooms SET site_id = ?, name = ? WHERE id = ?'), + roomDelete: db.prepare('DELETE FROM rooms WHERE id = ?'), + + typeList: db.prepare('SELECT * FROM server_types ORDER BY name'), + typeGet: db.prepare('SELECT * FROM server_types WHERE id = ?'), + typeInsert: db.prepare('INSERT INTO server_types (name) VALUES (?)'), + typeUpdate: db.prepare('UPDATE server_types SET name = ? WHERE id = ?'), + typeDelete: db.prepare('DELETE FROM server_types WHERE id = ?'), + + hostListAll: db.prepare(`${HOST_SELECT} ORDER BY h.hostname LIMIT 200`), + hostSearch: db.prepare( + `${HOST_SELECT} + WHERE LOWER(h.hardware_id) LIKE :q + OR LOWER(h.hostname) LIKE :q + OR LOWER(h.asset_id) LIKE :q + ORDER BY h.hostname LIMIT 200`, + ), + hostGet: db.prepare(`${HOST_SELECT} WHERE h.id = ?`), + hostGetByHwid: db.prepare(`${HOST_SELECT} WHERE h.hardware_id = ?`), + hostInsert: db.prepare( + `INSERT INTO hosts (hardware_id, hostname, asset_id, room_id, position, server_type_id) + VALUES (?, ?, ?, ?, ?, ?)`, + ), + hostUpdate: db.prepare( + `UPDATE hosts SET hardware_id = ?, hostname = ?, asset_id = ?, + room_id = ?, position = ?, server_type_id = ?, + updated_at = datetime('now') + WHERE id = ?`, + ), + hostDelete: db.prepare('DELETE FROM hosts WHERE id = ?'), + + ifaceListByHost: db.prepare( + 'SELECT * FROM interfaces WHERE host_id = ? ORDER BY name', + ), + ifaceGet: db.prepare('SELECT * FROM interfaces WHERE id = ?'), + ifaceInsert: db.prepare( + `INSERT INTO interfaces (host_id, name, mac_address, ip_address, subnet, link_speed) + VALUES (?, ?, ?, ?, ?, ?)`, + ), + ifaceUpdate: db.prepare( + `UPDATE interfaces SET host_id = ?, name = ?, mac_address = ?, + ip_address = ?, subnet = ?, link_speed = ? + WHERE id = ?`, + ), + ifaceDelete: db.prepare('DELETE FROM interfaces WHERE id = ?'), + }; + + return { + raw: db, + close: () => db.close(), + + sites: { + list: () => stmts.siteList.all(), + get: (id) => stmts.siteGet.get(id), + create: (name) => { + const { lastInsertRowid } = stmts.siteInsert.run(name); + return stmts.siteGet.get(lastInsertRowid); + }, + update: (id, name) => { + const r = stmts.siteUpdate.run(name, id); + return r.changes ? stmts.siteGet.get(id) : null; + }, + delete: (id) => stmts.siteDelete.run(id).changes > 0, + }, + + rooms: { + list: (siteId) => siteId + ? stmts.roomListBySite.all(siteId) + : stmts.roomList.all(), + get: (id) => stmts.roomGet.get(id), + create: (siteId, name) => { + const { lastInsertRowid } = stmts.roomInsert.run(siteId, name); + return stmts.roomGet.get(lastInsertRowid); + }, + update: (id, siteId, name) => { + const r = stmts.roomUpdate.run(siteId, name, id); + return r.changes ? stmts.roomGet.get(id) : null; + }, + delete: (id) => stmts.roomDelete.run(id).changes > 0, + }, + + serverTypes: { + list: () => stmts.typeList.all(), + get: (id) => stmts.typeGet.get(id), + create: (name) => { + const { lastInsertRowid } = stmts.typeInsert.run(name); + return stmts.typeGet.get(lastInsertRowid); + }, + update: (id, name) => { + const r = stmts.typeUpdate.run(name, id); + return r.changes ? stmts.typeGet.get(id) : null; + }, + delete: (id) => stmts.typeDelete.run(id).changes > 0, + }, + + hosts: { + list: () => stmts.hostListAll.all(), + search: (q) => { + const term = `%${q.toLowerCase()}%`; + return stmts.hostSearch.all({ q: term }); + }, + get: (id) => stmts.hostGet.get(id), + getByHardwareId: (hwid) => stmts.hostGetByHwid.get(hwid), + create: (h) => { + const { lastInsertRowid } = stmts.hostInsert.run( + h.hardware_id, h.hostname, h.asset_id, + h.room_id, h.position ?? '', h.server_type_id, + ); + return stmts.hostGet.get(lastInsertRowid); + }, + update: (id, h) => { + const r = stmts.hostUpdate.run( + h.hardware_id, h.hostname, h.asset_id, + h.room_id, h.position ?? '', h.server_type_id, + id, + ); + return r.changes ? stmts.hostGet.get(id) : null; + }, + delete: (id) => stmts.hostDelete.run(id).changes > 0, + }, + + interfaces: { + listByHost: (hostId) => stmts.ifaceListByHost.all(hostId), + get: (id) => stmts.ifaceGet.get(id), + create: (i) => { + const { lastInsertRowid } = stmts.ifaceInsert.run( + i.host_id, i.name, + i.mac_address ?? '', i.ip_address ?? '', + i.subnet ?? '', i.link_speed ?? '', + ); + return stmts.ifaceGet.get(lastInsertRowid); + }, + update: (id, i) => { + const r = stmts.ifaceUpdate.run( + i.host_id, i.name, + i.mac_address ?? '', i.ip_address ?? '', + i.subnet ?? '', i.link_speed ?? '', + id, + ); + return r.changes ? stmts.ifaceGet.get(id) : null; + }, + delete: (id) => stmts.ifaceDelete.run(id).changes > 0, + }, + }; +} + +export function seedIfEmpty(db) { + const { count } = db.raw.prepare('SELECT COUNT(*) AS count FROM sites').get(); + if (count > 0) return; + + const site = db.sites.create('HQ'); + db.rooms.create(site.id, 'Server Room A'); + for (const t of ['Web', 'Database', 'Application', 'Storage', 'Network']) { + db.serverTypes.create(t); + } +} diff --git a/src/routes/hosts.js b/src/routes/hosts.js new file mode 100644 index 0000000..55e1af5 --- /dev/null +++ b/src/routes/hosts.js @@ -0,0 +1,88 @@ +import { schemas } from '../schemas.js'; +import { translateSqliteError } from '../sqlite-errors.js'; + +export default async function hostsRoutes(fastify) { + const { db } = fastify; + + fastify.get('/', { + schema: { + querystring: { + type: 'object', + properties: { q: { type: 'string' } }, + }, + response: { + 200: { type: 'array', items: schemas.hostResponse }, + }, + }, + }, async (req) => { + const q = (req.query.q ?? '').trim(); + return q ? db.hosts.search(q) : db.hosts.list(); + }); + + fastify.get('/by-hardware-id/:hardwareId', { + schema: { + params: { + type: 'object', + required: ['hardwareId'], + properties: { hardwareId: { type: 'string', minLength: 1 } }, + }, + response: { 200: schemas.hostResponse, 404: schemas.errorResponse }, + }, + }, async (req) => { + const host = db.hosts.getByHardwareId(req.params.hardwareId); + if (!host) throw fastify.httpErrors.notFound('host not found'); + return host; + }); + + fastify.get('/:id', { + schema: { + params: schemas.idParam, + response: { 200: schemas.hostResponse, 404: schemas.errorResponse }, + }, + }, async (req) => { + const host = db.hosts.get(req.params.id); + if (!host) throw fastify.httpErrors.notFound('host not found'); + return host; + }); + + fastify.post('/', { + schema: { + body: schemas.hostBody, + response: { 201: schemas.hostResponse }, + }, + }, async (req, reply) => { + try { + const host = db.hosts.create(req.body); + reply.code(201); + return host; + } catch (err) { + translateSqliteError(err, fastify); + } + }); + + fastify.put('/:id', { + schema: { + params: schemas.idParam, + body: schemas.hostBody, + response: { 200: schemas.hostResponse, 404: schemas.errorResponse }, + }, + }, async (req) => { + try { + const host = db.hosts.update(req.params.id, req.body); + if (!host) throw fastify.httpErrors.notFound('host not found'); + return host; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify); + } + }); + + fastify.delete('/:id', { + schema: { params: schemas.idParam }, + }, async (req, reply) => { + const removed = db.hosts.delete(req.params.id); + if (!removed) throw fastify.httpErrors.notFound('host not found'); + reply.code(204); + return null; + }); +} diff --git a/src/routes/interfaces.js b/src/routes/interfaces.js new file mode 100644 index 0000000..717f63e --- /dev/null +++ b/src/routes/interfaces.js @@ -0,0 +1,71 @@ +import { schemas } from '../schemas.js'; +import { translateSqliteError } from '../sqlite-errors.js'; + +export default async function interfacesRoutes(fastify) { + const { db } = fastify; + + fastify.get('/', { + schema: { + querystring: schemas.interfaceQuery, + response: { 200: { type: 'array', items: schemas.interfaceResponse } }, + }, + }, async (req) => db.interfaces.listByHost(req.query.host_id)); + + fastify.get('/:id', { + schema: { + params: schemas.idParam, + response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse }, + }, + }, async (req) => { + const row = db.interfaces.get(req.params.id); + if (!row) throw fastify.httpErrors.notFound('interface not found'); + return row; + }); + + fastify.post('/', { + schema: { + body: schemas.interfaceBody, + response: { 201: schemas.interfaceResponse }, + }, + }, async (req, reply) => { + try { + const row = db.interfaces.create(req.body); + reply.code(201); + return row; + } catch (err) { + translateSqliteError(err, fastify, { + uniqueMessage: 'an interface with that name already exists on this host', + foreignKeyMessage: 'host does not exist', + }); + } + }); + + fastify.put('/:id', { + schema: { + params: schemas.idParam, + body: schemas.interfaceBody, + response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse }, + }, + }, async (req) => { + try { + const row = db.interfaces.update(req.params.id, req.body); + if (!row) throw fastify.httpErrors.notFound('interface not found'); + return row; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { + uniqueMessage: 'an interface with that name already exists on this host', + foreignKeyMessage: 'host does not exist', + }); + } + }); + + fastify.delete('/:id', { + schema: { params: schemas.idParam }, + }, async (req, reply) => { + const removed = db.interfaces.delete(req.params.id); + if (!removed) throw fastify.httpErrors.notFound('interface not found'); + reply.code(204); + return null; + }); +} diff --git a/src/routes/rooms.js b/src/routes/rooms.js new file mode 100644 index 0000000..a10494d --- /dev/null +++ b/src/routes/rooms.js @@ -0,0 +1,69 @@ +import { schemas } from '../schemas.js'; +import { translateSqliteError } from '../sqlite-errors.js'; + +export default async function roomsRoutes(fastify) { + const { db } = fastify; + + fastify.get('/', { + schema: { + querystring: { + type: 'object', + properties: { site_id: { type: 'integer', minimum: 1 } }, + }, + response: { 200: { type: 'array', items: schemas.roomResponse } }, + }, + }, async (req) => db.rooms.list(req.query.site_id)); + + fastify.get('/:id', { + schema: { params: schemas.idParam, response: { 200: schemas.roomResponse } }, + }, async (req) => { + const row = db.rooms.get(req.params.id); + if (!row) throw fastify.httpErrors.notFound('room not found'); + return row; + }); + + fastify.post('/', { + schema: { body: schemas.roomBody, response: { 201: schemas.roomResponse } }, + }, async (req, reply) => { + try { + const row = db.rooms.create(req.body.site_id, req.body.name); + reply.code(201); + return row; + } catch (err) { + translateSqliteError(err, fastify, { + uniqueMessage: 'a room with that name already exists at this site', + foreignKeyMessage: 'site does not exist', + }); + } + }); + + fastify.put('/:id', { + schema: { params: schemas.idParam, body: schemas.roomBody, response: { 200: schemas.roomResponse } }, + }, async (req) => { + try { + const row = db.rooms.update(req.params.id, req.body.site_id, req.body.name); + if (!row) throw fastify.httpErrors.notFound('room not found'); + return row; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { + uniqueMessage: 'a room with that name already exists at this site', + foreignKeyMessage: 'site does not exist', + }); + } + }); + + fastify.delete('/:id', { + schema: { params: schemas.idParam }, + }, async (req, reply) => { + try { + const removed = db.rooms.delete(req.params.id); + if (!removed) throw fastify.httpErrors.notFound('room not found'); + reply.code(204); + return null; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this room' }); + } + }); +} diff --git a/src/routes/server-types.js b/src/routes/server-types.js new file mode 100644 index 0000000..4a18cbd --- /dev/null +++ b/src/routes/server-types.js @@ -0,0 +1,57 @@ +import { schemas } from '../schemas.js'; +import { translateSqliteError } from '../sqlite-errors.js'; + +export default async function serverTypesRoutes(fastify) { + const { db } = fastify; + + fastify.get('/', { + schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } }, + }, async () => db.serverTypes.list()); + + fastify.get('/:id', { + schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } }, + }, async (req) => { + const row = db.serverTypes.get(req.params.id); + if (!row) throw fastify.httpErrors.notFound('server type not found'); + return row; + }); + + fastify.post('/', { + schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } }, + }, async (req, reply) => { + try { + const row = db.serverTypes.create(req.body.name); + reply.code(201); + return row; + } catch (err) { + translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' }); + } + }); + + fastify.put('/:id', { + schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } }, + }, async (req) => { + try { + const row = db.serverTypes.update(req.params.id, req.body.name); + if (!row) throw fastify.httpErrors.notFound('server type not found'); + return row; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' }); + } + }); + + fastify.delete('/:id', { + schema: { params: schemas.idParam }, + }, async (req, reply) => { + try { + const removed = db.serverTypes.delete(req.params.id); + if (!removed) throw fastify.httpErrors.notFound('server type not found'); + reply.code(204); + return null; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this server type' }); + } + }); +} diff --git a/src/routes/sites.js b/src/routes/sites.js new file mode 100644 index 0000000..01ffcfe --- /dev/null +++ b/src/routes/sites.js @@ -0,0 +1,57 @@ +import { schemas } from '../schemas.js'; +import { translateSqliteError } from '../sqlite-errors.js'; + +export default async function sitesRoutes(fastify) { + const { db } = fastify; + + fastify.get('/', { + schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } }, + }, async () => db.sites.list()); + + fastify.get('/:id', { + schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } }, + }, async (req) => { + const row = db.sites.get(req.params.id); + if (!row) throw fastify.httpErrors.notFound('site not found'); + return row; + }); + + fastify.post('/', { + schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } }, + }, async (req, reply) => { + try { + const row = db.sites.create(req.body.name); + reply.code(201); + return row; + } catch (err) { + translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' }); + } + }); + + fastify.put('/:id', { + schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } }, + }, async (req) => { + try { + const row = db.sites.update(req.params.id, req.body.name); + if (!row) throw fastify.httpErrors.notFound('site not found'); + return row; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' }); + } + }); + + fastify.delete('/:id', { + schema: { params: schemas.idParam }, + }, async (req, reply) => { + try { + const removed = db.sites.delete(req.params.id); + if (!removed) throw fastify.httpErrors.notFound('site not found'); + reply.code(204); + return null; + } catch (err) { + if (err.statusCode) throw err; + translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: rooms still reference this site' }); + } + }); +} diff --git a/src/schemas.js b/src/schemas.js new file mode 100644 index 0000000..19546ce --- /dev/null +++ b/src/schemas.js @@ -0,0 +1,140 @@ +const idParam = { + type: 'object', + required: ['id'], + properties: { id: { type: 'integer', minimum: 1 } }, +}; + +const errorResponse = { + type: 'object', + required: ['error'], + properties: { + error: { type: 'string' }, + details: { type: 'array', items: { type: 'string' } }, + }, +}; + +const name = { type: 'string', minLength: 1, maxLength: 100 }; + +const lookupResponse = { + type: 'object', + properties: { id: { type: 'integer' }, name: { type: 'string' } }, +}; + +const roomResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + site_id: { type: 'integer' }, + site_name: { type: 'string' }, + name: { type: 'string' }, + }, +}; + +const hostResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + hardware_id: { type: 'string' }, + hostname: { type: 'string' }, + asset_id: { type: 'string' }, + position: { type: 'string' }, + site_id: { type: 'integer' }, + site_name: { type: 'string' }, + room_id: { type: 'integer' }, + room_name: { type: 'string' }, + server_type_id: { type: 'integer' }, + server_type: { type: 'string' }, + created_at: { type: 'string' }, + updated_at: { type: 'string' }, + }, +}; + +const hostBody = { + type: 'object', + required: ['hardware_id', 'hostname', 'asset_id', 'room_id', 'server_type_id'], + additionalProperties: false, + properties: { + hardware_id: { type: 'string', minLength: 1, maxLength: 100 }, + hostname: { type: 'string', minLength: 1, maxLength: 253 }, + asset_id: { type: 'string', minLength: 1, maxLength: 100 }, + room_id: { type: 'integer', minimum: 1 }, + position: { type: 'string', maxLength: 100, default: '' }, + server_type_id: { type: 'integer', minimum: 1 }, + }, +}; + +const roomBody = { + type: 'object', + required: ['site_id', 'name'], + additionalProperties: false, + properties: { + site_id: { type: 'integer', minimum: 1 }, + name, + }, +}; + +const lookupBody = { + type: 'object', + required: ['name'], + additionalProperties: false, + properties: { name }, +}; + +const macPattern = '([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}'; +const ipv4Octet = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'; +const ipv4Pattern = `(${ipv4Octet}\\.){3}${ipv4Octet}`; +const cidrPattern = `${ipv4Pattern}/(3[0-2]|[12]?[0-9])`; +const linkPattern = '[0-9]+/(full|half|auto)'; + +const optionalString = (pattern, maxLength) => ({ + type: 'string', + maxLength, + pattern: `^$|^${pattern}$`, +}); + +const interfaceBody = { + type: 'object', + required: ['host_id', 'name'], + additionalProperties: false, + properties: { + host_id: { type: 'integer', minimum: 1 }, + name: { type: 'string', minLength: 1, maxLength: 50 }, + mac_address: optionalString(macPattern, 17), + ip_address: optionalString(ipv4Pattern, 15), + subnet: optionalString(cidrPattern, 18), + link_speed: optionalString(linkPattern, 20), + }, +}; + +const interfaceResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + host_id: { type: 'integer' }, + name: { type: 'string' }, + mac_address: { type: 'string' }, + ip_address: { type: 'string' }, + subnet: { type: 'string' }, + link_speed: { type: 'string' }, + }, +}; + +const interfaceQuery = { + type: 'object', + required: ['host_id'], + properties: { host_id: { type: 'integer', minimum: 1 } }, +}; + +export const schemas = { + idParam, + errorResponse, + hostBody, + hostResponse, + roomBody, + roomResponse, + lookupBody, + lookupResponse, + interfaceBody, + interfaceResponse, + interfaceQuery, +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..a9cb537 --- /dev/null +++ b/src/server.js @@ -0,0 +1,81 @@ +import Fastify from 'fastify'; +import sensible from '@fastify/sensible'; +import fastifyStatic from '@fastify/static'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { openDb, seedIfEmpty } from './db.js'; +import hostsRoutes from './routes/hosts.js'; +import sitesRoutes from './routes/sites.js'; +import roomsRoutes from './routes/rooms.js'; +import serverTypesRoutes from './routes/server-types.js'; +import interfacesRoutes from './routes/interfaces.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = join(__dirname, '../public'); +const DEFAULT_DB = join(__dirname, '../data/infrastructure.db'); + +export async function buildApp(opts = {}) { + const dbPath = opts.dbPath ?? process.env.DB_PATH ?? DEFAULT_DB; + const db = openDb(dbPath); + if (opts.seed !== false) seedIfEmpty(db); + + const app = Fastify({ + logger: opts.logger ?? false, + }); + + app.decorate('db', db); + app.addHook('onClose', (instance, done) => { + instance.db.close(); + done(); + }); + + await app.register(sensible); + + app.setErrorHandler((err, req, reply) => { + if (err.validation) { + const details = err.validation.map((v) => `${v.instancePath || '/'} ${v.message}`); + return reply.code(400).send({ error: 'validation failed', details }); + } + if (err.statusCode && err.statusCode < 500) { + return reply.code(err.statusCode).send({ error: err.message }); + } + req.log?.error(err); + return reply.code(500).send({ error: 'internal server error' }); + }); + + app.setNotFoundHandler(async (req, reply) => { + if (req.url.startsWith('/api/')) { + return reply.code(404).send({ error: 'not found' }); + } + return reply.sendFile('index.html'); + }); + + await app.register(async (api) => { + await api.register(hostsRoutes, { prefix: '/hosts' }); + await api.register(sitesRoutes, { prefix: '/sites' }); + await api.register(roomsRoutes, { prefix: '/rooms' }); + await api.register(serverTypesRoutes, { prefix: '/server-types' }); + await api.register(interfacesRoutes, { prefix: '/interfaces' }); + }, { prefix: '/api' }); + + await app.register(fastifyStatic, { + root: PUBLIC_DIR, + prefix: '/', + }); + + return app; +} + +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href; +if (isMain) { + const port = Number(process.env.PORT ?? 3000); + const host = process.env.HOST ?? '0.0.0.0'; + const app = await buildApp({ logger: true }); + try { + await app.listen({ port, host }); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} diff --git a/src/sqlite-errors.js b/src/sqlite-errors.js new file mode 100644 index 0000000..a0b7751 --- /dev/null +++ b/src/sqlite-errors.js @@ -0,0 +1,30 @@ +// node:sqlite throws Errors with `code: 'ERR_SQLITE_ERROR'`, an `errcode` (SQLite +// extended result code) and a human message. We pattern-match the message because +// it's stable across SQLite versions and avoids hardcoding numeric constants. + +export function translateSqliteError(err, fastify, ctx = {}) { + const msg = err?.message ?? ''; + if (err?.code !== 'ERR_SQLITE_ERROR') throw err; + + if (/^UNIQUE constraint failed/.test(msg)) { + const field = extractUniqueField(msg) ?? 'value'; + throw fastify.httpErrors.conflict( + ctx.uniqueMessage ?? `${field} already exists`, + ); + } + if (/^FOREIGN KEY constraint failed/.test(msg)) { + throw fastify.httpErrors.conflict( + ctx.foreignKeyMessage ?? 'referenced record does not exist or is still in use', + ); + } + if (/^CHECK constraint failed/.test(msg) || /NOT NULL constraint failed/.test(msg)) { + throw fastify.httpErrors.badRequest(msg); + } + throw err; +} + +function extractUniqueField(message) { + // SQLite says e.g. "UNIQUE constraint failed: hosts.hardware_id" + const m = /UNIQUE constraint failed:\s*\S+\.(\S+)/.exec(message); + return m?.[1]; +} diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..173e96e --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,37 @@ +import { buildApp } from '../src/server.js'; + +export async function newApp() { + return buildApp({ dbPath: ':memory:', seed: false }); +} + +export async function seedFixtures(app) { + const site = JSON.parse((await app.inject({ + method: 'POST', url: '/api/sites', payload: { name: 'HQ' }, + })).body); + const room = JSON.parse((await app.inject({ + method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'Main' }, + })).body); + const type = JSON.parse((await app.inject({ + method: 'POST', url: '/api/server-types', payload: { name: 'Web' }, + })).body); + return { site, room, type }; +} + +export function newHostPayload({ room_id, server_type_id }, suffix = '') { + return { + hardware_id: `HW-${suffix || '1'}`, + hostname: `host-${suffix || '1'}`, + asset_id: `AST-${suffix || '1'}`, + room_id, + position: 'R1-U1', + server_type_id, + }; +} + +export async function seedHost(app, fx, suffix = '') { + const res = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, suffix), + }); + return JSON.parse(res.body); +} diff --git a/tests/hosts.test.js b/tests/hosts.test.js new file mode 100644 index 0000000..53a3fb4 --- /dev/null +++ b/tests/hosts.test.js @@ -0,0 +1,128 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { newApp, seedFixtures, newHostPayload } from './helpers.js'; + +test('hosts: create, get, search, update, delete', async (t) => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + + // Create + const create = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'), + }); + assert.equal(create.statusCode, 201); + const created = JSON.parse(create.body); + assert.equal(created.hostname, 'host-A'); + assert.equal(created.site_name, 'HQ'); + assert.equal(created.room_name, 'Main'); + assert.equal(created.server_type, 'Web'); + + // Get one + const get = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` }); + assert.equal(get.statusCode, 200); + assert.equal(JSON.parse(get.body).hostname, 'host-A'); + + // 404 on missing + const missing = await app.inject({ method: 'GET', url: '/api/hosts/9999' }); + assert.equal(missing.statusCode, 404); + + // Lookup by hardware_id + const byHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/HW-A' }); + assert.equal(byHwid.statusCode, 200); + assert.equal(JSON.parse(byHwid.body).id, created.id); + + // 404 by hardware_id + const missingHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/nope' }); + assert.equal(missingHwid.statusCode, 404); + + // Search by hostname / hardware_id / asset_id + for (const q of ['host', 'HW-A', 'AST-A']) { + const r = await app.inject({ method: 'GET', url: `/api/hosts?q=${encodeURIComponent(q)}` }); + assert.equal(r.statusCode, 200); + const rows = JSON.parse(r.body); + assert.equal(rows.length, 1); + assert.equal(rows[0].hostname, 'host-A'); + } + + // Search is case-insensitive + const ci = await app.inject({ method: 'GET', url: '/api/hosts?q=HOST-a' }); + assert.equal(JSON.parse(ci.body).length, 1); + + // Update + const upd = await app.inject({ + method: 'PUT', url: `/api/hosts/${created.id}`, + payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'), position: 'R5-U10' }, + }); + assert.equal(upd.statusCode, 200); + assert.equal(JSON.parse(upd.body).position, 'R5-U10'); + + // Delete + const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${created.id}` }); + assert.equal(del.statusCode, 204); + const afterDel = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` }); + assert.equal(afterDel.statusCode, 404); +}); + +test('hosts: duplicate hardware_id returns 409', async (t) => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + + const a = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'X'), + }); + assert.equal(a.statusCode, 201); + + const dup = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'Y'), hardware_id: 'HW-X' }, + }); + assert.equal(dup.statusCode, 409); + assert.match(JSON.parse(dup.body).error, /hardware_id/); +}); + +test('hosts: invalid body returns 400 with details', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const r = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: { hostname: '' }, + }); + assert.equal(r.statusCode, 400); + const body = JSON.parse(r.body); + assert.equal(body.error, 'validation failed'); + assert.ok(Array.isArray(body.details) && body.details.length > 0); +}); + +test('hosts: missing FK returns 409', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const r = await app.inject({ + method: 'POST', url: '/api/hosts', + payload: { + hardware_id: 'HW-1', hostname: 'h', asset_id: 'A', + room_id: 999, position: '', server_type_id: 999, + }, + }); + assert.equal(r.statusCode, 409); +}); + +test('hosts: list caps at 200', async (t) => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + + for (let i = 0; i < 205; i++) { + await app.inject({ + method: 'POST', url: '/api/hosts', + payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, String(i).padStart(4, '0')), + }); + } + const r = await app.inject({ method: 'GET', url: '/api/hosts' }); + assert.equal(JSON.parse(r.body).length, 200); +}); diff --git a/tests/interfaces.test.js b/tests/interfaces.test.js new file mode 100644 index 0000000..494a40c --- /dev/null +++ b/tests/interfaces.test.js @@ -0,0 +1,176 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { newApp, seedFixtures, seedHost } from './helpers.js'; + +const validIface = (host_id, name = 'eth0') => ({ + host_id, + name, + mac_address: 'aa:bb:cc:dd:ee:ff', + ip_address: '10.0.0.5', + subnet: '10.0.0.0/24', + link_speed: '1000/full', +}); + +test('interfaces: create, list, get, update, delete', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const host = await seedHost(app, fx, 'A'); + + const create = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: validIface(host.id, 'eth0'), + }); + assert.equal(create.statusCode, 201); + const iface = JSON.parse(create.body); + assert.equal(iface.name, 'eth0'); + assert.equal(iface.host_id, host.id); + assert.equal(iface.ip_address, '10.0.0.5'); + + const list = await app.inject({ + method: 'GET', url: `/api/interfaces?host_id=${host.id}`, + }); + assert.equal(list.statusCode, 200); + assert.equal(JSON.parse(list.body).length, 1); + + const get = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` }); + assert.equal(get.statusCode, 200); + assert.equal(JSON.parse(get.body).name, 'eth0'); + + const upd = await app.inject({ + method: 'PUT', url: `/api/interfaces/${iface.id}`, + payload: { ...validIface(host.id, 'eth0'), ip_address: '10.0.0.99' }, + }); + assert.equal(upd.statusCode, 200); + assert.equal(JSON.parse(upd.body).ip_address, '10.0.0.99'); + + const del = await app.inject({ method: 'DELETE', url: `/api/interfaces/${iface.id}` }); + assert.equal(del.statusCode, 204); + const after404 = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` }); + assert.equal(after404.statusCode, 404); +}); + +test('interfaces: optional fields may be empty strings', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const host = await seedHost(app, fx, 'B'); + + const r = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth0' }, + }); + assert.equal(r.statusCode, 201); + const iface = JSON.parse(r.body); + assert.equal(iface.mac_address, ''); + assert.equal(iface.ip_address, ''); + assert.equal(iface.subnet, ''); + assert.equal(iface.link_speed, ''); +}); + +test('interfaces: duplicate (host_id, name) returns 409', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const host = await seedHost(app, fx, 'C'); + + const a = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth0' }, + }); + assert.equal(a.statusCode, 201); + + const dup = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth0' }, + }); + assert.equal(dup.statusCode, 409); + assert.match(JSON.parse(dup.body).error, /already exists/); +}); + +test('interfaces: same name allowed on different hosts', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const h1 = await seedHost(app, fx, 'D1'); + const h2 = await seedHost(app, fx, 'D2'); + + const a = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: h1.id, name: 'eth0' }, + }); + const b = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: h2.id, name: 'eth0' }, + }); + assert.equal(a.statusCode, 201); + assert.equal(b.statusCode, 201); +}); + +test('interfaces: missing host FK returns 409', async () => { + const app = await newApp(); + after(() => app.close()); + + const r = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: 9999, name: 'eth0' }, + }); + assert.equal(r.statusCode, 409); +}); + +test('interfaces: invalid formats return 400 with details', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const host = await seedHost(app, fx, 'E'); + + const cases = [ + { mac_address: 'zz:zz:zz:zz:zz:zz' }, + { ip_address: '999.0.0.1' }, + { subnet: '10.0.0.0/99' }, + { link_speed: '1000/weird' }, + ]; + for (const extra of cases) { + const r = await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth0', ...extra }, + }); + assert.equal(r.statusCode, 400, `expected 400 for ${JSON.stringify(extra)}`); + const body = JSON.parse(r.body); + assert.equal(body.error, 'validation failed'); + assert.ok(Array.isArray(body.details) && body.details.length > 0); + } +}); + +test('interfaces: deleting the host cascades', async () => { + const app = await newApp(); + after(() => app.close()); + const fx = await seedFixtures(app); + const host = await seedHost(app, fx, 'F'); + + await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth0' }, + }); + await app.inject({ + method: 'POST', url: '/api/interfaces', + payload: { host_id: host.id, name: 'eth1' }, + }); + + const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${host.id}` }); + assert.equal(del.statusCode, 204); + + const list = await app.inject({ + method: 'GET', url: `/api/interfaces?host_id=${host.id}`, + }); + assert.equal(list.statusCode, 200); + assert.equal(JSON.parse(list.body).length, 0); +}); + +test('interfaces: list requires host_id', async () => { + const app = await newApp(); + after(() => app.close()); + + const r = await app.inject({ method: 'GET', url: '/api/interfaces' }); + assert.equal(r.statusCode, 400); +}); diff --git a/tests/rooms.test.js b/tests/rooms.test.js new file mode 100644 index 0000000..a6aa12b --- /dev/null +++ b/tests/rooms.test.js @@ -0,0 +1,57 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { newApp } from './helpers.js'; + +test('rooms: CRUD with site filter', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body); + const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body); + + await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'A' } }); + await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'B' } }); + await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'C' } }); + + const all = JSON.parse((await app.inject({ method: 'GET', url: '/api/rooms' })).body); + assert.equal(all.length, 3); + + const onlyS1 = JSON.parse((await app.inject({ method: 'GET', url: `/api/rooms?site_id=${s1.id}` })).body); + assert.equal(onlyS1.length, 2); + assert.ok(onlyS1.every((r) => r.site_id === s1.id)); +}); + +test('rooms: same name allowed in different sites, blocked in same site', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body); + const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body); + + const a = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } }); + assert.equal(a.statusCode, 201); + const b = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'X' } }); + assert.equal(b.statusCode, 201); + const dup = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } }); + assert.equal(dup.statusCode, 409); +}); + +test('rooms: room with hosts cannot be deleted', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body); + const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body); + const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'T' } })).body); + + await app.inject({ + method: 'POST', url: '/api/hosts', + payload: { + hardware_id: 'HW', hostname: 'h', asset_id: 'A', + room_id: room.id, position: '', server_type_id: type.id, + }, + }); + + const r = await app.inject({ method: 'DELETE', url: `/api/rooms/${room.id}` }); + assert.equal(r.statusCode, 409); +}); diff --git a/tests/server-types.test.js b/tests/server-types.test.js new file mode 100644 index 0000000..215240d --- /dev/null +++ b/tests/server-types.test.js @@ -0,0 +1,47 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { newApp } from './helpers.js'; + +test('server-types: full CRUD', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const created = await app.inject({ + method: 'POST', url: '/api/server-types', payload: { name: 'Database' }, + }); + assert.equal(created.statusCode, 201); + const t1 = JSON.parse(created.body); + + const dup = await app.inject({ + method: 'POST', url: '/api/server-types', payload: { name: 'Database' }, + }); + assert.equal(dup.statusCode, 409); + + const upd = await app.inject({ + method: 'PUT', url: `/api/server-types/${t1.id}`, payload: { name: 'DB' }, + }); + assert.equal(JSON.parse(upd.body).name, 'DB'); + + const del = await app.inject({ method: 'DELETE', url: `/api/server-types/${t1.id}` }); + assert.equal(del.statusCode, 204); +}); + +test('server-types: cannot delete one referenced by a host (409)', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body); + const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body); + const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'Web' } })).body); + + await app.inject({ + method: 'POST', url: '/api/hosts', + payload: { + hardware_id: 'HW', hostname: 'h', asset_id: 'A', + room_id: room.id, position: '', server_type_id: type.id, + }, + }); + + const r = await app.inject({ method: 'DELETE', url: `/api/server-types/${type.id}` }); + assert.equal(r.statusCode, 409); +}); diff --git a/tests/sites.test.js b/tests/sites.test.js new file mode 100644 index 0000000..d33e771 --- /dev/null +++ b/tests/sites.test.js @@ -0,0 +1,54 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { newApp } from './helpers.js'; + +test('sites: full CRUD', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const empty = await app.inject({ method: 'GET', url: '/api/sites' }); + assert.deepEqual(JSON.parse(empty.body), []); + + const created = await app.inject({ + method: 'POST', url: '/api/sites', payload: { name: 'DC1' }, + }); + assert.equal(created.statusCode, 201); + const site = JSON.parse(created.body); + assert.equal(site.name, 'DC1'); + + const list = await app.inject({ method: 'GET', url: '/api/sites' }); + assert.equal(JSON.parse(list.body).length, 1); + + const upd = await app.inject({ + method: 'PUT', url: `/api/sites/${site.id}`, payload: { name: 'DC2' }, + }); + assert.equal(upd.statusCode, 200); + assert.equal(JSON.parse(upd.body).name, 'DC2'); + + const del = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` }); + assert.equal(del.statusCode, 204); +}); + +test('sites: duplicate name returns 409', async (t) => { + const app = await newApp(); + after(() => app.close()); + + await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } }); + const dup = await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } }); + assert.equal(dup.statusCode, 409); +}); + +test('sites: cannot delete site that has rooms (409)', async (t) => { + const app = await newApp(); + after(() => app.close()); + + const site = JSON.parse((await app.inject({ + method: 'POST', url: '/api/sites', payload: { name: 'S' }, + })).body); + await app.inject({ + method: 'POST', url: '/api/rooms', + payload: { site_id: site.id, name: 'R' }, + }); + const r = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` }); + assert.equal(r.statusCode, 409); +});