diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a158f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.github +pics +*.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..56597a7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy to racknerd1 + +on: + push: + branches: [main] + workflow_dispatch: {} + +env: + DEPLOY_PATH: /opt/archnest + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Copy repo to racknerd1 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.RACKNERD_HOST }} + username: ${{ secrets.RACKNERD_USER }} + key: ${{ secrets.RACKNERD_SSH_KEY }} + port: ${{ secrets.RACKNERD_PORT || 22 }} + source: "." + target: ${{ env.DEPLOY_PATH }} + rm: false + + - name: Build and restart container + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.RACKNERD_HOST }} + username: ${{ secrets.RACKNERD_USER }} + key: ${{ secrets.RACKNERD_SSH_KEY }} + port: ${{ secrets.RACKNERD_PORT || 22 }} + script: | + cd ${{ env.DEPLOY_PATH }} + docker compose up -d --build + docker image prune -f diff --git a/.gitignore b/.gitignore index a547bf3..fc9be78 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ dist dist-ssr *.local +# Backend data/secrets +backend/data +backend/.env +*.db +*.db-journal +*.db-wal +*.db-shm +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c7305e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8080 diff --git a/README.md b/README.md index 7dbf7eb..450f1bf 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,39 @@ -# React + TypeScript + Vite +# ArchNest -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +A self-hosted ops dashboard — infrastructure monitoring, a bookmark hub for your homelab/cloud links, an embedded terminal, and system settings, all in one place. -Currently, two official plugins are available: +Built with React 19 + TypeScript + Vite, styled with Tailwind CSS v4, charts via Recharts, icons via Lucide React. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## Pages -## React Compiler +| Page | Route | Status | +|------|-------|--------| +| Glance | `/` | Done — main ops dashboard (system status, resource overview, alerts, network traffic) | +| Infrastructure | `/infrastructure` | Done — resource distribution, node status grid, cost/trend breakdown. "Network" sub-tab planned as a future addition. | +| BookNest | `/booknest` | Done — categorized bookmark hub with quick access, favorites, link health, and category breakdown | +| Terminal | `/terminal` | Pending — will be based on a fork of the (archived) Termix project, not yet merged in | +| Settings | `/settings` | Done — Profile (incl. avatar upload), Appearance, Integrations, Notifications, Data & Backup, About | -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +See `archnest-blueprint.md` for the original per-page design spec and `design-decisions.md` for the visual/UX conventions and lessons learned while building each page — read that file before making layout changes, it documents *why* things are built the way they are (hero banner layering, card blend techniques, icon library gotchas, etc.). -## Expanding the ESLint configuration +## Development -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +Type-check with `npx tsc --noEmit` before committing — Vite/the browser surface some runtime errors (e.g. missing icon exports) that the type-checker won't catch, so also smoke-test pages in a browser. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## Tech Stack -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +- React 19 + Vite + TypeScript +- React Router for routing +- Tailwind CSS v4 +- Recharts (donuts, line/area charts) +- Lucide React (icons) +- Deploy target: Docker on racknerd1 → NPM proxy at archnest.snsnetlabs.com + +## Deployment + +This project is deployed via Docker on `racknerd1`, proxied through Nginx Proxy Manager at `archnest.snsnetlabs.com`. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d3f0466 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +PORT=4000 +ARCHNEST_DB_PATH=./data/archnest.db +ARCHNEST_JWT_SECRET=change-me-to-a-long-random-string +ARCHNEST_SECRET_KEY=change-me-to-another-long-random-string +ARCHNEST_CORS_ORIGIN=http://localhost:5173 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b2814f7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev=false +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +EXPOSE 4000 +CMD ["node", "dist/server.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..4d283bd --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1865 @@ +{ + "name": "archnest-backend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "archnest-backend", + "version": "0.0.0", + "dependencies": { + "@fastify/cors": "^10.0.1", + "@fastify/jwt": "^9.0.4", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.8.1", + "dotenv": "^16.6.1", + "fastify": "^5.2.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.5", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.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/jwt": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-9.1.0.tgz", + "integrity": "sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^5.0.0", + "fastify-plugin": "^5.0.0", + "steed": "^1.1.3" + } + }, + "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/@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/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "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/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "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-jwt": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-5.0.6.tgz", + "integrity": "sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "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/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "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/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "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/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "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.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "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/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "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/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..fc55efc --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "archnest-backend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -b", + "start": "node dist/server.js" + }, + "dependencies": { + "@fastify/cors": "^10.0.1", + "@fastify/jwt": "^9.0.4", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.8.1", + "dotenv": "^16.6.1", + "fastify": "^5.2.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.5", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/backend/src/db/crypto.ts b/backend/src/db/crypto.ts new file mode 100644 index 0000000..16e49eb --- /dev/null +++ b/backend/src/db/crypto.ts @@ -0,0 +1,25 @@ +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto' + +const rawKey = process.env.ARCHNEST_SECRET_KEY +if (!rawKey) { + throw new Error('ARCHNEST_SECRET_KEY env var is required to encrypt integration secrets') +} +const key = createHash('sha256').update(rawKey).digest() + +export function encryptSecret(plaintext: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + return Buffer.concat([iv, authTag, encrypted]).toString('base64') +} + +export function decryptSecret(payload: string): string { + const buf = Buffer.from(payload, 'base64') + const iv = buf.subarray(0, 12) + const authTag = buf.subarray(12, 28) + const encrypted = buf.subarray(28) + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8') +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..da75948 --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,72 @@ +import Database from 'better-sqlite3' +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +const DB_PATH = process.env.ARCHNEST_DB_PATH ?? './data/archnest.db' +mkdirSync(dirname(DB_PATH), { recursive: true }) + +export const db = new Database(DB_PATH) +db.pragma('journal_mode = WAL') +db.pragma('foreign_keys = ON') + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT, + email TEXT, + avatar_data_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS integrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'unknown', + config_json TEXT NOT NULL DEFAULT '{}', + last_checked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value_encrypted TEXT NOT NULL, + UNIQUE(integration_id, key) + ); + + CREATE TABLE IF NOT EXISTS bookmark_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + icon TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER REFERENCES bookmark_categories(id) ON DELETE SET NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT, + favorite INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + last_checked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + title TEXT NOT NULL, + source TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`) + +export function logEvent(type: string, title: string, source?: string | null) { + db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null) +} diff --git a/backend/src/integrations/docker.ts b/backend/src/integrations/docker.ts new file mode 100644 index 0000000..b2e3a3e --- /dev/null +++ b/backend/src/integrations/docker.ts @@ -0,0 +1,28 @@ +import type { IntegrationAdapter, Resource } from './types.js' + +export const docker: IntegrationAdapter = { + async testConnection(config) { + const baseUrl = config.baseUrl?.replace(/\/$/, '') + if (!baseUrl) return { ok: false, message: 'Missing baseUrl' } + try { + const res = await fetch(`${baseUrl}/version`) + if (!res.ok) return { ok: false, message: `HTTP ${res.status}` } + return { ok: true, message: 'Connected' } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } + } + }, + + async listResources(config): Promise { + const baseUrl = config.baseUrl?.replace(/\/$/, '') + if (!baseUrl) return [] + const res = await fetch(`${baseUrl}/containers/json?all=true`) + if (!res.ok) return [] + const containers = (await res.json()) as { Names: string[]; State: string }[] + return containers.map((c) => ({ + name: c.Names[0]?.replace(/^\//, '') ?? 'unknown', + status: c.State === 'running' ? 'healthy' : c.State === 'restarting' ? 'warning' : 'critical', + detail: c.State, + })) + }, +} diff --git a/backend/src/integrations/registry.ts b/backend/src/integrations/registry.ts new file mode 100644 index 0000000..cf1e0d0 --- /dev/null +++ b/backend/src/integrations/registry.ts @@ -0,0 +1,19 @@ +import type { IntegrationAdapter, IntegrationType } from './types.js' +import { uptimeKuma } from './uptimeKuma.js' +import { docker } from './docker.js' + +const notImplemented: IntegrationAdapter = { + async testConnection() { + return { ok: false, message: 'Test connection not yet implemented for this integration type' } + }, +} + +export const adapterRegistry: Record = { + uptime_kuma: uptimeKuma, + docker, + proxmox: notImplemented, + netbird: notImplemented, + cloudflare: notImplemented, + aws: notImplemented, + weather: notImplemented, +} diff --git a/backend/src/integrations/types.ts b/backend/src/integrations/types.ts new file mode 100644 index 0000000..c25ab42 --- /dev/null +++ b/backend/src/integrations/types.ts @@ -0,0 +1,28 @@ +export type IntegrationType = + | 'proxmox' + | 'docker' + | 'netbird' + | 'cloudflare' + | 'aws' + | 'uptime_kuma' + | 'weather' + +export interface IntegrationConfig { + [key: string]: string +} + +export interface TestResult { + ok: boolean + message: string +} + +export interface Resource { + name: string + status: 'healthy' | 'warning' | 'critical' | 'unknown' + detail?: string +} + +export interface IntegrationAdapter { + testConnection(config: IntegrationConfig, secrets: Record): Promise + listResources?(config: IntegrationConfig, secrets: Record): Promise +} diff --git a/backend/src/integrations/uptimeKuma.ts b/backend/src/integrations/uptimeKuma.ts new file mode 100644 index 0000000..71e628d --- /dev/null +++ b/backend/src/integrations/uptimeKuma.ts @@ -0,0 +1,15 @@ +import type { IntegrationAdapter } from './types.js' + +export const uptimeKuma: IntegrationAdapter = { + async testConnection(config) { + const baseUrl = config.baseUrl?.replace(/\/$/, '') + if (!baseUrl) return { ok: false, message: 'Missing baseUrl' } + try { + const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`) + if (!res.ok) return { ok: false, message: `HTTP ${res.status}` } + return { ok: true, message: 'Connected' } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } + } + }, +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..74abc23 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance } from 'fastify' +import bcrypt from 'bcryptjs' +import { z } from 'zod' +import { db, logEvent } from '../db/index.js' + +const credentialsSchema = z.object({ + username: z.string().min(3).max(64), + password: z.string().min(8).max(256), +}) + +export async function authRoutes(app: FastifyInstance) { + app.get('/api/system/setup-status', async () => { + const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number } + return { needsSetup: row.count === 0 } + }) + + app.post('/api/setup', async (req, reply) => { + const existing = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number } + if (existing.count > 0) { + return reply.code(409).send({ error: 'Setup already completed' }) + } + const parsed = credentialsSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const { username, password } = parsed.data + const passwordHash = await bcrypt.hash(password, 12) + const result = db + .prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') + .run(username, passwordHash) + const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username }) + logEvent('account_created', `Account created for ${username}`) + return { token } + }) + + app.post('/api/auth/login', async (req, reply) => { + const parsed = credentialsSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: 'Invalid input' }) + } + const { username, password } = parsed.data + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as + | { id: number; username: string; password_hash: string } + | undefined + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + return reply.code(401).send({ error: 'Invalid username or password' }) + } + const token = app.jwt.sign({ sub: user.id, username: user.username }) + logEvent('user_login', `${user.username} logged in`) + return { token } + }) + + app.get('/api/auth/me', { onRequest: [app.authenticate] }, async (req) => { + const payload = req.user as { sub: number; username: string } + const user = db + .prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?') + .get(payload.sub) + return { user } + }) +} diff --git a/backend/src/routes/bookmarks.ts b/backend/src/routes/bookmarks.ts new file mode 100644 index 0000000..b7f17cc --- /dev/null +++ b/backend/src/routes/bookmarks.ts @@ -0,0 +1,81 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { db, logEvent } from '../db/index.js' + +const bookmarkSchema = z.object({ + categoryId: z.number().int().nullable().optional(), + title: z.string().min(1).max(128), + url: z.string().url(), + icon: z.string().optional(), + favorite: z.boolean().optional(), +}) + +const categorySchema = z.object({ + name: z.string().min(1).max(64), + icon: z.string().optional(), + sortOrder: z.number().int().optional(), +}) + +export async function bookmarkRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/bookmarks/categories', async () => { + const categories = db.prepare('SELECT * FROM bookmark_categories ORDER BY sort_order').all() + return { categories } + }) + + app.post('/api/bookmarks/categories', async (req, reply) => { + const parsed = categorySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' }) + const { name, icon, sortOrder } = parsed.data + const result = db + .prepare('INSERT INTO bookmark_categories (name, icon, sort_order) VALUES (?, ?, ?)') + .run(name, icon ?? null, sortOrder ?? 0) + return reply.code(201).send({ id: result.lastInsertRowid }) + }) + + app.get('/api/bookmarks', async () => { + const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all() + return { bookmarks } + }) + + app.post('/api/bookmarks', async (req, reply) => { + const parsed = bookmarkSchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' }) + const { categoryId, title, url, icon, favorite } = parsed.data + const result = db + .prepare( + 'INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)' + ) + .run(categoryId ?? null, title, url, icon ?? null, favorite ? 1 : 0) + logEvent('bookmark_created', `Bookmark added: ${title}`) + return reply.code(201).send({ id: result.lastInsertRowid }) + }) + + app.put('/api/bookmarks/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const parsed = bookmarkSchema.partial().safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' }) + const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as + | { category_id: number | null; title: string; url: string; icon: string | null; favorite: number } + | undefined + if (!existing) return reply.code(404).send({ error: 'Not found' }) + const categoryId = parsed.data.categoryId ?? existing.category_id + const title = parsed.data.title ?? existing.title + const url = parsed.data.url ?? existing.url + const icon = parsed.data.icon ?? existing.icon + const favorite = parsed.data.favorite ?? !!existing.favorite + db.prepare( + 'UPDATE bookmarks SET category_id = ?, title = ?, url = ?, icon = ?, favorite = ? WHERE id = ?' + ).run(categoryId, title, url, icon, favorite ? 1 : 0, id) + return { ok: true } + }) + + app.delete('/api/bookmarks/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const existing = db.prepare('SELECT title FROM bookmarks WHERE id = ?').get(id) as { title: string } | undefined + db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id) + if (existing) logEvent('bookmark_deleted', `Bookmark removed: ${existing.title}`) + return reply.code(204).send() + }) +} diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 0000000..47e5004 --- /dev/null +++ b/backend/src/routes/events.ts @@ -0,0 +1,13 @@ +import type { FastifyInstance } from 'fastify' +import { db } from '../db/index.js' + +export async function eventRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/events', async (req) => { + const query = req.query as { limit?: string } + const limit = Math.min(Number(query.limit) || 20, 100) + const events = db.prepare('SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ?').all(limit) + return { events } + }) +} diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts new file mode 100644 index 0000000..6380405 --- /dev/null +++ b/backend/src/routes/integrations.ts @@ -0,0 +1,163 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { db, logEvent } from '../db/index.js' +import { encryptSecret, decryptSecret } from '../db/crypto.js' +import { adapterRegistry } from '../integrations/registry.js' +import type { IntegrationType, Resource } from '../integrations/types.js' + +const integrationTypes = [ + 'proxmox', + 'docker', + 'netbird', + 'cloudflare', + 'aws', + 'uptime_kuma', + 'weather', +] as const + +const createSchema = z.object({ + type: z.enum(integrationTypes), + name: z.string().min(1).max(128), + config: z.record(z.string(), z.string()).default({}), + secrets: z.record(z.string(), z.string()).default({}), +}) + +interface IntegrationRow { + id: number + type: string + name: string + enabled: number + status: string + config_json: string + last_checked_at: string | null + created_at: string +} + +function serialize(row: IntegrationRow) { + return { + id: row.id, + type: row.type, + name: row.name, + enabled: !!row.enabled, + status: row.status, + config: JSON.parse(row.config_json), + lastCheckedAt: row.last_checked_at, + createdAt: row.created_at, + } +} + +function loadSecrets(integrationId: number): Record { + const rows = db + .prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?') + .all(integrationId) as { key: string; value_encrypted: string }[] + const out: Record = {} + for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted) + return out +} + +export async function integrationRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/integrations', async () => { + const rows = db.prepare('SELECT * FROM integrations ORDER BY created_at').all() as IntegrationRow[] + return { integrations: rows.map(serialize) } + }) + + app.post('/api/integrations', async (req, reply) => { + const parsed = createSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const { type, name, config, secrets } = parsed.data + const result = db + .prepare('INSERT INTO integrations (type, name, config_json) VALUES (?, ?, ?)') + .run(type, name, JSON.stringify(config)) + const integrationId = Number(result.lastInsertRowid) + const insertSecret = db.prepare( + 'INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)' + ) + for (const [key, value] of Object.entries(secrets)) { + insertSecret.run(integrationId, key, encryptSecret(value)) + } + const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow + logEvent('integration_created', `${name} integration added`, type) + return reply.code(201).send({ integration: serialize(row) }) + }) + + app.put('/api/integrations/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const parsed = createSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as + | IntegrationRow + | undefined + if (!existing) return reply.code(404).send({ error: 'Not found' }) + + const name = parsed.data.name ?? existing.name + const config = parsed.data.config ?? JSON.parse(existing.config_json) + db.prepare('UPDATE integrations SET name = ?, config_json = ? WHERE id = ?').run( + name, + JSON.stringify(config), + id + ) + if (parsed.data.secrets) { + const upsert = db.prepare( + `INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?) + ON CONFLICT(integration_id, key) DO UPDATE SET value_encrypted = excluded.value_encrypted` + ) + for (const [key, value] of Object.entries(parsed.data.secrets)) { + upsert.run(id, key, encryptSecret(value)) + } + } + const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow + return { integration: serialize(row) } + }) + + app.delete('/api/integrations/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined + db.prepare('DELETE FROM integrations WHERE id = ?').run(id) + if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type) + return reply.code(204).send() + }) + + app.post('/api/integrations/:id/test', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as + | IntegrationRow + | undefined + if (!row) return reply.code(404).send({ error: 'Not found' }) + + const adapter = adapterRegistry[row.type as IntegrationType] + const config = JSON.parse(row.config_json) + const secrets = loadSecrets(id) + const result = await adapter.testConnection(config, secrets) + const status = result.ok ? 'connected' : 'error' + db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run( + status, + id + ) + logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type) + return result + }) + + app.get('/api/integrations/resources', async () => { + const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[] + const resources: (Resource & { integration: string })[] = [] + for (const row of rows) { + const adapter = adapterRegistry[row.type as IntegrationType] + if (!adapter.listResources) continue + const config = JSON.parse(row.config_json) + const secrets = loadSecrets(row.id) + try { + const found = await adapter.listResources(config, secrets) + for (const r of found) resources.push({ ...r, integration: row.name }) + } catch { + // adapter unreachable — skip, connection test already surfaces this + } + } + return { resources } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3ccb11d --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,39 @@ +import 'dotenv/config' +import Fastify from 'fastify' +import cors from '@fastify/cors' +import jwt from '@fastify/jwt' +import { authRoutes } from './routes/auth.js' +import { integrationRoutes } from './routes/integrations.js' +import { bookmarkRoutes } from './routes/bookmarks.js' +import { eventRoutes } from './routes/events.js' + +const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET +if (!JWT_SECRET) { + throw new Error('ARCHNEST_JWT_SECRET env var is required') +} + +const app = Fastify({ logger: true }) + +await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true }) +await app.register(jwt, { secret: JWT_SECRET }) + +app.decorate('authenticate', async function (req, reply) { + try { + await req.jwtVerify() + } catch { + reply.code(401).send({ error: 'Unauthorized' }) + } +}) + +await app.register(authRoutes) +await app.register(integrationRoutes) +await app.register(bookmarkRoutes) +await app.register(eventRoutes) + +app.get('/api/health', async () => ({ ok: true })) + +const port = Number(process.env.PORT ?? 4000) +app.listen({ port, host: '0.0.0.0' }).catch((err) => { + app.log.error(err) + process.exit(1) +}) diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts new file mode 100644 index 0000000..3f72503 --- /dev/null +++ b/backend/src/types.d.ts @@ -0,0 +1,14 @@ +import '@fastify/jwt' + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { sub: number; username: string } + user: { sub: number; username: string } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..6786e8b --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false + }, + "include": ["src"] +} diff --git a/design-decisions.md b/design-decisions.md index 99115f4..baacc32 100644 --- a/design-decisions.md +++ b/design-decisions.md @@ -8,8 +8,8 @@ ## Global Rules (Apply to Every Page) ### Sidebar -- **Expanded width**: 100px (not 80px — needs room for labels) -- **Collapsed width**: 60px (icon only) +- **Expanded width**: 200px (matches mockup proportions — needs room for labels) +- **Collapsed width**: 64px (icon only) - **User can manually collapse/expand** via toggle button (not just responsive) - **Main content margin-left** must match sidebar width exactly @@ -80,3 +80,146 @@ - Bottom row: 2 columns (65/35) - Network Traffic card has its own background image at low opacity - User avatar dropdown has: Profile, Appearance, Security, Help & Support, Sign Out + +### Infrastructure Page +- Hero banner rendered at the **App.tsx layout level** (not per-page), so it can extend + behind the sticky TopBar — conditional via `showHero = location.pathname === '/infrastructure'`. + TopBar and the search input are transparent on hero routes so the banner shows through. +- Hero image: `object-position: center 5%` to reveal the full arch + sky; faded out via + `linear-gradient` mask (vertical) + a `radial-gradient(ellipse 70% 100% at center, ...)` + overlay (sides/corners) — borderless, blends into page background. +- Sub-tabs trimmed to **Overview only** (Compute → Tags are future work, not built yet). +- Status cards: `rgba(10,10,12,0.5)` background (more transparent than other pages), + content centered (`justify-content/alignItems: center`) with a fixed row height (110px) + so there's breathing room instead of empty space below left-aligned content. +- Middle row (`grid-cols-[1fr_1.6fr]`): **Resource Distribution** (donut) and **Node Status** + (server tile grid). Both use the `/blank-kpi-bg.png` background art with a `cardDim` + (semi-transparent dark overlay) + `cardVignette` (radial-gradient `closest-side` blend) + combo — keeps the background pattern visible but subdued, with borders blended rather + than hard-edged. Card titles are rendered as our own text, NOT baked into the image + (baked-in labels got covered by the dim overlay). +- **Node Status** card: originally a world-map-style region dot plot, replaced with a + 4-column tile grid (one tile per server, colored status dot + name) — a world map + didn't make sense for a small/single-site infra. Reuse this "small-scale" reasoning + for any future map-like cards. +- Bottom row (`grid-cols-[1.4fr_1fr_1fr]`): Resource Trend / Cost Breakdown / Recent + Activity — **left plain/regular**, no dim/vignette blending (explicit user preference, + only the middle row gets the hero-style blend). Resource Trend uses the + `/archnest-network-traffic-bg.png` background (plain, no dim/vignette) with 4 trend + lines: blue `#3B82F6` (compute), orange `#E67E22` (storage), green `#2ECC71` (database), + brown `#8B5E3C` (network). +- `cardVignette` radial-gradient must use the `closest-side` keyword (not a fixed `%`) + — otherwise straight edges of the card don't reach full opacity and a hard border line + remains visible (only corners fade correctly with a fixed percentage). + +### BookNest Page +- Hero banner reused at the App.tsx layout level (`showHero` now includes `/booknest`), + with page-specific tuning via small lookup maps in `App.tsx` keyed on `location.pathname`: + `heroPaddingTop` (how far content sits below the hero top) and `heroObjectPosition` + (horizontal/vertical crop of the arch image) — `70px` / `54% 8%` for BookNest vs. + `72px` / `center 5%` for Infrastructure. Extend these maps rather than hardcoding a + single value when a future page needs different hero framing. +- **Large hero title + subtitle**: unlike other pages, BookNest's TopBar title is NOT the + small 18px uppercase label — it's rendered at 28px with a subtitle line ("Your Digital + Library") underneath, driven by a new `pageSubtitles` map in `TopBar.tsx`. When a page + has a subtitle, the header height grows from 56px → 72px (`TopBar.tsx`), and `App.tsx`'s + `topBarHeight` lookup keeps the content section's `calc(100vh - Npx)` in sync — both + must be updated together or the layout will clip/gap. +- **Stats row lives directly under the hero subtitle** (Links / Categories / Favorites), + not in its own separate bar — matches the blueprint's hero-header block grouping. +- **"Quick Access" section label** added above the 5 quick-access category cards (gold, + same `sectionTitle` style) — the row is intentionally pulled up via a small negative + hero-padding tune so it slightly overlaps the bottom edge of the hero, like the blueprint. +- **"+ Add Bookmark" button**: same gold-fill button style as Infrastructure's "+ Add + Resource", placed inline next to the "Quick Access" label rather than the page-stats row. +- **Right sidebar spans both grid rows** (`gridRow: '1 / span 2'` in a `gridTemplateRows: + 'auto 1fr'` grid) so the Favorites card can rise up near the hero while the main column's + page-stats row stays in row 1 of column 1 only — keeps the two from overlapping/clipping. + Negative margins were tried first and discarded: content pushed above the scroll + container's natural top edge gets clipped by `overflow-y-auto`, so prefer reshaping the + grid/flow over negative-margin hacks when something needs to "reach upward." +- **Sidebar cards stretch to match the main column's full height** so the last card's + (Category Breakdown) bottom border lines up with the bottom of the bookmark groups grid: + sidebar wrapper is `display:flex; flex-direction:column; height:100%` (grid's default + `align-items: stretch` already gives it the matching height), and each card uses + `flex: 1 0 auto` (Favorites gets `flex: 1.4 0 auto` to read as visibly taller, per + explicit request) so they share the leftover vertical space instead of all packing tight + at content height. +- lucide-react gotcha (see Global Rules candidate): the installed version does **not** + export brand/wordmark icons (`Github`, `Gitlab`, `Linkedin`, `Youtube`) even though + TypeScript's type declarations list them — `tsc --noEmit` stays clean while the page + renders blank with a runtime `SyntaxError` only visible in the Vite dev log. Verify + icon names against `Object.keys(require('lucide-react'))` before importing anything + brand-flavored; substitutes used here: `GitBranch`/`GitFork` (GitHub/GitLab/Gitea), + `SquarePlay` (YouTube), `Briefcase` (LinkedIn Learning). + +### Settings Page +- No mockup image existed for this page — built directly from the blueprint's Page 6 + spec rather than iterating against a screenshot. +- Layout: fixed-width (200px) left nav listing the 6 sections (Profile, Appearance, + Integrations, Notifications, Data & Backup, About) + a scrollable content panel on + the right showing the active section. No hero banner (not in the blueprint spec for + this page, and a settings page doesn't need one). +- Active section is local component state (a string id) mapped through a + `sectionComponents` record to the corresponding section-renderer function — simplest + approach for a page with no routing/deep-linking requirement. +- Shared style helpers (`cardBase`, `sectionTitle`, `labelStyle`, `inputStyle`) plus two + small reusable components defined in the same file: `Toggle` (on/off pill switch) and + `GoldButton` (gold-filled primary / danger-outline variant) — kept local to + `Settings.tsx` rather than extracted, since no other page needs them yet. +- **Integrations** cards mask secret fields (API keys/tokens) behind dots with an eye + icon to reveal/hide, plus a "Test Connection" button per card — matches the blueprint's + explicit "masked secrets with eye toggle" instruction. +- **Avatar upload** (Profile section): the avatar circle is clickable and opens a hidden + `` via a `useRef` + `.click()` call rather than a + visible file input — keeps the round avatar as the only visible control. On change, + `FileReader.readAsDataURL` converts the selected image to a base64 data URL stored in + component state, which becomes the circle's `backgroundImage` (cover-fit), replacing + the "AO" initials fallback. A hover-only camera-icon overlay (Tailwind `group` / + `group-hover:opacity-100`) signals the circle is clickable without cluttering the + default state. This is a frontend-only preview (no backend upload endpoint exists yet). + +--- + +## Backend (added once frontend reached "good enough" state) + +- New `backend/` package (separate `package.json`/`tsconfig.json`, own `node_modules`, + own Dockerfile) — Fastify + TypeScript, `better-sqlite3` for storage, deployed as a + second container alongside the existing frontend container (`docker-compose.yml` now + has `archnest` + `archnest-backend`, with a named volume for the SQLite file). +- Auth: single-user, JWT-based (`@fastify/jwt`). `POST /api/setup` creates the one user + and only succeeds while the `users` table is empty — this is what powers the + first-run enrollment page. `GET /api/system/setup-status` tells the frontend whether + to show enrollment/login or the normal app. +- Integration credentials are split across two tables: `integrations` (type, name, + status, non-secret `config_json`) and `secrets` (per-key AES-256-GCM-encrypted + values, key derived from the `ARCHNEST_SECRET_KEY` env var) — keeps secrets out of any + generic "list integrations" query/response by construction, not by remembering to + redact a field. +- Each integration type has an adapter module under `backend/src/integrations/` + exporting `testConnection(config, secrets)`; `registry.ts` maps `IntegrationType` → + adapter. Only `uptime_kuma` and `docker` are real so far (simple HTTP health checks); + the rest return a "not yet implemented" result until built out — this lets the + Integrations UI and `POST /api/integrations/:id/test` endpoint work end-to-end for + every type without blocking on every adapter being finished. +- Vite dev server proxies `/api` → `http://localhost:4000` (`vite.config.ts`) so the + frontend can call relative `/api/...` paths in both dev and prod (prod routes `/api` + to the backend container via NPM). +- Next steps (not yet done): build the enrollment/login frontend pages, strip the mock + arrays out of Glance/Infrastructure/BookNest/Settings and replace with calls to this + API, add bookmark category seeding. + +## Future Integration Notes + +### Live Provider Data (AWS, Linode, etc.) +- All KPI/status card data (resource counts, health, pricing, budgets, cost breakdowns, + utilization, regions/map data) is currently mocked/static. +- The Infrastructure page (and likely Glance) should eventually integrate with real + cloud provider APIs — AWS, Linode, or any other VPC/cloud provider — via user-supplied + API keys, to pull live data such as: + - Resource inventory/counts and health status + - Pricing and budget/cost data (replacing the static Cost Breakdown numbers) + - Resource utilization metrics + - Region/datacenter info for the Infrastructure Map +- Design the data layer so it's provider-agnostic (a common interface/adapter per + provider) since users may connect more than one provider's API key. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7393402 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + archnest: + build: . + image: archnest:latest + container_name: archnest + restart: unless-stopped + ports: + - "8080:8080" + depends_on: + - archnest-backend + + archnest-backend: + build: ./backend + image: archnest-backend:latest + container_name: archnest-backend + restart: unless-stopped + environment: + - PORT=4000 + - ARCHNEST_DB_PATH=/data/archnest.db + - ARCHNEST_JWT_SECRET=${ARCHNEST_JWT_SECRET} + - ARCHNEST_SECRET_KEY=${ARCHNEST_SECRET_KEY} + - ARCHNEST_CORS_ORIGIN=${ARCHNEST_CORS_ORIGIN:-https://archnest.snsnetlabs.com} + volumes: + - archnest-data:/data + ports: + - "4000:4000" + +volumes: + archnest-data: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..cfb23c8 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json index ae0b1c9..4029398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0", "recharts": "^3.8.1", "tailwindcss": "^4.3.0" }, @@ -61,7 +62,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -271,10 +271,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -793,6 +814,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -1200,7 +1252,6 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1211,7 +1262,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1277,7 +1327,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -1508,7 +1557,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1599,7 +1647,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1651,6 +1698,19 @@ "dev": true, "license": "MIT" }, + "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", @@ -1893,7 +1953,6 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2826,7 +2885,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2887,7 +2945,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2897,7 +2954,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2917,7 +2973,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -2936,6 +2991,44 @@ } } }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -2970,8 +3063,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3037,6 +3129,12 @@ "semver": "bin/semver.js" } }, + "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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3149,7 +3247,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3266,7 +3363,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3391,7 +3487,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index fd16521..897023b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0", "recharts": "^3.8.1", "tailwindcss": "^4.3.0" }, diff --git a/public/archnest-logo-clean.png b/public/archnest-logo-clean.png new file mode 100644 index 0000000..016bf0f Binary files /dev/null and b/public/archnest-logo-clean.png differ diff --git a/public/blank-kpi-bg.png b/public/blank-kpi-bg.png new file mode 100644 index 0000000..ee034fd Binary files /dev/null and b/public/blank-kpi-bg.png differ diff --git a/public/infra-map-kpi.png b/public/infra-map-kpi.png new file mode 100644 index 0000000..847295c Binary files /dev/null and b/public/infra-map-kpi.png differ diff --git a/public/network-kpi-bg.png b/public/network-kpi-bg.png new file mode 100644 index 0000000..b3330f8 Binary files /dev/null and b/public/network-kpi-bg.png differ diff --git a/public/resource-distrabution-bg.png b/public/resource-distrabution-bg.png new file mode 100644 index 0000000..72e2562 Binary files /dev/null and b/public/resource-distrabution-bg.png differ diff --git a/public/resource-utilization.png b/public/resource-utilization.png new file mode 100644 index 0000000..5e67b66 Binary files /dev/null and b/public/resource-utilization.png differ diff --git a/src/App.tsx b/src/App.tsx index f535b1b..e714610 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,39 @@ import { useState } from 'react' +import { Routes, Route, useLocation } from 'react-router-dom' import Sidebar from './components/Sidebar' import TopBar from './components/TopBar' -import StatusCards from './components/StatusCards' -import MiddleRow from './components/MiddleRow' -import BottomRow from './components/BottomRow' +import Glance from './pages/Glance' +import Infrastructure from './pages/Infrastructure' +import BookNest from './pages/BookNest' +import Settings from './pages/Settings' +import Login from './pages/Login' +import Enrollment from './pages/Enrollment' +import { useAuth } from './lib/AuthContext' function App() { + const { status } = useAuth() + + if (status === 'loading') { + return ( +
+

Loading…

+
+ ) + } + if (status === 'needs-setup' || status === 'enrolling') return + if (status === 'logged-out') return + + return +} + +function Dashboard() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) - const sidebarWidth = sidebarCollapsed ? 60 : 140 + const sidebarWidth = sidebarCollapsed ? 64 : 200 + const location = useLocation() + const showHero = location.pathname === '/infrastructure' || location.pathname === '/booknest' + const heroPaddingTop = location.pathname === '/booknest' ? '70px' : '72px' + const heroObjectPosition = location.pathname === '/booknest' ? '54% 8%' : 'center 5%' + const topBarHeight = location.pathname === '/booknest' ? 72 : 56 return (
@@ -17,50 +43,45 @@ function App() { />
- + {showHero && ( +
+ +
+
+ )} + +
+ +
-
- {/* Hero + KPI overlap — KPI bottom aligns with banner bottom */} -
-
- ArchNest Banner { - const target = e.currentTarget - target.style.display = 'none' - target.parentElement!.classList.add('bg-card') - target.parentElement!.style.height = '260px' - }} - /> -
- {/* KPI cards positioned so their bottom edge aligns with banner bottom */} -
- -
-
- - {/* 24px breathing room between KPI row and middle row */} -
- - {/* Middle Row */} - - - {/* Gap */} -
- - {/* Bottom Row */} - -
+ + } /> + } /> + } /> + } /> +
diff --git a/src/components/BottomRow.tsx b/src/components/BottomRow.tsx index 1010050..7e0a427 100644 --- a/src/components/BottomRow.tsx +++ b/src/components/BottomRow.tsx @@ -1,17 +1,13 @@ -import { AreaChart, Area, ResponsiveContainer } from 'recharts' -import { ServerCog, DatabaseBackup, Rocket, FileText } from 'lucide-react' - -const trafficData = Array.from({ length: 48 }, (_, i) => ({ - time: i, - incoming: 800 + Math.sin(i / 6) * 300 + Math.random() * 150, - outgoing: 700 + Math.cos(i / 8) * 250 + Math.random() * 100, -})) +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Plug, ServerCog, BookMarked, Settings as SettingsIcon } from 'lucide-react' +import { api, type Integration } from '../lib/api' const shortcuts = [ - { icon: ServerCog, label: 'Add Server' }, - { icon: DatabaseBackup, label: 'Create Backup' }, - { icon: Rocket, label: 'Deploy App' }, - { icon: FileText, label: 'View Logs' }, + { icon: ServerCog, label: 'Add Integration', to: '/settings' }, + { icon: BookMarked, label: 'Add Bookmark', to: '/booknest' }, + { icon: Plug, label: 'Infrastructure', to: '/infrastructure' }, + { icon: SettingsIcon, label: 'Settings', to: '/settings' }, ] const cardBase: React.CSSProperties = { @@ -25,56 +21,48 @@ const cardBase: React.CSSProperties = { overflow: 'hidden', } +const statusColor: Record = { + connected: '#2ECC71', + error: '#E74C3C', + unknown: '#7A7D85', +} + export default function BottomRow() { + const [integrations, setIntegrations] = useState(null) + const navigate = useNavigate() + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) + }, []) + return (
- {/* Network Traffic */} + {/* Connected Integrations */}
- {/* Background image at very low opacity */} -
- {/* Gold top edge */}

- Network Traffic + Connected Integrations

-
-
- - - - - - - - - - - - - - - - + {integrations === null ? ( +

Loading…

+ ) : integrations.length === 0 ? ( +

No integrations added yet — add one in Settings.

+ ) : ( +
+ {integrations.map((i) => ( +
+ + {i.name} +
+ ))}
-
-
-

Incoming

-

1.23 Gbps

-

↓ 12.4%

-
-
-

Outgoing

-

1.08 Gbps

-

↑ 8.7%

-
-
-
+ )}
- {/* Shortcuts — miniature control panels */} + {/* Shortcuts */}
@@ -82,19 +70,20 @@ export default function BottomRow() {

Shortcuts

-
+
{shortcuts.map((item) => { const Icon = item.icon return ( ) })} diff --git a/src/components/MiddleRow.tsx b/src/components/MiddleRow.tsx index b9afe3d..4f9dfa0 100644 --- a/src/components/MiddleRow.tsx +++ b/src/components/MiddleRow.tsx @@ -1,27 +1,6 @@ -import { X, CircleCheck, Shield, Play, Settings, User } from 'lucide-react' - -const resources = [ - { label: 'Compute', current: 18, max: 24, unit: '' }, - { label: 'Storage', current: 12.4, max: 20, unit: ' TB' }, - { label: 'Database', current: 8, max: 12, unit: '' }, - { label: 'Network', current: 98.7, max: 100, unit: '%' }, - { label: 'Containers', current: 32, max: 40, unit: '' }, -] - -const activities = [ - { icon: CircleCheck, title: 'Backup completed', source: 'Database Cluster 01', time: '2m ago' }, - { icon: Shield, title: 'Security scan completed', source: 'Web Frontend', time: '8m ago' }, - { icon: Play, title: 'Instance launched', source: 'App Server 03', time: '15m ago' }, - { icon: Settings, title: 'Configuration updated', source: 'Load Balancer', time: '22m ago' }, - { icon: User, title: 'User login detected', source: 'admin@archnest.io', time: '35m ago' }, -] - -const alerts = [ - { severity: 'high', title: 'High CPU Usage', source: 'App Server 02', time: '2m ago' }, - { severity: 'medium', title: 'Disk Space Low', source: 'Database Cluster 01', time: '15m ago' }, - { severity: 'medium', title: 'Unauthorized Login Attempt', source: 'Web Frontend', time: '32m ago' }, - { severity: 'medium', title: 'SSL Certificate Expiring', source: 'api.archnest.io', time: '1h ago' }, -] +import { useEffect, useState } from 'react' +import { CircleCheck, AlertTriangle, Plug, Bookmark as BookmarkIcon, LogIn } from 'lucide-react' +import { api, type Event, type Resource, type Integration } from '../lib/api' const cardBase: React.CSSProperties = { backgroundColor: 'rgba(10, 10, 12, 0.92)', @@ -32,111 +11,148 @@ const cardBase: React.CSSProperties = { transition: 'border-color 0.2s ease', position: 'relative', overflow: 'hidden', + height: '100%', + display: 'flex', + flexDirection: 'column', } -function getBarColor(percentage: number) { - if (percentage >= 90) return '#E74C3C' - if (percentage >= 70) return '#E67E22' - return '#C8A434' +const statusColor: Record = { + healthy: '#C8A434', + warning: '#E67E22', + critical: '#E74C3C', + unknown: '#7A7D85', +} + +const eventIcons: Record = { + integration_created: Plug, + integration_tested: CircleCheck, + integration_deleted: Plug, + bookmark_created: BookmarkIcon, + bookmark_deleted: BookmarkIcon, + user_login: LogIn, + account_created: LogIn, +} + +function timeAgo(iso: string) { + const diffMs = Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime() + const mins = Math.floor(diffMs / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` } export default function MiddleRow() { + const [resources, setResources] = useState(null) + const [events, setEvents] = useState(null) + const [integrations, setIntegrations] = useState(null) + + useEffect(() => { + api.listResources().then(({ resources }) => setResources(resources)) + api.listEvents(5).then(({ events }) => setEvents(events)) + api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) + }, []) + + const erroredIntegrations = integrations?.filter((i) => i.status === 'error') ?? [] + const problemResources = resources?.filter((r) => r.status === 'warning' || r.status === 'critical') ?? [] + return ( -
+
{/* Resource Overview */}
- {/* Subtle hex pattern overlay */}
- {/* Gold top edge lighting */}
-
-
-

- Resource Overview -

- -
-
- {resources.map((res) => { - const percentage = res.unit === '%' ? res.current : (res.current / res.max) * 100 - const displayValue = res.unit === '%' ? `${res.current}%` : `${res.current} / ${res.max}${res.unit}` - return ( -
- {res.label} -
-
-
- {displayValue} +
+

+ Resource Overview +

+ {resources === null ? ( +

Loading…

+ ) : resources.length === 0 ? ( +

Connect an integration in Settings to see live resources here.

+ ) : ( +
+ {resources.slice(0, 6).map((r, i) => ( +
+ + {r.name} + {r.integration}
- ) - })} -
+ ))} +
+ )}
- {/* Recent Activity — visually dominant */} + {/* Recent Activity */}
- {/* City grid texture */}
- {/* Gold top edge */}
-
-
-

- Recent Activity -

- -
-
- {activities.map((item, i) => { - const Icon = item.icon - return ( -
-
- +
+

+ Recent Activity +

+ {events === null ? ( +

Loading…

+ ) : events.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {events.map((item) => { + const Icon = eventIcons[item.type] ?? CircleCheck + return ( +
+
+ +
+
+

{item.title}

+ {item.source &&

{item.source}

} +
+ {timeAgo(item.created_at)}
-
-

{item.title}

-

{item.source}

-
- {item.time} -
- ) - })} -
+ ) + })} +
+ )}
{/* Top Alerts */}
- {/* Amber edge lighting */}
-
-
-

- Top Alerts -

- View all -
-
- {alerts.map((alert, i) => ( -
-
-
-

{alert.title}

-

{alert.source}

+
+

+ Top Alerts +

+ {erroredIntegrations.length === 0 && problemResources.length === 0 ? ( +

No alerts — everything connected is healthy.

+ ) : ( +
+ {erroredIntegrations.map((i) => ( +
+ +
+

Connection failing

+

{i.name}

+
- {alert.time} -
- ))} -
+ ))} + {problemResources.map((r, idx) => ( +
+ +
+

{r.name}

+

{r.integration} · {r.detail ?? r.status}

+
+
+ ))} +
+ )}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cd9cb53..2e72817 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ +import { useLocation, Link } from 'react-router-dom' import { LayoutGrid, Server, - Globe, Bookmark, Terminal, Settings, @@ -15,81 +15,96 @@ interface SidebarProps { } const navItems = [ - { icon: LayoutGrid, label: 'Glance', route: '/', active: true }, - { icon: Server, label: 'Infrastructure', route: '/infrastructure', active: false }, - { icon: Globe, label: 'Network', route: '/network', active: false }, - { icon: Bookmark, label: 'BookNest', route: '/booknest', active: false }, - { icon: Terminal, label: 'Terminal', route: '/terminal', active: false }, - { icon: Settings, label: 'Settings', route: '/settings', active: false }, + { icon: LayoutGrid, label: 'Glance', route: '/' }, + { icon: Server, label: 'Infrastructure', route: '/infrastructure' }, + { icon: Bookmark, label: 'BookNest', route: '/booknest' }, + { icon: Terminal, label: 'Terminal', route: '/terminal' }, + { icon: Settings, label: 'Settings', route: '/settings' }, ] export default function Sidebar({ collapsed, onToggle }: SidebarProps) { - const width = collapsed ? 60 : 140 + const width = collapsed ? 64 : 200 + const location = useLocation() return (
)} diff --git a/src/lib/AuthContext.tsx b/src/lib/AuthContext.tsx new file mode 100644 index 0000000..d37de0c --- /dev/null +++ b/src/lib/AuthContext.tsx @@ -0,0 +1,83 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { api, getToken, setToken } from './api' + +type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in' + +interface AuthUser { + id: number + username: string + display_name: string | null + email: string | null + avatar_data_url: string | null +} + +interface AuthContextValue { + status: AuthStatus + user: AuthUser | null + login: (username: string, password: string) => Promise + completeSetup: (username: string, password: string) => Promise + finishEnrollment: () => Promise + logout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + + async function refresh() { + if (getToken()) { + try { + const { user } = await api.me() + setUser(user) + setStatus('logged-in') + return + } catch { + setToken(null) + } + } + const { needsSetup } = await api.getSetupStatus() + setStatus(needsSetup ? 'needs-setup' : 'logged-out') + } + + useEffect(() => { + refresh() + }, []) + + async function login(username: string, password: string) { + const { token } = await api.login(username, password) + setToken(token) + await refresh() + } + + async function completeSetup(username: string, password: string) { + const { token } = await api.setup(username, password) + setToken(token) + const { user } = await api.me() + setUser(user) + setStatus('enrolling') + } + + async function finishEnrollment() { + await refresh() + } + + function logout() { + setToken(null) + setUser(null) + setStatus('logged-out') + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..8ea4791 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,118 @@ +const TOKEN_KEY = 'archnest_token' + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) +} + +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +export async function apiFetch(path: string, options: RequestInit = {}): Promise { + const token = getToken() + const headers: Record = { + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers as Record | undefined), + } + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`/api${path}`, { ...options, headers }) + + if (!res.ok) { + let message = res.statusText + try { + const body = await res.json() + message = body.error ?? message + } catch { + // ignore non-JSON error bodies + } + throw new ApiError(res.status, message) + } + + if (res.status === 204) return undefined as T + return res.json() as Promise +} + +export const api = { + getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'), + setup: (username: string, password: string) => + apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }), + login: (username: string, password: string) => + apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'), + + listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), + createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => + apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }), + updateIntegration: (id: number, data: Partial<{ name: string; config: Record; secrets: Record }>) => + apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteIntegration: (id: number) => apiFetch(`/integrations/${id}`, { method: 'DELETE' }), + testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }), + + listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), + listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'), + createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) => + apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }), + createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) => + apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }), + updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) => + apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteBookmark: (id: number) => apiFetch(`/bookmarks/${id}`, { method: 'DELETE' }), + + listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), + listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'), +} + +export interface Integration { + id: number + type: string + name: string + enabled: boolean + status: string + config: Record + lastCheckedAt: string | null + createdAt: string +} + +export interface Bookmark { + id: number + category_id: number | null + title: string + url: string + icon: string | null + favorite: number + status: string + last_checked_at: string | null + created_at: string +} + +export interface BookmarkCategory { + id: number + name: string + icon: string | null + sort_order: number +} + +export interface Event { + id: number + type: string + title: string + source: string | null + created_at: string +} + +export interface Resource { + name: string + status: 'healthy' | 'warning' | 'critical' | 'unknown' + detail?: string + integration: string +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..a688069 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' +import { AuthProvider } from './lib/AuthContext' createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/src/pages/BookNest.tsx b/src/pages/BookNest.tsx new file mode 100644 index 0000000..68a1dca --- /dev/null +++ b/src/pages/BookNest.tsx @@ -0,0 +1,552 @@ +import { useEffect, useMemo, useState } from 'react' +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' +import { + Link2, + FolderOpen, + Star, + Plus, + Server, + Bot, + Cloud, + Network, + GitBranch, + GitFork, + Box, + Terminal as TerminalIcon, + Database, + Shield, + Workflow, + FileCode, + Router, + Wifi, + BookOpen, + GraduationCap, + SquarePlay, + Briefcase, + Wallet, + CreditCard, + PiggyBank, + TrendingUp, + Calendar, + Mail, + Image, + HardDrive, + FileText, + Plane, + Sparkles, + MessageSquare, + Zap, + Globe2, + Container, + X, + type LucideIcon, +} from 'lucide-react' +import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api' + +const ICONS: Record = { + server: Server, + bot: Bot, + cloud: Cloud, + network: Network, + gitbranch: GitBranch, + gitfork: GitFork, + box: Box, + terminal: TerminalIcon, + database: Database, + shield: Shield, + workflow: Workflow, + filecode: FileCode, + router: Router, + wifi: Wifi, + bookopen: BookOpen, + graduationcap: GraduationCap, + squareplay: SquarePlay, + briefcase: Briefcase, + wallet: Wallet, + creditcard: CreditCard, + piggybank: PiggyBank, + trendingup: TrendingUp, + calendar: Calendar, + mail: Mail, + image: Image, + harddrive: HardDrive, + filetext: FileText, + plane: Plane, + sparkles: Sparkles, + messagesquare: MessageSquare, + zap: Zap, + globe2: Globe2, + container: Container, + link2: Link2, +} + +function resolveIcon(name: string | null | undefined): LucideIcon { + if (!name) return Link2 + return ICONS[name.toLowerCase()] ?? Link2 +} + +const statusColors: Record = { + online: '#2ECC71', + warning: '#E67E22', + offline: '#E74C3C', + unknown: '#7A7D85', +} + +const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C'] + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + padding: '18px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', + transition: 'border-color 0.2s ease', + position: 'relative', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +} + +const sectionTitle: React.CSSProperties = { + fontSize: '11px', + textTransform: 'uppercase', + letterSpacing: '1.5px', + color: '#7A7D85', + fontWeight: 500, + marginBottom: '14px', +} + +const inputStyle: React.CSSProperties = { + backgroundColor: 'rgba(255,255,255,0.03)', + border: '1px solid rgba(200,164,52,0.12)', + borderRadius: '8px', + padding: '9px 12px', + fontSize: '13px', + color: '#E8E6E0', + width: '100%', + outline: 'none', +} + +function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) { + const hasData = data.some((d) => d.value > 0) + return ( +
+
+ {hasData && ( + + + + {data.map((entry) => ( + + ))} + + + + )} + {centerLabel && ( +
+ {centerLabel} +
+ )} +
+
+ {data.map((entry) => ( +
+ + {entry.name} + {entry.value} +
+ ))} +
+
+ ) +} + +function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) { + const Icon = resolveIcon(bookmark.icon) + return ( + + ) +} + +function AddBookmarkModal({ + categories, + onClose, + onCreated, + onCategoryCreated, +}: { + categories: BookmarkCategory[] + onClose: () => void + onCreated: (bookmark: Bookmark) => void + onCategoryCreated: (category: BookmarkCategory) => void +}) { + const [title, setTitle] = useState('') + const [url, setUrl] = useState('') + const [icon, setIcon] = useState('link2') + const [categoryId, setCategoryId] = useState('') + const [newCategoryName, setNewCategoryName] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + if (!title.trim() || !url.trim()) { + setError('Title and URL are required') + return + } + setBusy(true) + try { + let resolvedCategoryId: number | null = null + if (categoryId === 'new') { + if (!newCategoryName.trim()) { + setError('Enter a name for the new category') + setBusy(false) + return + } + const { id } = await api.createBookmarkCategory({ name: newCategoryName.trim() }) + resolvedCategoryId = id + onCategoryCreated({ id, name: newCategoryName.trim(), icon: null, sort_order: 0 }) + } else if (categoryId !== '') { + resolvedCategoryId = categoryId + } + const { id } = await api.createBookmark({ + title: title.trim(), + url: url.trim(), + icon, + categoryId: resolvedCategoryId, + }) + onCreated({ + id, + category_id: resolvedCategoryId, + title: title.trim(), + url: url.trim(), + icon, + favorite: 0, + status: 'unknown', + last_checked_at: null, + created_at: new Date().toISOString(), + }) + onClose() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to create bookmark') + } finally { + setBusy(false) + } + } + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + style={{ ...cardBase, width: '420px', padding: '24px', gap: '14px' }} + > +
+

Add Bookmark

+ +
+ +
+ + setTitle(e.target.value)} placeholder="e.g. Proxmox" /> +
+ +
+ + setUrl(e.target.value)} placeholder="https://..." /> +
+ +
+ + +
+ +
+ + +
+ + {categoryId === 'new' && ( +
+ + setNewCategoryName(e.target.value)} placeholder="e.g. Monitoring" /> +
+ )} + + {error &&

{error}

} + + +
+
+ ) +} + +export default function BookNest() { + const [bookmarks, setBookmarks] = useState(null) + const [categories, setCategories] = useState([]) + const [showAddModal, setShowAddModal] = useState(false) + + useEffect(() => { + Promise.all([api.listBookmarks(), api.listBookmarkCategories()]).then(([b, c]) => { + setBookmarks(b.bookmarks) + setCategories(c.categories) + }) + }, []) + + async function toggleFavorite(bookmark: Bookmark) { + const next = bookmark.favorite ? false : true + setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: next ? 1 : 0 } : b)) ?? prev) + try { + await api.updateBookmark(bookmark.id, { favorite: next }) + } catch { + setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: bookmark.favorite } : b)) ?? prev) + } + } + + const groups = useMemo(() => { + if (!bookmarks) return [] + const byCategory = new Map() + for (const b of bookmarks) { + const key = b.category_id + if (!byCategory.has(key)) byCategory.set(key, []) + byCategory.get(key)!.push(b) + } + const named = categories + .map((c) => ({ title: c.name, links: byCategory.get(c.id) ?? [] })) + .filter((g) => g.links.length > 0) + const uncategorized = byCategory.get(null) ?? [] + return uncategorized.length > 0 ? [...named, { title: 'Uncategorized', links: uncategorized }] : named + }, [bookmarks, categories]) + + const favorites = useMemo(() => (bookmarks ?? []).filter((b) => b.favorite), [bookmarks]) + + const recentlyAdded = useMemo( + () => [...(bookmarks ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).slice(0, 5), + [bookmarks] + ) + + const quickAccess = useMemo( + () => + [...groups] + .sort((a, b) => b.links.length - a.links.length) + .slice(0, 5) + .map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => resolveIcon(l.icon)), count: g.links.length })), + [groups] + ) + + const linkHealthData = useMemo(() => { + const counts: Record = { online: 0, warning: 0, offline: 0, unknown: 0 } + for (const b of bookmarks ?? []) counts[b.status in counts ? b.status : 'unknown']++ + return Object.entries(counts) + .filter(([, value]) => value > 0) + .map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value, color: statusColors[name] })) + }, [bookmarks]) + + const categoryBreakdownData = useMemo( + () => groups.map((g, i) => ({ name: g.title, value: g.links.length, color: categoryPalette[i % categoryPalette.length] })), + [groups] + ) + + if (!bookmarks) { + return ( +
+

Loading bookmarks…

+
+ ) + } + + return ( +
+ {showAddModal && ( + setShowAddModal(false)} + onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])} + onCategoryCreated={(c) => setCategories((prev) => [...prev, c])} + /> + )} +
+ {/* Page stats — sits directly under the hero title/subtitle, like the blueprint */} +
+
+ {bookmarks.length} Links + {categories.length} Categories + {favorites.length} Favorites +
+
+ + {/* Main column */} +
+ {/* Quick Access */} +
+

Quick Access

+ +
+ {quickAccess.length > 0 ? ( +
+ {quickAccess.map((qa) => ( +
+ {qa.label} +
+ {qa.icons.map((Icon, i) => ( +
+ +
+ ))} +
+ {qa.count} links +
+ ))} +
+ ) : ( +
+

No bookmarks yet — add your first one to get started.

+
+ )} + + {/* Bookmark groups grid */} + {groups.length > 0 && ( +
+ {groups.map((group) => ( +
+

{group.title}

+
+ {group.links.map((link) => ( + toggleFavorite(link)} /> + ))} +
+
+ ))} +
+ )} +
+ + {/* Right sidebar — spans both rows so Favorites reaches up near the hero, and stretches to match the main column's full height */} +
+
+

Favorites

+
+ {favorites.length > 0 ? ( + favorites.map((f) => { + const Icon = resolveIcon(f.icon) + return ( + + + {f.title} + + ) + }) + ) : ( +

No favorites yet

+ )} +
+
+ +
+

Recently Added

+
+ {recentlyAdded.length > 0 ? ( + recentlyAdded.map((r) => ( +
+ {r.title} + {new Date(r.created_at).toLocaleDateString()} +
+ )) + ) : ( +

Nothing yet

+ )} +
+
+ +
+

Link Health

+ +
+ +
+
+

Category Breakdown

+ +
+
+
+
+
+ ) +} diff --git a/src/pages/Enrollment.tsx b/src/pages/Enrollment.tsx new file mode 100644 index 0000000..c9e2259 --- /dev/null +++ b/src/pages/Enrollment.tsx @@ -0,0 +1,275 @@ +import { useState } from 'react' +import { Server, Container, Network, Cloud, CloudCog, Activity, CloudSun, Check, ArrowRight } from 'lucide-react' +import { useAuth } from '../lib/AuthContext' +import { api, ApiError } from '../lib/api' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.12)', + borderRadius: '14px', + padding: '32px', +} + +const fieldLabel: React.CSSProperties = { + fontSize: '11px', + color: '#7A7D85', + marginBottom: '6px', + display: 'block', +} + +const fieldInput: React.CSSProperties = { + width: '100%', + height: '36px', + borderRadius: '8px', + border: '1px solid rgba(200,164,52,0.12)', + backgroundColor: 'rgba(255,255,255,0.03)', + color: '#E8E6E0', + fontSize: '13px', + padding: '0 12px', + outline: 'none', +} + +const goldButton: React.CSSProperties = { + height: '38px', + borderRadius: '8px', + border: 'none', + fontSize: '13px', + fontWeight: 600, + color: '#0A0B0D', + backgroundColor: '#C8A434', + boxShadow: '0 0 14px rgba(200,164,52,0.2)', + padding: '0 20px', +} + +const integrationOptions = [ + { type: 'proxmox', name: 'Proxmox', icon: Server }, + { type: 'docker', name: 'Docker', icon: Container }, + { type: 'netbird', name: 'NetBird', icon: Network }, + { type: 'cloudflare', name: 'Cloudflare', icon: Cloud }, + { type: 'aws', name: 'AWS', icon: CloudCog }, + { type: 'uptime_kuma', name: 'Uptime Kuma', icon: Activity }, + { type: 'weather', name: 'Weather API', icon: CloudSun }, +] as const + +export default function Enrollment() { + const { status, completeSetup, finishEnrollment } = useAuth() + const step = status === 'enrolling' ? 'connect' : 'account' + + return ( +
+
+ {step === 'account' ? ( + + ) : ( + + )} +
+
+ ) +} + +function AccountStep({ + completeSetup, +}: { + completeSetup: (username: string, password: string) => Promise +}) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + if (password !== confirm) { + setError('Passwords do not match') + return + } + if (password.length < 8) { + setError('Password must be at least 8 characters') + return + } + setSubmitting(true) + try { + await completeSetup(username, password) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Setup failed') + } finally { + setSubmitting(false) + } + } + + return ( +
+

+ Welcome to ArchNest +

+

+ Create your admin account to get started +

+ + + setUsername(e.target.value)} autoFocus required /> + + + setPassword(e.target.value)} required /> + + + setConfirm(e.target.value)} required /> + + {error &&

{error}

} + + +
+ ) +} + +function ConnectStep({ onFinish }: { onFinish: () => Promise }) { + const [connected, setConnected] = useState>(new Set()) + const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null) + + return ( +
+

+ Connect Your Services +

+

+ Add the integrations you want ArchNest to monitor. You can also do this later from Settings. +

+ + {active ? ( + { + setConnected((prev) => new Set(prev).add(active.type)) + setActive(null) + }} + onCancel={() => setActive(null)} + /> + ) : ( +
+ {integrationOptions.map((opt) => { + const Icon = opt.icon + const isDone = connected.has(opt.type) + return ( + + ) + })} +
+ )} + + {!active && ( +
+ +
+ )} +
+ ) +} + +function ConnectForm({ + option, + onConnected, + onCancel, +}: { + option: (typeof integrationOptions)[number] + onConnected: () => void + onCancel: () => void +}) { + const [baseUrl, setBaseUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [error, setError] = useState(null) + const [testResult, setTestResult] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setTestResult(null) + setSubmitting(true) + try { + const { integration } = await api.createIntegration({ + type: option.type, + name: option.name, + config: baseUrl ? { baseUrl } : {}, + secrets: apiKey ? { apiKey } : {}, + }) + const result = await api.testIntegration(integration.id) + setTestResult(result.message) + if (result.ok) onConnected() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to add integration') + } finally { + setSubmitting(false) + } + } + + return ( +
+

{option.name}

+ + + setBaseUrl(e.target.value)} placeholder="https://..." /> + + + setApiKey(e.target.value)} /> + + {error &&

{error}

} + {testResult &&

{testResult}

} + +
+ + +
+
+ ) +} diff --git a/src/pages/Glance.tsx b/src/pages/Glance.tsx new file mode 100644 index 0000000..c4e6a7f --- /dev/null +++ b/src/pages/Glance.tsx @@ -0,0 +1,51 @@ +import StatusCards from '../components/StatusCards' +import MiddleRow from '../components/MiddleRow' +import BottomRow from '../components/BottomRow' + +export default function Glance() { + return ( + <> + {/* Hero + KPI overlap — KPI bottom aligns with banner bottom */} +
+ ArchNest Banner { + const target = e.currentTarget + target.style.display = 'none' + target.parentElement!.classList.add('bg-card') + }} + /> + {/* Side vignette so the rectangular image blends into the page edges */} +
+ {/* KPI cards positioned so their bottom edge aligns with banner bottom */} +
+ +
+
+ + {/* Middle Row — stretches to fill available vertical space */} +
+ +
+ + {/* Bottom Row — anchored to the bottom */} +
+ +
+ + ) +} diff --git a/src/pages/Infrastructure.tsx b/src/pages/Infrastructure.tsx new file mode 100644 index 0000000..c6f942d --- /dev/null +++ b/src/pages/Infrastructure.tsx @@ -0,0 +1,350 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' +import { Plus, Server, Activity, AlertTriangle, CircleCheck } from 'lucide-react' +import { api, type Resource, type Integration, type Event } from '../lib/api' + +const subTabs = ['Overview'] +const futureSubTabs = ['Network'] + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + padding: '20px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', + transition: 'border-color 0.2s ease', + position: 'relative', + overflow: 'hidden', + height: '100%', + display: 'flex', + flexDirection: 'column', +} + +const sectionTitle: React.CSSProperties = { + fontSize: '11px', + textTransform: 'uppercase', + letterSpacing: '1.5px', + color: '#7A7D85', + fontWeight: 500, + marginBottom: '16px', +} + +function framedCard(bgUrl: string): React.CSSProperties { + return { + backgroundImage: `url(${bgUrl})`, + backgroundSize: '100% 100%', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + position: 'relative', + overflow: 'hidden', + height: '100%', + display: 'flex', + flexDirection: 'column', + padding: '20px 20px 64px 20px', + } +} + +const cardVignette: React.CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: 'radial-gradient(ellipse closest-side at center, transparent 70%, var(--color-page) 100%)', +} + +const cardDim: React.CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + backgroundColor: 'rgba(8, 8, 10, 0.45)', +} + +const nodeStatusColor: Record = { + healthy: '#2ECC71', + warning: '#E67E22', + critical: '#E74C3C', + unknown: '#7A7D85', +} + +const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C'] + +function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) { + const hasData = data.some((d) => d.value > 0) + return ( +
+
+ {hasData && ( + + + + {data.map((entry) => ( + + ))} + + + + )} + {centerLabel && ( +
+ {centerLabel} +
+ )} +
+
+ {data.map((entry) => ( +
+ + {entry.name} + {entry.value} +
+ ))} +
+
+ ) +} + +export default function Infrastructure() { + const [activeTab, setActiveTab] = useState('Overview') + const [resources, setResources] = useState(null) + const [integrations, setIntegrations] = useState(null) + const [events, setEvents] = useState(null) + const navigate = useNavigate() + + useEffect(() => { + api.listResources().then(({ resources }) => setResources(resources)) + api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) + api.listEvents(4).then(({ events }) => setEvents(events)) + }, []) + + const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0 + const warning = resources?.filter((r) => r.status === 'warning').length ?? 0 + const critical = resources?.filter((r) => r.status === 'critical').length ?? 0 + const total = resources?.length ?? 0 + + const statusCards = [ + { label: 'Total Resources', value: String(total), icon: Server, sub: `${integrations?.filter((i) => i.status === 'connected').length ?? 0} integrations connected` }, + { label: 'Healthy', value: String(healthy), icon: Activity, sub: total ? `${Math.round((healthy / total) * 100)}%` : '—', color: '#2ECC71' }, + { label: 'Warnings', value: String(warning), icon: AlertTriangle, sub: warning ? 'Needs attention' : 'None', color: '#E67E22' }, + { label: 'Critical', value: String(critical), icon: AlertTriangle, sub: critical ? 'Action required' : 'None', color: '#E74C3C' }, + ] + + const distributionData = useMemo(() => { + if (!resources) return [] + const byIntegration = new Map() + for (const r of resources) byIntegration.set(r.integration, (byIntegration.get(r.integration) ?? 0) + 1) + return Array.from(byIntegration.entries()).map(([name, value], i) => ({ name, value, color: integrationPalette[i % integrationPalette.length] })) + }, [resources]) + + return ( + <> + {/* Sub-tabs + Add Resource — hero banner is rendered at the layout level behind this */} +
+
+ {subTabs.map((tab) => { + const active = tab === activeTab + return ( + + ) + })} + {futureSubTabs.map((tab) => ( + + ))} +
+ +
+ + {/* Status Cards */} +
+ {statusCards.map((card) => { + const Icon = card.icon + return ( +
+

+ {card.label} +

+
+ + {card.value} +
+

{card.sub}

+
+ ) + })} +
+ + {/* Middle Row */} +
+
+ {/* Resource Distribution */} +
+
+
+
+

Resource Distribution

+
+ {distributionData.length > 0 ? ( + + ) : ( +

Connect an integration in Settings to see resource distribution.

+ )} +
+
+
+ + {/* Node Status — expanded */} +
+
+
+
+

Node Status

+ {resources && resources.length > 0 ? ( +
+ {resources.map((node, i) => ( +
+
+ + +
+ {node.name} +
+ ))} +
+ ) : ( +
+

No resources reported yet. Connect Docker (or another supported integration) in Settings to populate this view.

+
+ )} +
+
+
+
+ + {/* Bottom Row */} +
+
+ {/* Integration Health */} +
+
+

Integration Health

+ {integrations && integrations.length > 0 ? ( +
+ {integrations.map((i) => ( +
+ + + {i.name} + + {i.status} +
+ ))} +
+ ) : ( +

No integrations added yet.

+ )} +
+
+ + {/* Recent Activity */} +
+
+

Recent Activity

+ {events && events.length > 0 ? ( +
+ {events.map((item) => ( +
+
+

{item.title}

+
+
+ ))} +
+ ) : ( +

No activity yet.

+ )} +
+
+
+
+ + {/* Footer stats bar */} +
+ {total} Resources| + {integrations?.filter((i) => i.status === 'connected').length ?? 0} Integrations Connected + {critical > 0 && ( + <> + | + {critical} Critical + + )} +
+ + ) +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..92f4577 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useAuth } from '../lib/AuthContext' +import { ApiError } from '../lib/api' + +export default function Login() { + const { login } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await login(username, password) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Login failed') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

+ ArchNest +

+

Sign in to your dashboard

+ + + setUsername(e.target.value)} + autoFocus + required + /> + + + setPassword(e.target.value)} + required + /> + + {error && ( +

{error}

+ )} + + +
+
+ ) +} + +const fieldLabel: React.CSSProperties = { + fontSize: '11px', + color: '#7A7D85', + marginBottom: '6px', + display: 'block', +} + +const fieldInput: React.CSSProperties = { + width: '100%', + height: '36px', + borderRadius: '8px', + border: '1px solid rgba(200,164,52,0.12)', + backgroundColor: 'rgba(255,255,255,0.03)', + color: '#E8E6E0', + fontSize: '13px', + padding: '0 12px', + outline: 'none', +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..7b2d294 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,653 @@ +import { useEffect, useRef, useState } from 'react' +import { api, ApiError, type Integration } from '../lib/api' +import { + User, + Palette, + Plug, + Bell, + Database, + Info, + Eye, + EyeOff, + Check, + Download, + Upload, + Trash2, + RotateCcw, + Camera, +} from 'lucide-react' + +const navSections = [ + { id: 'profile', label: 'Profile', icon: User }, + { id: 'appearance', label: 'Appearance', icon: Palette }, + { id: 'integrations', label: 'Integrations', icon: Plug }, + { id: 'notifications', label: 'Notifications', icon: Bell }, + { id: 'data', label: 'Data & Backup', icon: Database }, + { id: 'about', label: 'About', icon: Info }, +] + +const accentColors = [ + { name: 'Gold', color: '#C8A434' }, + { name: 'Teal', color: '#2DD4BF' }, + { name: 'Purple', color: '#A855F7' }, + { name: 'Blue', color: '#3B82F6' }, + { name: 'Green', color: '#2ECC71' }, + { name: 'Red', color: '#E74C3C' }, +] + +type FieldDef = { key: string; label: string; secret?: boolean } + +const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [ + { type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] }, + { type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] }, + { type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] }, + { type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] }, + { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, + { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, + { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, +] + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + padding: '22px', + position: 'relative', +} + +const sectionTitle: React.CSSProperties = { + fontSize: '11px', + textTransform: 'uppercase', + letterSpacing: '1.5px', + color: '#7A7D85', + fontWeight: 500, + marginBottom: '16px', +} + +const labelStyle: React.CSSProperties = { + fontSize: '11px', + color: '#7A7D85', + marginBottom: '6px', + display: 'block', +} + +const inputStyle: React.CSSProperties = { + width: '100%', + height: '34px', + borderRadius: '8px', + border: '1px solid rgba(200,164,52,0.12)', + backgroundColor: 'rgba(255,255,255,0.03)', + color: '#E8E6E0', + fontSize: '12px', + padding: '0 12px', + outline: 'none', +} + +function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ) +} + +function GoldButton({ children, danger }: { children: React.ReactNode; danger?: boolean }) { + return ( + + ) +} + +function ProfileSection() { + const [avatar, setAvatar] = useState(null) + const fileInputRef = useRef(null) + + function handleAvatarChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => setAvatar(reader.result as string) + reader.readAsDataURL(file) + } + + return ( +
+

Profile

+
+
fileInputRef.current?.click()} + className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group" + style={{ + width: '64px', + height: '64px', + borderColor: '#C8A434', + color: '#C8A434', + fontSize: '20px', + backgroundColor: 'rgba(200,164,52,0.08)', + backgroundImage: avatar ? `url(${avatar})` : undefined, + backgroundSize: 'cover', + backgroundPosition: 'center', + overflow: 'hidden', + }} + title="Upload photo" + > + {!avatar && 'AO'} +
+ +
+
+ +
+
+ ArchNest Ops + + Administrator + +
+ admin@archnest.io +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Save Changes + +
+ ) +} + +function AppearanceSection() { + const [theme, setTheme] = useState<'dark' | 'light'>('dark') + const [accent, setAccent] = useState('Gold') + const [fontSize, setFontSize] = useState(13) + const [radius, setRadius] = useState(12) + const [sidebarExpanded, setSidebarExpanded] = useState(true) + const [animations, setAnimations] = useState(true) + + return ( +
+

Appearance

+ +
+ Theme +
+ {(['dark', 'light'] as const).map((t) => ( + + ))} +
+
+ +
+ Accent Color +
+ {accentColors.map((a) => ( + + ))} +
+
+ +
+
+ Font Size + {fontSize}px +
+ setFontSize(Number(e.target.value))} + className="w-full" + style={{ accentColor: '#C8A434' }} + /> +
+ +
+
+ Card Border Radius + {radius}px +
+ setRadius(Number(e.target.value))} + className="w-full" + style={{ accentColor: '#C8A434' }} + /> +
+ +
+ Sidebar Expanded by Default + setSidebarExpanded((v) => !v)} /> +
+ +
+ Animations + setAnimations((v) => !v)} /> +
+
+ ) +} + +function IntegrationsSection() { + const [integrations, setIntegrations] = useState(null) + const [revealed, setRevealed] = useState>(new Set()) + const [drafts, setDrafts] = useState>>({}) + const [statusMsg, setStatusMsg] = useState>({}) + const [busy, setBusy] = useState>(new Set()) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) + }, []) + + function toggleReveal(key: string) { + setRevealed((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + function setBusyFlag(type: string, value: boolean) { + setBusy((prev) => { + const next = new Set(prev) + if (value) next.add(type) + else next.delete(type) + return next + }) + } + + function setDraftField(type: string, fieldKey: string, value: string) { + setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } })) + } + + async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { + setBusyFlag(def.type, true) + setStatusMsg((prev) => ({ ...prev, [def.type]: '' })) + try { + const draft = drafts[def.type] ?? {} + const config: Record = {} + const secrets: Record = {} + for (const f of def.fields) { + const value = draft[f.key] + if (value === undefined) continue + if (f.secret) secrets[f.key] = value + else config[f.key] = value + } + let integration: Integration + if (existing) { + ;({ integration } = await api.updateIntegration(existing.id, { config, secrets })) + } else { + ;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets })) + } + setIntegrations((prev) => { + const others = (prev ?? []).filter((i) => i.id !== integration.id) + return [...others, integration] + }) + setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' })) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(def.type, false) + } + } + + async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { + if (!existing) { + setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' })) + return + } + setBusyFlag(def.type, true) + try { + const result = await api.testIntegration(existing.id) + setStatusMsg((prev) => ({ ...prev, [def.type]: result.message })) + const { integrations } = await api.listIntegrations() + setIntegrations(integrations) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' })) + } finally { + setBusyFlag(def.type, false) + } + } + + if (!integrations) { + return ( +
+

Loading integrations…

+
+ ) + } + + return ( +
+ {integrationTypeDefs.map((def) => { + const existing = integrations.find((i) => i.type === def.type) + const online = existing?.status === 'connected' + const draft = drafts[def.type] ?? {} + + return ( +
+
+
+ + {def.name} +
+
+ {statusMsg[def.type] && ( + {statusMsg[def.type]} + )} + + +
+
+
+ {def.fields.map((f) => { + const key = `${def.type}-${f.key}` + const isRevealed = revealed.has(key) + const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' + const value = draft[f.key] ?? savedValue + return ( +
+ +
+ setDraftField(def.type, f.key, e.target.value)} + placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'} + /> + {f.secret && ( + + )} +
+
+ ) + })} +
+
+ ) + })} +
+ ) +} + +function NotificationsSection() { + const [enabled, setEnabled] = useState(true) + const [email, setEmail] = useState(true) + const [push, setPush] = useState(false) + const [sound, setSound] = useState(true) + + return ( +
+

Notifications

+ +
+ Enable Notifications + setEnabled((v) => !v)} /> +
+ +
+ + +
+ +
+ Email Notifications + setEmail((v) => !v)} /> +
+ +
+ Browser Push + setPush((v) => !v)} /> +
+ +
+ Sound + setSound((v) => !v)} /> +
+ {sound && ( + + )} +
+ ) +} + +function DataBackupSection() { + return ( +
+

Data & Backup

+
+
+ Export Bookmarks (JSON) + Export +
+
+ Import Bookmarks (JSON) + Import +
+
+ Export Settings + Export +
+
+
+ Clear Cache + Clear +
+
+ Reset to Defaults + Reset +
+
+
+ ) +} + +function AboutSection() { + const rows: [string, string][] = [ + ['App', 'ArchNest Dashboard v1.0.0'], + ['Author', 'Samuel James'], + ['Repo', 'github.com/SamuelSJames/archnest'], + ['Stack', 'React 19, Vite, TypeScript'], + ['License', 'MIT'], + ] + return ( +
+

About

+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ) +} + +const sectionComponents: Record JSX.Element> = { + profile: ProfileSection, + appearance: AppearanceSection, + integrations: IntegrationsSection, + notifications: NotificationsSection, + data: DataBackupSection, + about: AboutSection, +} + +export default function Settings() { + const [active, setActive] = useState('profile') + const ActiveSection = sectionComponents[active] + + return ( +
+ {/* Settings nav */} +
+ {navSections.map((s) => { + const Icon = s.icon + const isActive = active === s.id + return ( + + ) + })} +
+ + {/* Content */} +
+ +
+
+ ) +} diff --git a/vite.config.ts b/vite.config.ts index 0616e59..5e7eb0f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://localhost:4000', + changeOrigin: true, + }, + }, + }, })