diff --git a/.gitignore b/.gitignore index 2ad4e23..59ea536 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist-ssr/ .env .env.* !.env.example +*.log +*.sqlite +*.db npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/dist/index.html b/dist/index.html index 48d8c7f..110805a 100644 --- a/dist/index.html +++ b/dist/index.html @@ -9,10 +9,10 @@ - + - - + +
diff --git a/docker-compose.yml b/docker-compose.yml index 77a0672..a953c95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: depends_on: backend: condition: service_healthy + db: + condition: service_healthy # Backend API + WebSocket server backend: @@ -43,6 +45,26 @@ services: retries: 5 start_period: 10s + db: + image: postgres:15-alpine + restart: always + environment: + POSTGRES_USER: homelab + POSTGRES_PASSWORD: homelab_password + POSTGRES_DB: homelab_topology + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U homelab -d homelab_topology"] + interval: 5s + timeout: 5s + retries: 5 + networks: default: name: homelab-topology + +volumes: + pgdata: diff --git a/package-lock.json b/package-lock.json index a0a457e..ceba7f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,14 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.4.4", + "bcrypt": "^6.0.0", "cors": "^2.8.6", "dagre": "^0.8.5", + "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.468.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -32,10 +35,13 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@prisma/client": "^5.22.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/bcrypt": "^6.0.0", "@types/express-rate-limit": "^5.1.3", "@types/jsdom": "^27.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.0.0", "@types/pino": "^7.0.4", "@types/react": "^18.3.18", @@ -49,6 +55,7 @@ "globals": "^15.14.0", "jsdom": "^28.1.0", "postcss": "^8.4.49", + "prisma": "^5.22.0", "tailwindcss": "^3.4.17", "tsx": "^4.19.0", "typescript": "~5.6.2", @@ -1338,6 +1345,75 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1837,6 +1913,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2051,6 +2137,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -2878,6 +2982,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -2992,6 +3110,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -3670,6 +3794,18 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3684,6 +3820,15 @@ "node": ">= 0.4" } }, + "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/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4951,6 +5096,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5017,6 +5217,42 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5024,6 +5260,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5254,6 +5496,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5796,6 +6058,26 @@ "license": "MIT", "peer": true }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -6225,6 +6507,26 @@ "queue-microtask": "^1.2.2" } }, + "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-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", diff --git a/package.json b/package.json index 2221249..22e5464 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,14 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.4.4", + "bcrypt": "^6.0.0", "cors": "^2.8.6", "dagre": "^0.8.5", + "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.468.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -39,10 +42,13 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@prisma/client": "^5.22.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/bcrypt": "^6.0.0", "@types/express-rate-limit": "^5.1.3", "@types/jsdom": "^27.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.0.0", "@types/pino": "^7.0.4", "@types/react": "^18.3.18", @@ -56,6 +62,7 @@ "globals": "^15.14.0", "jsdom": "^28.1.0", "postcss": "^8.4.49", + "prisma": "^5.22.0", "tailwindcss": "^3.4.17", "tsx": "^4.19.0", "typescript": "~5.6.2", diff --git a/prisma/migrations/20260223234241_init/migration.sql b/prisma/migrations/20260223234241_init/migration.sql new file mode 100644 index 0000000..5eecc14 --- /dev/null +++ b/prisma/migrations/20260223234241_init/migration.sql @@ -0,0 +1,88 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HostConfig" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "sshUser" TEXT NOT NULL DEFAULT 'bear', + "sshKeyPath" TEXT, + "sshPort" INTEGER NOT NULL DEFAULT 22, + "hostType" TEXT NOT NULL DEFAULT 'docker-host', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HostConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NetworkNode" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ip" TEXT, + "status" TEXT NOT NULL DEFAULT 'unknown', + "importance" INTEGER NOT NULL DEFAULT 3, + "description" TEXT, + "metadata" JSONB, + "category" TEXT, + "parentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NetworkNode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NetworkEdge" ( + "id" TEXT NOT NULL, + "sourceId" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "type" TEXT, + "label" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NetworkEdge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Settings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "HostConfig_name_key" ON "HostConfig"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "NetworkEdge_sourceId_targetId_key" ON "NetworkEdge"("sourceId", "targetId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Settings_key_key" ON "Settings"("key"); + +-- AddForeignKey +ALTER TABLE "NetworkNode" ADD CONSTRAINT "NetworkNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "NetworkNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "NetworkNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "NetworkNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..0f6938c --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,75 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + username String @unique + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model HostConfig { + id String @id @default(uuid()) + name String @unique + ip String + sshUser String @default("bear") + sshKeyPath String? + sshPort Int @default(22) + hostType String @default("docker-host") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model NetworkNode { + id String @id + type String + name String + ip String? + status String @default("unknown") + importance Int @default(3) + description String? + metadata Json? + category String? // for services + parentId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + parent NetworkNode? @relation("NodeHierarchy", fields: [parentId], references: [id]) + children NetworkNode[] @relation("NodeHierarchy") + + sourceEdges NetworkEdge[] @relation("EdgeSource") + targetEdges NetworkEdge[] @relation("EdgeTarget") +} + +model NetworkEdge { + id String @id @default(uuid()) + sourceId String + targetId String + type String? + label String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + source NetworkNode @relation("EdgeSource", fields: [sourceId], references: [id]) + target NetworkNode @relation("EdgeTarget", fields: [targetId], references: [id]) + + @@unique([sourceId, targetId]) +} + +model Settings { + id String @id @default("singleton") + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/server/config.ts b/server/config.ts index 3d648c8..6d9ab42 100644 --- a/server/config.ts +++ b/server/config.ts @@ -1,76 +1,31 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; import { homedir } from 'os'; +import { PrismaClient } from '@prisma/client'; import { HostConfig } from './types'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const CONFIG_FILE = path.join(__dirname, 'hosts.json'); - -function parseEnvHosts(): HostConfig[] { - const hostsEnv = process.env.SSH_HOSTS; - if (!hostsEnv) return []; - - const hosts: HostConfig[] = []; - const entries = hostsEnv.split(',').map(h => h.trim()).filter(Boolean); - - for (const entry of entries) { - const [name, ip] = entry.split(':'); - if (name && ip) { - hosts.push({ - name: name.trim(), - ip: ip.trim(), - sshUser: process.env.SSH_USER || 'bear', - sshKeyPath: process.env.SSH_KEY, - sshPort: process.env.SSH_PORT ? parseInt(process.env.SSH_PORT, 10) : 22, - }); - } - } - - return hosts; -} - -function parseJsonConfig(): HostConfig[] { - if (!fs.existsSync(CONFIG_FILE)) { - console.error('Config file not found:', CONFIG_FILE); - return []; - } +const prisma = new PrismaClient(); +export async function getHostConfigs(): Promise { try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - const data = JSON.parse(content); - - if (!data.hosts || !Array.isArray(data.hosts)) { - console.error('No hosts array in config'); - return []; - } - - const hosts = data.hosts.map((h: Partial) => ({ - name: h.name || '', - ip: h.ip || '', + const dbConfigs = await prisma.hostConfig.findMany(); + return dbConfigs.map(h => ({ + name: h.name, + ip: h.ip, sshUser: h.sshUser || 'bear', sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()), sshPort: h.sshPort || 22, - })).filter((h: HostConfig) => h.name && h.ip); - - console.error('Loaded hosts:', JSON.stringify(hosts)); - return hosts; - } catch (e: any) { - console.error('Config parse error:', e.message); + hostType: h.hostType, + })); + } catch (error) { + console.error('Error fetching host configs from DB:', error); return []; } } -export function getHostConfigs(): HostConfig[] { - const envHosts = parseEnvHosts(); - if (envHosts.length > 0) { - return envHosts; +export async function hasConfig(): Promise { + try { + const count = await prisma.hostConfig.count(); + return count > 0; + } catch (error) { + return false; } - - return parseJsonConfig(); -} - -export function hasConfig(): boolean { - return getHostConfigs().length > 0; } diff --git a/server/hosts.json b/server/hosts.json deleted file mode 100644 index c07fcf2..0000000 --- a/server/hosts.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "hosts": [ - { - "name": "ubuntu", - "ip": "192.168.50.61", - "sshUser": "bear", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "docker-host" - }, - { - "name": "grizzley", - "ip": "192.168.50.84", - "sshUser": "bear", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "docker-host" - }, - { - "name": "truenas", - "ip": "192.168.50.12", - "sshUser": "root", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "truenas" - }, - { - "name": "proxmox", - "ip": "192.168.50.11", - "sshUser": "root", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "proxmox" - }, - { - "name": "ice", - "ip": "192.168.50.197", - "sshUser": "bear", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "docker-host" - }, - { - "name": "panda", - "ip": "192.168.50.196", - "sshUser": "bear", - "sshKeyPath": "~/.ssh/id_ed25519", - "sshPort": 22, - "hostType": "docker-host" - } - ] -} diff --git a/server/index.ts b/server/index.ts index 1dd4795..4e8664c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,10 @@ import configRouter from './routes/config'; import statsRouter from './routes/stats'; import filesRouter from './routes/files'; import terminalRouter from './routes/terminal'; +import authRouter from './routes/auth'; +import topologyRouter from './routes/topology'; +import hostsRouter from './routes/hosts'; +import { requireAuth } from './middleware/auth'; import { getHostConfigs } from './config'; import { requestLogger, logger } from './middleware/requestLogger'; import { errorHandler } from './middleware/errorHandler'; @@ -17,10 +21,13 @@ const app = express(); const httpServer = createServer(app); const PORT = 3001; +const allowedOrigins = ['http://localhost:3000', 'http://localhost:4173']; +const corsOrigin = process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN, ...allowedOrigins] : allowedOrigins; + // --- Socket.IO setup (websocket-engineer skill) --- const io = new Server(httpServer, { cors: { - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + origin: corsOrigin, credentials: true, }, pingInterval: 25000, @@ -45,7 +52,7 @@ app.use(helmet({ // CORS — restrict to configured origins app.use(cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + origin: corsOrigin, credentials: true, })); @@ -85,18 +92,23 @@ app.get('/api/health', (_req, res) => { // --- Debug endpoint (dev only) --- if (process.env.NODE_ENV !== 'production') { - app.get('/api/debug-config', (_req, res) => { - const hosts = getHostConfigs(); + app.get('/api/debug-config', async (_req, res) => { + const hosts = await getHostConfigs(); res.json({ hosts }); }); } -// --- Routes --- -app.use('/api', discoverRouter); -app.use('/api', configRouter); -app.use('/api', statsRouter); -app.use('/api', filesRouter); -app.use('/api', terminalRouter); +// --- Public Routes --- +app.use('/api', authRouter); + +// --- Protected Routes --- +app.use('/api', requireAuth, discoverRouter); +app.use('/api', requireAuth, configRouter); +app.use('/api', requireAuth, statsRouter); +app.use('/api', requireAuth, filesRouter); +app.use('/api', requireAuth, terminalRouter); +app.use('/api', requireAuth, topologyRouter); +app.use('/api', requireAuth, hostsRouter); // --- Global error handler (must be last) --- app.use(errorHandler); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts new file mode 100644 index 0000000..5452e22 --- /dev/null +++ b/server/middleware/auth.ts @@ -0,0 +1,27 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export interface AuthRequest extends Request { + user?: { + id: string; + username: string; + }; +} + +const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only'; + +export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ status: 'error', message: 'Authentication required' }); + } + + const token = authHeader.split(' ')[1]; + try { + const decoded = jwt.verify(token, JWT_SECRET) as { id: string; username: string }; + req.user = decoded; + next(); + } catch (error) { + return res.status(401).json({ status: 'error', message: 'Invalid or expired token' }); + } +}; diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..49b23b6 --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,78 @@ +import { Router } from 'express'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { PrismaClient } from '@prisma/client'; +import rateLimit from 'express-rate-limit'; + +const router = Router(); +const prisma = new PrismaClient(); +const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only'; + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Limit each IP to 5 failed login attempts per window + message: { status: 'error', message: 'Too many login attempts, please try again later' }, + skipSuccessfulRequests: true, +}); + +// Check if setup is required (no users exist) +router.get('/auth/status', async (req, res) => { + try { + const userCount = await prisma.user.count(); + res.json({ setupRequired: userCount === 0 }); + } catch (error) { + res.status(500).json({ status: 'error', message: 'Database error' }); + } +}); + +// Initial Setup (Only works if no users exist) +router.post('/auth/setup', async (req, res) => { + try { + const userCount = await prisma.user.count(); + if (userCount > 0) { + return res.status(403).json({ status: 'error', message: 'Setup has already been completed' }); + } + + const { username, password } = req.body; + if (!username || !password || password.length < 8) { + return res.status(400).json({ status: 'error', message: 'Invalid username or password must be at least 8 characters' }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { username, password: hashedPassword }, + }); + + const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' }); + res.json({ status: 'success', token, user: { id: user.id, username: user.username } }); + } catch (error) { + res.status(500).json({ status: 'error', message: 'Failed to create user' }); + } +}); + +// Login +router.post('/auth/login', loginLimiter, async (req, res) => { + try { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ status: 'error', message: 'Username and password required' }); + } + + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + return res.status(401).json({ status: 'error', message: 'Invalid credentials' }); + } + + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + return res.status(401).json({ status: 'error', message: 'Invalid credentials' }); + } + + const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' }); + res.json({ status: 'success', token, user: { id: user.id, username: user.username } }); + } catch (error) { + res.status(500).json({ status: 'error', message: 'Login failed' }); + } +}); + +export default router; diff --git a/server/routes/config.ts b/server/routes/config.ts index 15324a3..b66489a 100644 --- a/server/routes/config.ts +++ b/server/routes/config.ts @@ -163,7 +163,7 @@ router.get('/config/:host/:container', async (req, res) => { try { // Find host config - const hostConfigs = getHostConfigs(); + const hostConfigs = await getHostConfigs(); const hostConfig = hostConfigs.find(h => h.name === host); if (!hostConfig) { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 92b8b87..9d1b2f5 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -7,10 +7,13 @@ import { Router } from 'express'; import { execSync } from 'child_process'; import { homedir } from 'os'; +import { PrismaClient } from '@prisma/client'; import { getHostConfigs } from '../config'; import { DiscoveryResponse } from '../types'; +import { io } from '../index'; const router = Router(); +const prisma = new PrismaClient(); interface HostDiscoveryResult { name: string; @@ -35,11 +38,11 @@ async function discoverHost( console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`); const keyArg = `-i ${keyPath}`; const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : ''; - + const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`; const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 }); const containers = dockerOutput.trim().split('\n').filter(c => c.trim()); - + let services: string[] = []; if (hostType !== 'proxmox') { try { @@ -50,7 +53,7 @@ async function discoverHost( console.error(`DEBUG: ${name} systemd discovery failed`); } } - + let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = []; if (hostType === 'proxmox' || name === 'proxmox') { try { @@ -63,7 +66,7 @@ async function discoverHost( vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' }); } } - + const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`; const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 }); const vmLines = vmOutput.trim().split('\n').slice(1); @@ -77,7 +80,7 @@ async function discoverHost( console.error(`DEBUG: ${name} Proxmox discovery failed`); } } - + return { name, ip, @@ -97,18 +100,53 @@ async function discoverHost( } } -// POST /api/discover - Discover all hosts via SSH +// POST /api/discover - Trigger discovery but return cached immediately router.post('/discover', async (req, res) => { try { - const hosts = getHostConfigs(); - + // 1. Fetch from cache immediately for fast loading + const cachedSetting = await prisma.settings.findUnique({ where: { key: 'last_discovery' } }); + + let previousState: DiscoveryResponse = { + hosts: [], + timestamp: new Date().toISOString(), + errors: [], + }; + + if (cachedSetting && cachedSetting.value) { + try { + previousState = JSON.parse(cachedSetting.value); + } catch (e) { + console.error('Failed to parse cached discovery state'); + } + } + + // 2. Return cached response to unblock the UI request + res.json(previousState); + + // 3. Kick off background discovery + runBackgroundDiscovery(); + + } catch (error: any) { + res.status(500).json({ + hosts: [], + timestamp: new Date().toISOString(), + errors: [error.message || 'Cache retrieval failed'], + }); + } +}); + +async function runBackgroundDiscovery() { + try { + const hosts = await getHostConfigs(); + if (hosts.length === 0) { const response: DiscoveryResponse = { hosts: [], timestamp: new Date().toISOString(), errors: ['No hosts configured'], }; - return res.json(response); + await cacheAndEmit(response); + return; } const results: HostDiscoveryResult[] = []; @@ -134,7 +172,7 @@ router.post('/discover', async (req, res) => { }); } } - + const errors: string[] = []; results.forEach((result: HostDiscoveryResult) => { if (!result.online && result.error) { @@ -155,15 +193,26 @@ router.post('/discover', async (req, res) => { errors, }; - res.json(response); + await cacheAndEmit(response); } catch (error: any) { - const response: DiscoveryResponse = { - hosts: [], - timestamp: new Date().toISOString(), - errors: [error.message || 'Discovery failed'], - }; - res.status(500).json(response); + console.error('Background discovery failed:', error); } -}); +} + +async function cacheAndEmit(response: DiscoveryResponse) { + try { + // Cache the standard response to the DB + await prisma.settings.upsert({ + where: { key: 'last_discovery' }, + update: { value: JSON.stringify(response) }, + create: { key: 'last_discovery', value: JSON.stringify(response) }, + }); + + // Broadcast the full update via Socket.IO + io.emit('topology:update', response); + } catch (err) { + console.error('Failed to cache and emit discovery results:', err); + } +} export default router; diff --git a/server/routes/files.ts b/server/routes/files.ts index 276109d..d978d53 100644 --- a/server/routes/files.ts +++ b/server/routes/files.ts @@ -136,7 +136,7 @@ router.get('/files/:host/:container', async (req, res) => { try { const { host, container } = req.params; - const hosts = getHostConfigs(); + const hosts = await getHostConfigs(); const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase()); if (!hostConfig) { @@ -240,7 +240,7 @@ router.get('/files/browse/:host', async (req, res) => { const { host } = req.params; const path = (req.query.path as string) || '/'; - const hosts = getHostConfigs(); + const hosts = await getHostConfigs(); const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase()); if (!hostConfig) { diff --git a/server/routes/hosts.ts b/server/routes/hosts.ts new file mode 100644 index 0000000..56beca0 --- /dev/null +++ b/server/routes/hosts.ts @@ -0,0 +1,50 @@ +import { Router } from 'express'; +import { PrismaClient } from '@prisma/client'; + +const router = Router(); +const prisma = new PrismaClient(); + +// GET all host configurations +router.get('/hosts', async (req, res) => { + try { + const hosts = await prisma.hostConfig.findMany(); + res.json({ status: 'success', hosts }); + } catch (error) { + res.status(500).json({ status: 'error', message: 'Failed to fetch host configs' }); + } +}); + +// POST a new host configuration +router.post('/hosts', async (req, res) => { + try { + const host = await prisma.hostConfig.create({ data: req.body }); + res.json({ status: 'success', host }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to create host config. Ensure name is unique.' }); + } +}); + +// PUT (update) a host configuration +router.put('/hosts/:id', async (req, res) => { + try { + const host = await prisma.hostConfig.update({ + where: { id: req.params.id }, + data: req.body, + }); + res.json({ status: 'success', host }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to update host config' }); + } +}); + +// DELETE a host configuration +router.delete('/hosts/:id', async (req, res) => { + try { + await prisma.hostConfig.delete({ where: { id: req.params.id } }); + res.json({ status: 'success' }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to delete host config' }); + } +}); + +export default router; diff --git a/server/routes/stats.ts b/server/routes/stats.ts index 0973f76..b0ee2ff 100644 --- a/server/routes/stats.ts +++ b/server/routes/stats.ts @@ -131,7 +131,7 @@ router.get('/stats/:host/:container', async (req, res) => { const { host, container } = req.params; // Find host config by name - const hosts = getHostConfigs(); + const hosts = await getHostConfigs(); const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase()); if (!hostConfig) { diff --git a/server/routes/terminal.ts b/server/routes/terminal.ts index dd3fda8..05aeda3 100644 --- a/server/routes/terminal.ts +++ b/server/routes/terminal.ts @@ -18,7 +18,7 @@ router.post('/terminal/exec', async (req, res) => { return res.status(400).json({ error: 'Missing host or command' }); } - const hosts = getHostConfigs(); + const hosts = await getHostConfigs(); const hostConfig = hosts.find(h => h.name === hostName); if (!hostConfig) { @@ -44,7 +44,7 @@ router.post('/terminal/exec', async (req, res) => { router.get('/terminal/hosts', async (_req, res) => { try { - const hosts = getHostConfigs(); + const hosts = await getHostConfigs(); res.json({ hosts: hosts.map(h => ({ name: h.name, diff --git a/server/routes/topology.ts b/server/routes/topology.ts new file mode 100644 index 0000000..ba215b1 --- /dev/null +++ b/server/routes/topology.ts @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import { PrismaClient } from '@prisma/client'; + +const router = Router(); +const prisma = new PrismaClient(); + +// GET all nodes and edges +router.get('/topology', async (req, res) => { + try { + const nodes = await prisma.networkNode.findMany(); + const edges = await prisma.networkEdge.findMany(); + res.json({ status: 'success', nodes, edges }); + } catch (error) { + res.status(500).json({ status: 'error', message: 'Failed to fetch topology' }); + } +}); + +// POST a new node +router.post('/topology/nodes', async (req, res) => { + try { + const node = await prisma.networkNode.create({ data: req.body }); + res.json({ status: 'success', node }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to create node' }); + } +}); + +// PUT (update) a node +router.put('/topology/nodes/:id', async (req, res) => { + try { + const node = await prisma.networkNode.update({ + where: { id: req.params.id }, + data: req.body, + }); + res.json({ status: 'success', node }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to update node' }); + } +}); + +// DELETE a node +router.delete('/topology/nodes/:id', async (req, res) => { + try { + await prisma.networkNode.delete({ where: { id: req.params.id } }); + res.json({ status: 'success' }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to delete node' }); + } +}); + +// POST a new edge +router.post('/topology/edges', async (req, res) => { + try { + const edge = await prisma.networkEdge.create({ data: req.body }); + res.json({ status: 'success', edge }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to create edge' }); + } +}); + +// DELETE an edge +router.delete('/topology/edges/:id', async (req, res) => { + try { + await prisma.networkEdge.delete({ where: { id: req.params.id } }); + res.json({ status: 'success' }); + } catch (error) { + res.status(400).json({ status: 'error', message: 'Failed to delete edge' }); + } +}); + +export default router; diff --git a/src/App.tsx b/src/App.tsx index 9ac5cc2..cd0274c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,8 @@ import CommandPalette from './components/CommandPalette'; import StaleWarning from './components/StaleWarning'; import TerminalPanel from './components/TerminalPanel'; import MetricsBar from './components/Dashboard/MetricsBar'; +import Login from './components/Login'; +import SettingsOverlay from './components/Settings/SettingsOverlay'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; @@ -60,6 +62,7 @@ function App() { setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery, }))); + const token = useTopologyStore((s) => s.token); const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus); const { @@ -69,6 +72,8 @@ function App() { pollInterval, terminalOpen, terminalHost, + toggleLeftPanel, + toggleRightPanel, } = useTopologyStore(useShallow((s) => ({ leftPanelOpen: s.leftPanelOpen, rightPanelOpen: s.rightPanelOpen, @@ -76,6 +81,8 @@ function App() { pollInterval: s.pollInterval, terminalOpen: s.terminalOpen, terminalHost: s.terminalHost, + toggleLeftPanel: s.toggleLeftPanel, + toggleRightPanel: s.toggleRightPanel, }))); const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette); @@ -99,7 +106,10 @@ function App() { try { const response = await fetch(`${API_BASE_URL}/api/discover`, { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${useTopologyStore.getState().token}` + } }); if (response.ok) { @@ -253,6 +263,10 @@ function App() { }; }, [setConnectionStatus, setNodes, setEdges, setLastUpdated]); + if (!token) { + return ; + } + return (
@@ -265,22 +279,37 @@ function App() {
-
+
{leftPanelOpen && ( - + <> +