feat: migrate from static config to database and add authentication

- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma)

- Implemented JWT authentication and Login UI

- Added dynamic API routes for hosts, topology, and settings

- Updated UI components to fetch and manage state dynamically

- Added Settings interface for managing hosts and topology nodes
This commit is contained in:
2026-02-25 14:07:11 -08:00
parent df02542c26
commit 0910c966a5
37 changed files with 1884 additions and 645 deletions

3
.gitignore vendored
View File

@@ -5,6 +5,9 @@ dist-ssr/
.env
.env.*
!.env.example
*.log
*.sqlite
*.db
npm-debug.log*
yarn-debug.log*
yarn-error.log*

6
dist/index.html vendored
View File

@@ -9,10 +9,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CTcm3RSe.js"></script>
<script type="module" crossorigin src="/assets/index-BAJgTfdz.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-pGkIx_vZ.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-tLSpD589.js">
<link rel="stylesheet" crossorigin href="/assets/index-BjyEYKkh.css">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BSwt2l3v.js">
<link rel="stylesheet" crossorigin href="/assets/index-CYGIQDBj.css">
</head>
<body>
<div id="root"></div>

View File

@@ -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:

302
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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"

75
prisma/schema.prisma Normal file
View File

@@ -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
}

View File

@@ -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<HostConfig[]> {
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<HostConfig>) => ({
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<boolean> {
try {
const count = await prisma.hostConfig.count();
return count > 0;
} catch (error) {
return false;
}
return parseJsonConfig();
}
export function hasConfig(): boolean {
return getHostConfigs().length > 0;
}

View File

@@ -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"
}
]
}

View File

@@ -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);

27
server/middleware/auth.ts Normal file
View File

@@ -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' });
}
};

78
server/routes/auth.ts Normal file
View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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) {

50
server/routes/hosts.ts Normal file
View File

@@ -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;

View File

@@ -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) {

View File

@@ -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,

71
server/routes/topology.ts Normal file
View File

@@ -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;

View File

@@ -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 <Login />;
}
return (
<ReactFlowProvider>
<div className="h-screen w-screen flex flex-col bg-slate-900">
@@ -265,22 +279,37 @@ function App() {
<Header onRefresh={loadData} isLoading={isLoading} />
<MetricsBar />
<div className="flex-1 flex overflow-hidden" role="main" id="main-content" tabIndex={-1}>
<div className="flex-1 flex overflow-hidden relative" role="main" id="main-content" tabIndex={-1}>
{leftPanelOpen && (
<LeftPanel />
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm z-10 md:hidden animate-in fade-in duration-300"
onClick={toggleLeftPanel}
aria-hidden="true"
/>
<LeftPanel />
</>
)}
<div className="flex-1">
<div className="flex-1 relative z-0">
<TopologyGraph />
</div>
{rightPanelOpen && (
<RightPanel />
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm z-10 md:hidden animate-in fade-in duration-300"
onClick={toggleRightPanel}
aria-hidden="true"
/>
<RightPanel />
</>
)}
</div>
<Footer />
<CommandPalette />
<SettingsOverlay />
{terminalOpen && terminalHost && (
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
)}

View File

@@ -12,21 +12,21 @@ interface Command {
action: () => void;
}
const nodeTypeLabels: Record<NodeType, string> = {
gateway: 'Gateway',
vlan: 'VLAN',
wifi: 'WiFi',
host_physical: 'Physical Host',
host_vm: 'VM Host',
host_container: 'Container Host',
vm_lxc: 'LXC Container',
vm_qemu: 'QEMU VM',
systemd_service: 'Systemd Service',
service: 'Service',
volume: 'Volume',
mount: 'Mount',
path: 'Path',
};
const nodeTypeLabels: Record<NodeType, string> = {
gateway: 'Gateway',
vlan: 'VLAN',
wifi: 'WiFi',
host_physical: 'Physical Host',
host_vm: 'VM Host',
host_container: 'Container Host',
vm_lxc: 'LXC Container',
vm_qemu: 'QEMU VM',
systemd_service: 'Systemd Service',
service: 'Service',
volume: 'Volume',
mount: 'Mount',
path: 'Path',
};
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
gateway: <Router className="w-4 h-4" />,
@@ -47,14 +47,14 @@ const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
function fuzzyMatch(pattern: string, text: string): boolean {
const patternLower = pattern.toLowerCase();
const textLower = text.toLowerCase();
let patternIdx = 0;
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIdx]) {
patternIdx++;
}
}
return patternIdx === patternLower.length;
}
@@ -63,8 +63,8 @@ interface CommandPaletteProps {
}
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
const {
commandPaletteOpen,
const {
commandPaletteOpen,
toggleCommandPalette,
typeFilters,
toggleTypeFilter,
@@ -170,7 +170,7 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
];
const nodeTypes: NodeType[] = [
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
'host_container', 'service', 'volume', 'mount', 'path'
];
@@ -267,12 +267,12 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
let globalIndex = 0;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm"
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-md px-4"
onClick={handleBackdropClick}
>
<div className="w-full max-w-xl bg-slate-800 rounded-xl shadow-2xl border border-slate-700 overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700">
<div className="w-full max-w-xl glass border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center gap-3 px-4 py-4 border-b border-white/10 bg-slate-900/40">
<Search className="w-5 h-5 text-slate-400 flex-shrink-0" />
<input
ref={inputRef}
@@ -281,43 +281,42 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base"
className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base font-medium font-mono tracking-tight"
/>
<button
onClick={toggleCommandPalette}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-700 rounded"
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2">
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2 bg-slate-900/40 relative">
{filteredCommands.length === 0 ? (
<div className="px-4 py-8 text-center text-slate-400">
<div className="px-4 py-8 text-center text-slate-400 font-medium">
No commands found
</div>
) : (
Object.entries(groupedCommands).map(([category, cmds]) => (
<div key={category}>
<div className="px-4 py-2 text-xs font-medium text-slate-500 uppercase tracking-wider">
<div key={category} className="mb-2">
<div className="px-4 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-widest sticky top-0 bg-slate-900/80 backdrop-blur-md z-10 border-y border-white/5">
{categoryLabels[category as Command['category']]}
</div>
{cmds.map((cmd) => {
const currentIndex = globalIndex++;
const isSelected = currentIndex === selectedIndex;
return (
<button
key={cmd.id}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(currentIndex)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected
? 'bg-indigo-500/20 text-white'
: 'text-slate-300 hover:bg-slate-700/50'
}`}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-200 ${isSelected
? 'bg-indigo-500/20 text-white shadow-[inset_4px_0_0_rgba(99,102,241,1)]'
: 'text-slate-300 hover:bg-white/5'
}`}
>
<span className={`flex-shrink-0 ${isSelected ? 'text-indigo-400' : 'text-slate-400'}`}>
<span className={`flex-shrink-0 transition-colors ${isSelected ? 'text-indigo-400' : 'text-slate-400'}`}>
{cmd.icon}
</span>
<div className="flex-1 min-w-0">
@@ -339,17 +338,17 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
)}
</div>
<div className="px-4 py-2 border-t border-slate-700 flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400"></kbd>
<div className="px-4 py-3 border-t border-white/10 bg-slate-900/60 flex items-center gap-4 text-[10px] text-slate-400 uppercase tracking-wider font-bold">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm"></kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Enter</kbd>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Enter</kbd>
Select
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Esc</kbd>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Esc</kbd>
Close
</span>
</div>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -28,14 +29,20 @@ export default function FileBrowser({ host, initialPath = '/', onClose }: FileBr
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/files/browse/${host}?path=${encodeURIComponent(path)}`
`${API_BASE_URL}/api/files/${host}?path=${encodeURIComponent(path)}`,
{ headers: { 'Authorization': `Bearer ${useTopologyStore.getState().token}` } }
);
const data = await response.json();
if (data.error) {
setError(data.error);
if (!response.ok) {
setError(data.error || `Failed to fetch files: ${response.status} ${response.statusText}`);
setFiles([]);
} else {
setFiles(data.files || []);
if (data.error) {
setError(data.error);
setFiles([]);
} else {
setFiles(data.files || []);
}
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
@@ -75,71 +82,72 @@ export default function FileBrowser({ host, initialPath = '/', onClose }: FileBr
};
return (
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<div className="flex items-center gap-2">
<span className="text-slate-400">/</span>
<span className="text-cyan-400 font-medium">{host}</span>
<span className="text-slate-500">:</span>
<input
type="text"
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
className="bg-slate-700 text-slate-200 px-2 py-1 rounded text-sm font-mono w-64"
/>
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4">
<div className="w-full max-w-4xl h-[85vh] glass rounded-xl shadow-2xl border border-white/10 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex flex-col sm:flex-row items-center justify-between px-4 py-3 bg-slate-900/60 border-b border-white/10 gap-3">
<div className="flex items-center gap-2 w-full sm:w-auto overflow-x-auto hide-scrollbar">
<span className="text-slate-400 font-bold">/</span>
<span className="text-cyan-400 font-bold tracking-tight whitespace-nowrap">{host}</span>
<span className="text-slate-500 font-bold">:</span>
<input
type="text"
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
className="bg-slate-900/50 border border-white/10 text-slate-200 px-3 py-1.5 rounded-lg text-sm font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50 w-full sm:w-80 transition-all shadow-inner"
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => fetchFiles(currentPath)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => fetchFiles(currentPath)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4 text-slate-400" />
</button>
<button
onClick={onClose}
className="p-1 hover:bg-slate-700 rounded transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="w-6 h-6 text-slate-400 animate-spin" />
</div>
) : error ? (
<div className="text-red-400 p-4">{error}</div>
) : (
<div className="font-mono text-sm">
{currentPath !== '/' && (
<button
onClick={goUp}
className="flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded"
>
<ArrowLeft className="w-4 h-4 text-slate-400" />
<span className="text-slate-400">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.path}
onClick={() => file.type === 'directory' && navigateTo(file.path)}
className={`flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded ${
file.type !== 'directory' ? 'cursor-default' : ''
}`}
>
{getFileIcon(file.type)}
<span className="text-slate-200 flex-1 text-left">{file.name}</span>
<span className="text-slate-500 text-xs">{formatSize(file.size)}</span>
<span className="text-slate-600 text-xs w-24">{file.modified}</span>
</button>
))}
</div>
)}
<div className="flex-1 overflow-auto p-2 bg-slate-900/40">
{loading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="w-6 h-6 text-cyan-500/50 animate-spin" />
</div>
) : error ? (
<div className="text-red-400 p-4 font-mono text-sm">{error}</div>
) : (
<div className="font-mono text-sm">
{currentPath !== '/' && (
<button
onClick={goUp}
className="flex items-center gap-3 w-full px-3 py-2 hover:bg-white/5 rounded-lg transition-colors group"
>
<ArrowLeft className="w-4 h-4 text-slate-400 group-hover:-translate-x-1 transition-transform" />
<span className="text-slate-400 font-bold">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.path}
onClick={() => file.type === 'directory' && navigateTo(file.path)}
className={`flex items-center gap-3 w-full px-3 py-2 hover:bg-white/5 rounded-lg transition-colors ${file.type !== 'directory' ? 'cursor-default' : ''
}`}
>
{getFileIcon(file.type)}
<span className="text-slate-200 flex-1 text-left truncate pr-4">{file.name}</span>
<span className="text-slate-500 text-xs w-20 text-right">{formatSize(file.size)}</span>
<span className="text-slate-500 text-xs w-32 text-right hidden sm:block">{file.modified}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
);

View File

@@ -54,7 +54,8 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
rightPanelOpen,
isLoading: storeLoading,
pollInterval,
setPollInterval
setPollInterval,
toggleSettings
} = useTopologyStore(useShallow((s) => ({
viewMode: s.viewMode,
setViewMode: s.setViewMode,
@@ -73,6 +74,7 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
isLoading: s.isLoading,
pollInterval: s.pollInterval,
setPollInterval: s.setPollInterval,
toggleSettings: s.toggleSettings,
})));
const loading = externalLoading ?? storeLoading;
@@ -84,55 +86,55 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
}, [onRefresh]);
return (
<header className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center">
<header className="h-16 glass border-b border-white/10 px-2 sm:px-4 flex items-center justify-between shrink-0 z-30 relative shadow-lg gap-2">
<div className="flex-1 flex items-center gap-4 overflow-x-auto hide-scrollbar pr-2 sm:pr-4 mask-fade-right">
<div className="flex items-center gap-3 shrink-0 mr-2">
<div className="w-9 h-9 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-[0_0_15px_rgba(99,102,241,0.3)]">
<Network className="w-5 h-5 text-white" />
</div>
<h1 className="text-lg font-semibold text-white">Homelab Topology</h1>
<h1 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-slate-100 to-slate-400 tracking-tight hidden sm:block">Homelab Topology</h1>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-1" role="toolbar" aria-label="View mode">
<div className="flex items-center gap-1 shrink-0 bg-slate-900/40 p-1 rounded-lg border border-white/5" role="toolbar" aria-label="View mode">
{viewModes.map(({ mode, label, icon }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
aria-pressed={viewMode === mode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${viewMode === mode
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-700'
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-all duration-200 ${viewMode === mode
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.2)]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
{icon}
{label}
<span className="hidden md:inline">{label}</span>
</button>
))}
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<label htmlFor="orientation-select" className="visually-hidden">Graph orientation</label>
<select
id="orientation-select"
value={orientation}
onChange={(e) => setOrientation(e.target.value as Orientation)}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
className="h-9 px-3 bg-slate-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
{orientations.map(({ value, label }) => (
<option key={value} value={value}>
<option key={value} value={value} className="bg-slate-800">
{label}
</option>
))}
</select>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0" />
<div className="flex items-center gap-1" role="toolbar" aria-label="Node type filters">
<div className="flex items-center gap-1 shrink-0 bg-slate-900/40 p-1 rounded-lg border border-white/5" role="toolbar" aria-label="Node type filters">
{nodeTypeFilters.map(({ type, icon }) => {
const isActive = typeFilters.includes(type);
const color = getNodeColor(type);
@@ -142,14 +144,15 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
onClick={() => toggleTypeFilter(type)}
aria-pressed={isActive}
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
className={`p-2 rounded-md transition-colors ${isActive
? 'border'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700'
className={`p-1.5 md:p-2 rounded-md transition-all duration-200 transform hover:scale-105 ${isActive
? 'border border-transparent'
: 'text-slate-500 hover:text-slate-300 hover:bg-white/5'
}`}
style={isActive ? {
backgroundColor: `${color}20`,
borderColor: `${color}50`,
color: color
borderColor: `${color}40`,
color: color,
boxShadow: `0 0 10px ${color}20`
} : undefined}
>
{icon}
@@ -158,44 +161,44 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
})}
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0 hidden md:block" />
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 hidden lg:flex">
<label htmlFor="status-filter" className="visually-hidden">Status filter</label>
<select
id="status-filter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
className="h-9 px-3 bg-slate-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
<option value="all">All Status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
<option value="all" className="bg-slate-800">All Status</option>
<option value="running" className="bg-slate-800">Running</option>
<option value="stopped" className="bg-slate-800">Stopped</option>
</select>
</div>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0 hidden lg:block" />
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-slate-400" aria-hidden="true" />
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
<Settings className="w-4 h-4 text-slate-500" aria-hidden="true" />
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
<select
id="poll-interval"
value={pollInterval}
onChange={(e) => setPollInterval(parseInt(e.target.value, 10))}
className="h-9 px-3 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
className="h-9 px-3 bg-slate-900/50 border border-white/10 rounded-lg text-sm text-slate-300 focus:outline-none focus:border-indigo-500/50 cursor-pointer transition-colors hover:bg-slate-800/80"
>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
<option value={60000}>1 minute</option>
<option value={300000}>5 minutes</option>
<option value={10000} className="bg-slate-800">10 seconds</option>
<option value={30000} className="bg-slate-800">30 seconds</option>
<option value={60000} className="bg-slate-800">1 minute</option>
<option value={300000} className="bg-slate-800">5 minutes</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" aria-hidden="true" />
<div className="flex items-center gap-1 sm:gap-3 shrink-0 ml-auto pl-2 sm:pl-4 border-l border-white/10">
<div className="relative shrink-0 hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" aria-hidden="true" />
<label htmlFor="node-search" className="visually-hidden">Search nodes</label>
<input
id="node-search"
@@ -203,7 +206,7 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64 h-9 pl-9 pr-4 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:border-indigo-500"
className="w-48 lg:w-64 h-9 pl-9 pr-4 bg-slate-900/50 border border-white/10 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
@@ -211,32 +214,42 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
onClick={handleRefresh}
disabled={loading}
aria-label={loading ? 'Loading data' : 'Refresh data'}
className="h-9 px-3 flex items-center gap-2 bg-slate-700 hover:bg-slate-600 border border-slate-600 rounded-lg text-sm text-slate-300 transition-colors disabled:opacity-50"
className="h-9 px-2 sm:px-3 flex items-center gap-2 bg-slate-800/80 hover:bg-slate-700/80 border border-white/10 rounded-lg text-sm text-slate-300 transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transform hover:-translate-y-0.5"
>
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
{loading ? 'Loading...' : 'Refresh'}
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin text-indigo-400' : ''}`} aria-hidden="true" />
<span className="hidden lg:inline">{loading ? 'Loading...' : 'Refresh'}</span>
</button>
<div className="h-6 w-px bg-slate-600" />
<div className="h-6 w-px bg-white/10 shrink-0 hidden sm:block" />
<button
onClick={toggleLeftPanel}
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
aria-pressed={leftPanelOpen}
className={`p-2 rounded-lg transition-colors ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
className={`p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-105 ${leftPanelOpen ? 'bg-indigo-500/20 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]' : 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Box className="w-5 h-5" aria-hidden="true" />
<Box className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
<button
onClick={toggleRightPanel}
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
aria-pressed={rightPanelOpen}
className={`p-2 rounded-lg transition-colors ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400' : 'text-slate-400 hover:text-white hover:bg-slate-700'
className={`p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-105 ${rightPanelOpen ? 'bg-indigo-500/20 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]' : 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Database className="w-5 h-5" aria-hidden="true" />
<Database className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
<div className="h-6 w-px bg-white/10 shrink-0" />
<button
onClick={toggleSettings}
aria-label="Configuration Settings"
className="p-1.5 sm:p-2 rounded-lg transition-all duration-200 text-slate-400 hover:text-white hover:bg-white/5 hover:scale-105 hover:rotate-90 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
>
<Settings className="w-5 h-5 sm:w-5 sm:h-5 w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ChevronRight, Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
import { ChevronRight, Server, Network, Wifi, Box, Database, Folder, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../store/topologyStore';
import { getNodeColor } from '../utils/colors';
@@ -39,11 +39,12 @@ const typeLabels: Record<NodeType, string> = {
};
export default function LeftPanel() {
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore(
const { nodes, selectedNodeId, setSelectedNode, toggleLeftPanel } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
toggleLeftPanel: s.toggleLeftPanel,
}))
);
@@ -61,16 +62,25 @@ export default function LeftPanel() {
}, [nodes, selectedNodeId]);
return (
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel">
<div className="h-12 px-4 flex items-center border-b border-slate-700">
<h2 className="text-sm font-semibold text-white uppercase tracking-wide">
{selectedNode ? 'Child Nodes' : 'Select a Node'}
</h2>
{childNodes.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-slate-700 text-slate-300 text-xs rounded-full">
{childNodes.length}
</span>
)}
<aside className="absolute md:relative z-20 h-full w-72 md:w-80 glass-panel border-r border-white/10 flex flex-col shadow-[4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-left-4 duration-300" aria-label="Child nodes panel">
<div className="h-14 px-4 flex items-center justify-between border-b border-white/5 bg-slate-900/40 backdrop-blur-md">
<div className="flex items-center">
<h2 className="text-sm font-semibold text-white tracking-wide">
{selectedNode ? 'Child Nodes' : 'Select a Node'}
</h2>
{childNodes.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-indigo-500/20 text-indigo-300 text-xs rounded-full border border-indigo-500/30">
{childNodes.length}
</span>
)}
</div>
<button
onClick={toggleLeftPanel}
className="p-1 hover:bg-slate-700/50 rounded-lg transition-colors md:hidden"
aria-label="Close panel"
>
<X className="w-5 h-5 text-slate-400" aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
@@ -93,9 +103,9 @@ export default function LeftPanel() {
<button
key={node.id}
onClick={() => setSelectedNode(node.id)}
className={`w-full px-3 py-2 flex items-center gap-3 rounded-lg transition-colors text-left ${selectedNodeId === node.id
? 'bg-indigo-500/20 text-indigo-300'
: 'text-slate-300 hover:bg-slate-700'
className={`w-full px-3 py-2 flex items-center gap-3 rounded-lg transition-all duration-200 text-left ${selectedNodeId === node.id
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.15)]'
: 'text-slate-300 hover:bg-white/5 hover:translate-x-1'
}`}
>
<div
@@ -121,7 +131,7 @@ export default function LeftPanel() {
)}
</div>
{/* Host metrics chart (data-visualizer skill) */}
<div className="border-t border-slate-700/50">
<div className="border-t border-white/5 bg-slate-900/20">
<HostChart />
</div>
</aside>

148
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../store/topologyStore';
import { Lock, User, Key, Server } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSetupMode, setIsSetupMode] = useState(false);
const { setToken } = useTopologyStore();
useEffect(() => {
// Check if setup is required
fetch(`${API_BASE_URL}/api/auth/status`)
.then(res => res.json())
.then(data => {
if (data.setupRequired) {
setIsSetupMode(true);
}
})
.catch(err => console.error('Failed to fetch auth status', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok && data.token) {
setToken(data.token);
} else {
setError(data.message || 'Authentication failed');
}
} catch (err) {
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4 relative overflow-hidden">
{/* Decorative Background Orbs */}
<div className="absolute top-[10%] left-[20%] w-[500px] h-[500px] bg-indigo-600/20 rounded-full mix-blend-screen filter blur-[100px] opacity-50 animate-pulse-slow"></div>
<div className="absolute bottom-[10%] right-[20%] w-[600px] h-[600px] bg-purple-600/20 rounded-full mix-blend-screen filter blur-[100px] opacity-40 animate-pulse-slow" style={{ animationDelay: '1.5s' }}></div>
<div className="max-w-md w-full glass rounded-2xl shadow-2xl overflow-hidden border border-slate-700/50 z-10 relative">
<div className="p-8 text-center bg-slate-900/40 border-b border-slate-700/50 backdrop-blur-sm">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 text-indigo-400 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.15)] group transition-transform duration-300 hover:scale-105">
<Server size={32} className="group-hover:text-indigo-300 transition-colors" />
</div>
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Homelab Topology</h2>
<p className="text-slate-400 mt-2 text-sm">
{isSetupMode ? 'Create your admin account' : 'Sign in to access your dashboard'}
</p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6 bg-slate-900/20">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 p-3 rounded-lg text-sm flex items-start transform transition-all duration-300 shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
<div className="space-y-5">
<div className="group">
<label className="block text-sm font-medium text-slate-300 mb-1.5 transition-colors group-hover:text-indigo-300" htmlFor="username">
Username
</label>
<div className="relative overflow-hidden rounded-lg">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500 transition-colors group-focus-within:text-indigo-400">
<User size={18} />
</div>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full pl-10 bg-slate-900/50 border border-slate-700/50 text-slate-100 placeholder-slate-500 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 focus:bg-slate-800/80 sm:text-sm py-2.5 outline-none transition-all duration-300"
placeholder="admin"
required
/>
</div>
</div>
<div className="group">
<label className="block text-sm font-medium text-slate-300 mb-1.5 transition-colors group-hover:text-indigo-300" htmlFor="password">
Password
</label>
<div className="relative overflow-hidden rounded-lg">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500 transition-colors group-focus-within:text-indigo-400">
<Key size={18} />
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 bg-slate-900/50 border border-slate-700/50 text-slate-100 placeholder-slate-500 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 focus:bg-slate-800/80 sm:text-sm py-2.5 outline-none transition-all duration-300"
placeholder="••••••••"
required
pattern={isSetupMode ? ".{8,}" : ".*"}
title={isSetupMode ? "Password must be at least 8 characters long" : ""}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-[0_0_15px_rgba(99,102,241,0.15)] text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:ring-offset-slate-900 transition-all duration-300 transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
{isLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
) : (
<span className="flex items-center">
<Lock size={16} className="mr-2" />
{isSetupMode ? 'Create Account & Continue' : 'Sign In'}
</span>
)}
</button>
</form>
</div>
</div>
);
}

View File

@@ -73,8 +73,8 @@ export default function RightPanel() {
if (!selectedNode) {
return (
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col items-center justify-center p-4" aria-label="Node details panel">
<div className="text-slate-500 text-sm text-center">
<aside className="absolute right-0 md:relative z-20 h-full w-80 lg:w-96 glass-panel border-l border-white/10 flex flex-col items-center justify-center p-4 shadow-[-4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-right-4 duration-300" aria-label="Node details panel">
<div className="text-slate-400 text-sm text-center">
Select a node to view its details
</div>
</aside>
@@ -86,9 +86,9 @@ export default function RightPanel() {
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
return (
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col" aria-label="Node details panel">
<div className="h-12 px-4 flex items-center justify-between border-b border-slate-700">
<h2 className="text-sm font-semibold text-white truncate">{selectedNode.name}</h2>
<aside className="absolute right-0 md:relative z-20 h-full w-80 lg:w-96 glass-panel border-l border-white/10 flex flex-col shadow-[-4px_0_24px_rgba(0,0,0,0.5)] md:shadow-none animate-in slide-in-from-right-4 duration-300" aria-label="Node details panel">
<div className="h-14 px-4 flex items-center justify-between border-b border-white/5 bg-slate-900/40 backdrop-blur-md">
<h2 className="text-sm font-semibold text-white tracking-wide truncate pr-2">{selectedNode.name}</h2>
<div className="flex items-center gap-1">
{isHost && selectedNodeId && (
<>
@@ -126,7 +126,7 @@ export default function RightPanel() {
</div>
</div>
<div className="flex border-b border-slate-700" role="tablist" aria-label="Node information tabs">
<div className="flex border-b border-white/5 bg-slate-900/20" role="tablist" aria-label="Node information tabs">
{tabs.map((tab, index) => (
<button
key={tab.id}
@@ -137,9 +137,9 @@ export default function RightPanel() {
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-colors ${activeTab === tab.id
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
: 'text-slate-400 hover:text-white'
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-all duration-200 ${activeTab === tab.id
? 'text-indigo-300 bg-indigo-500/20 shadow-[inset_0_-2px_0_rgba(99,102,241,1)]'
: 'text-slate-400 hover:text-white hover:bg-white/5 hover:-translate-y-0.5'
}`}
>
<span aria-hidden="true">{tab.icon}</span>
@@ -210,7 +210,7 @@ function DetailsTab({ node, nodeColor, statusColor }: { node: TopologyNode; node
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Metadata</div>
<div className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
<div className="bg-slate-900/50 border border-white/5 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto shadow-inner">
{JSON.stringify(node.data.metadata, null, 2)}
</div>
</div>
@@ -233,7 +233,7 @@ function ConfigTab({ node }: { node: TopologyNode }) {
return (
<div>
<pre className="bg-slate-900 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto">
<pre className="bg-slate-900/50 border border-white/5 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto shadow-inner">
{node.data.config}
</pre>
</div>
@@ -252,7 +252,7 @@ function FilesTab({ node }: { node: TopologyNode }) {
{files.map((file: string, idx: number) => (
<button
key={idx}
className="w-full px-3 py-2 flex items-center gap-2 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-left transition-colors"
className="w-full px-3 py-2.5 flex items-center gap-2 bg-slate-900/40 border border-white/5 hover:bg-white/5 hover:border-white/10 rounded-lg text-left transition-all duration-200 transform hover:-translate-y-0.5"
>
<FolderOpen className="w-4 h-4 text-slate-400" aria-hidden="true" />
<span className="font-mono text-xs text-slate-300 truncate">{file}</span>

View File

@@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Server, Plus, Trash2, Edit2, CheckCircle2, X } from 'lucide-react';
import { HostConfig } from '../../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function HostConfigTab() {
const { token } = useTopologyStore();
const [hosts, setHosts] = useState<HostConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form state
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<Partial<HostConfig>>({});
useEffect(() => {
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE_URL}/api/hosts`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch hosts');
const data = await res.json();
setHosts(data.hosts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
const isUpdate = !!formData.id;
const url = isUpdate ? `${API_BASE_URL}/api/hosts/${formData.id}` : `${API_BASE_URL}/api/hosts`;
const method = isUpdate ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || 'Failed to save host');
}
await fetchHosts();
setIsEditing(false);
setFormData({});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this host?')) return;
try {
const res = await fetch(`${API_BASE_URL}/api/hosts/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete host');
await fetchHosts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
if (isLoading) return <div className="text-slate-400">Loading hosts...</div>;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">SSH Targets</h3>
<p className="text-sm text-slate-400">Configure linux hosts for auto-discovery</p>
</div>
{!isEditing && (
<button
onClick={() => { setFormData({ sshPort: 22, sshUser: 'root', hostType: 'docker-host' }); setIsEditing(true); }}
className="flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-[0_0_15px_rgba(99,102,241,0.3)] transform hover:-translate-y-0.5"
>
<Plus className="w-4 h-4" /> Add Host
</button>
)}
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg text-sm shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
{isEditing ? (
<form onSubmit={handleSave} className="bg-slate-900/40 p-4 md:p-6 rounded-xl border border-white/5 space-y-4 shadow-inner backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="text-md font-bold text-white">
{formData.id ? 'Edit Host' : 'New Host'}
</h4>
<button type="button" onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Host Name</label>
<input
required
type="text"
value={formData.name || ''}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="e.g. primary-server"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">IP Address</label>
<input
required
type="text"
value={formData.ip || ''}
onChange={e => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="192.168.1.10"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH User</label>
<input
required
type="text"
value={formData.sshUser || ''}
onChange={e => setFormData({ ...formData, sshUser: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH Port</label>
<input
required
type="number"
value={formData.sshPort || 22}
onChange={e => setFormData({ ...formData, sshPort: parseInt(e.target.value) })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">SSH Key Path (Optional)</label>
<input
type="text"
value={formData.sshKeyPath || ''}
onChange={e => setFormData({ ...formData, sshKeyPath: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="~/.ssh/id_ed25519"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Host Type</label>
<select
value={formData.hostType || 'docker-host'}
onChange={e => setFormData({ ...formData, hostType: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="docker-host" className="bg-slate-800">Docker Host</option>
<option value="proxmox" className="bg-slate-800">Proxmox VE</option>
<option value="truenas" className="bg-slate-800">TrueNAS</option>
</select>
</div>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-white/5 mt-6">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
<button type="submit" className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-md transform hover:-translate-y-0.5">
<CheckCircle2 className="w-4 h-4" /> Save
</button>
</div>
</form>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{hosts.length === 0 ? (
<div className="col-span-full py-16 text-center border border-dashed border-white/10 bg-slate-900/20 rounded-xl">
<Server className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<h4 className="text-white font-bold tracking-tight">No hosts configured</h4>
<p className="text-slate-400 text-sm mt-1">Add a host to enable auto-discovery.</p>
</div>
) : (
hosts.map(host => (
<div key={host.id} className="bg-slate-900/40 p-5 rounded-xl border border-white/5 hover:border-indigo-500/30 transition-all duration-300 group shadow-md hover:shadow-[0_0_15px_rgba(99,102,241,0.1)]">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 text-indigo-400 rounded-lg flex items-center justify-center border border-indigo-500/30 shadow-inner">
<Server className="w-6 h-6" />
</div>
<div>
<h4 className="font-bold text-white tracking-tight">{host.name}</h4>
<p className="text-xs text-slate-400 font-mono mt-0.5">{host.ip}</p>
</div>
</div>
<div className="flex opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<button onClick={() => { setFormData(host); setIsEditing(true); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(host.id!)} className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg ml-1 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs text-slate-300 bg-slate-900/60 p-3.5 rounded-lg border border-white/5 shadow-inner font-medium">
<div><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">User</span> {host.sshUser}</div>
<div><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Port</span> {host.sshPort}</div>
<div className="col-span-2 truncate"><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Key</span> <span className="font-mono opacity-80">{host.sshKeyPath || 'Default (~/.ssh/id_ed25519)'}</span></div>
<div className="col-span-2"><span className="text-slate-500 uppercase tracking-wider text-[10px] block mb-0.5">Type</span> <span className="bg-indigo-500/20 text-indigo-300 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-indigo-500/20">{host.hostType}</span></div>
</div>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useState } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Settings, Server, Network, X } from 'lucide-react';
import HostConfigTab from './HostConfigTab';
import TopologyNodeTab from './TopologyNodeTab';
type Tab = 'hosts' | 'topology';
export default function SettingsOverlay() {
const { settingsOpen, toggleSettings } = useTopologyStore();
const [activeTab, setActiveTab] = useState<Tab>('hosts');
if (!settingsOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md p-2 md:p-4">
<div className="glass rounded-xl shadow-2xl w-full max-w-5xl h-[95vh] md:h-[85vh] flex flex-col overflow-hidden border border-white/10 relative">
{/* Header */}
<div className="h-16 border-b border-white/10 bg-slate-900/40 flex items-center justify-between px-4 md:px-6 shrink-0 relative z-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-500/20 text-indigo-400 rounded-lg flex items-center justify-center border border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.2)]">
<Settings className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white tracking-tight">Configuration</h2>
<p className="text-xs text-slate-400">Manage hosts and topology</p>
</div>
</div>
<button
onClick={toggleSettings}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Layout */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden relative z-10">
{/* Sidebar Tabs */}
<div className="w-full md:w-64 bg-slate-900/40 border-b md:border-b-0 md:border-r border-white/10 p-4 shrink-0 flex flex-row md:flex-col gap-2 overflow-x-auto hide-scrollbar">
<button
onClick={() => setActiveTab('hosts')}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 shrink-0 ${activeTab === 'hosts'
? 'bg-indigo-600 text-white shadow-[0_0_15px_rgba(99,102,241,0.3)] transform -translate-y-0.5'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Server className="w-4 h-4" />
SSH Hosts
</button>
<button
onClick={() => setActiveTab('topology')}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 shrink-0 ${activeTab === 'topology'
? 'bg-indigo-600 text-white shadow-[0_0_15px_rgba(99,102,241,0.3)] transform -translate-y-0.5'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Network className="w-4 h-4" />
Static Topology
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto bg-slate-900/20 p-4 md:p-6 relative">
{activeTab === 'hosts' && <HostConfigTab />}
{activeTab === 'topology' && <TopologyNodeTab />}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { useTopologyStore } from '../../store/topologyStore';
import { Network, Plus, Trash2, Edit2, CheckCircle2, X } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function TopologyNodeTab() {
const { token, nodes: currentLiveNodes } = useTopologyStore();
const [dbNodes, setDbNodes] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<any>({});
useEffect(() => {
fetchNodes();
}, []);
const fetchNodes = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE_URL}/api/topology`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch topology nodes');
const data = await res.json();
setDbNodes(data.nodes);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
const isUpdate = !!formData.id;
const url = isUpdate ? `${API_BASE_URL}/api/topology/nodes/${formData.id}` : `${API_BASE_URL}/api/topology/nodes`;
const payload = {
name: formData.name,
type: formData.type,
ip: formData.ip || null,
mac: formData.mac || null,
os: formData.os || null,
status: formData.status || 'unknown',
parentId: formData.parentId || null
};
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to save network node');
await fetchNodes();
setIsEditing(false);
setFormData({});
setError(null);
// Trigger a refresh on the main UI
fetch(`${API_BASE_URL}/api/discover`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this static node?')) return;
try {
const res = await fetch(`${API_BASE_URL}/api/topology/nodes/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete network node');
await fetchNodes();
fetch(`${API_BASE_URL}/api/discover`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
if (isLoading) return <div className="text-slate-400">Loading topology nodes...</div>;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">Static Topology</h3>
<p className="text-sm text-slate-400">Manually add network devices (Gateways, Switches, WiFi)</p>
</div>
{!isEditing && (
<button
onClick={() => { setFormData({ type: 'gateway', status: 'running' }); setIsEditing(true); }}
className="flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-[0_0_15px_rgba(99,102,241,0.3)] transform hover:-translate-y-0.5"
>
<Plus className="w-4 h-4" /> Add Node
</button>
)}
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg text-sm shadow-[0_0_10px_rgba(239,68,68,0.1)]">
{error}
</div>
)}
{isEditing ? (
<form onSubmit={handleSave} className="bg-slate-900/40 p-4 md:p-6 rounded-xl border border-white/5 space-y-4 shadow-inner backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="text-md font-bold text-white">
{formData.id ? 'Edit Static Node' : 'New Static Node'}
</h4>
<button type="button" onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Device Name</label>
<input
required
type="text"
value={formData.name || ''}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
placeholder="e.g. Unifi Dream Machine"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Device Type</label>
<select
value={formData.type || 'gateway'}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="gateway" className="bg-slate-800">Gateway / Router</option>
<option value="vlan" className="bg-slate-800">Switch / VLAN</option>
<option value="wifi" className="bg-slate-800">Access Point</option>
<option value="host_physical" className="bg-slate-800">Physical Device</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">IP Address (Optional)</label>
<input
type="text"
value={formData.ip || ''}
onChange={e => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400 font-medium tracking-wide uppercase">Parent Node (Optional)</label>
<select
value={formData.parentId || ''}
onChange={e => setFormData({ ...formData, parentId: e.target.value || null })}
className="w-full bg-slate-900/50 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all hover:bg-slate-800/50 cursor-pointer"
>
<option value="" className="bg-slate-800">None (Root Node)</option>
{currentLiveNodes
.filter(n => n.id !== formData.id)
.map(node => (
<option key={node.id} value={node.id} className="bg-slate-800">{node.name}</option>
))
}
</select>
</div>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-white/5 mt-6">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
<button type="submit" className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-md transform hover:-translate-y-0.5">
<CheckCircle2 className="w-4 h-4" /> Save
</button>
</div>
</form>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{dbNodes.length === 0 ? (
<div className="col-span-full py-16 text-center border border-dashed border-white/10 bg-slate-900/20 rounded-xl">
<Network className="w-12 h-12 text-slate-600 mx-auto mb-3" />
<h4 className="text-white font-bold tracking-tight">No static nodes</h4>
<p className="text-slate-400 text-sm mt-1">Add gateways or switches to map your core network.</p>
</div>
) : (
dbNodes.map(node => (
<div key={node.id} className="bg-slate-900/40 p-5 rounded-xl border border-white/5 hover:border-indigo-500/30 transition-all duration-300 group shadow-md hover:shadow-[0_0_15px_rgba(99,102,241,0.1)] flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500/20 to-teal-500/20 text-emerald-400 rounded-lg flex items-center justify-center border border-emerald-500/30 shadow-inner">
<Network className="w-6 h-6" />
</div>
<div>
<h4 className="font-bold text-white tracking-tight">{node.name}</h4>
<div className="flex items-center gap-2 mt-0.5">
<span className="bg-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded text-[10px] uppercase tracking-wider border border-emerald-500/20">
{node.type.replace('_', ' ')}
</span>
{node.ip && <span className="text-xs text-slate-400 font-mono">{node.ip}</span>}
</div>
</div>
</div>
<div className="flex opacity-100 lg:opacity-0 lg:group-hover:opacity-100 transition-opacity">
<button onClick={() => { setFormData(node); setIsEditing(true); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(node.id)} className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg ml-1 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { X } from 'lucide-react';
import { useTopologyStore } from '../store/topologyStore';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -51,8 +52,14 @@ export default function TerminalPanel({ host, onClose }: TerminalProps) {
try {
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, command: cmd }),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${useTopologyStore.getState().token}`
},
body: JSON.stringify({
host,
command: cmd,
})
});
const data = await response.json();
if (data.output) {
@@ -106,22 +113,26 @@ export default function TerminalPanel({ host, onClose }: TerminalProps) {
}, [host, currentPath]);
return (
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-mono">$</span>
<span className="text-slate-200 font-medium">{host}</span>
<span className="text-slate-500">:</span>
<span className="text-slate-400 font-mono">{currentPath}</span>
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-2 sm:p-4">
<div className="w-full max-w-6xl h-[95vh] sm:h-[90vh] glass rounded-xl shadow-[0_0_40px_rgba(0,0,0,0.8)] border border-white/10 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-4 py-3 bg-slate-900/80 border-b border-white/10 backdrop-blur-md shrink-0">
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
<span className="text-cyan-400 font-mono font-bold">$</span>
<span className="text-slate-200 font-bold tracking-tight whitespace-nowrap">{host}</span>
<span className="text-slate-500 font-bold">:</span>
<span className="text-slate-400 font-mono text-sm whitespace-nowrap">{currentPath}</span>
</div>
<button
onClick={onClose}
className="p-1.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all duration-200 hover:scale-105 hover:rotate-90 shrink-0 ml-4"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 bg-[#0f172a] p-2 relative">
<div ref={terminalRef} className="absolute inset-2" />
</div>
<button
onClick={onClose}
className="p-1 hover:bg-slate-700 rounded transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div ref={terminalRef} className="flex-1 p-2" />
</div>
);
}

View File

@@ -1,275 +0,0 @@
import { TopologyNode, TopologyEdge, NetworkInfo, Host, ServiceCategory } from '../types';
const serviceCategory: Record<string, ServiceCategory> = {
jellyfin: 'media',
immich: 'media',
sonarr: 'media',
radarr: 'media',
sabnzbd: 'media',
qbittorrent: 'media',
lidarr: 'media',
readarr: 'media',
bazarr: 'media',
tdarr: 'media',
traefik: 'infra',
authentik: 'infra',
vaultwarden: 'infra',
gitea: 'infra',
postgres: 'infra',
portainer: 'infra',
prometheus: 'monitoring',
grafana: 'monitoring',
loki: 'monitoring',
uptimekuma: 'monitoring',
cadvisor: 'monitoring',
nodeexporter: 'monitoring',
alertmanager: 'monitoring',
litellm: 'ai',
ollama: 'ai',
'codeserver-ai': 'ai',
qdrant: 'storage',
};
function getCategory(name: string): ServiceCategory {
const key = Object.keys(serviceCategory).find(k => name.toLowerCase().includes(k));
return serviceCategory[key || ''] || 'other';
}
export const staticNetworkInfo: NetworkInfo = {
gateway: {
model: 'UniFi Dream Machine Pro',
ip: '192.168.1.1'
},
vlans: [
{ id: 1, name: 'Default', subnet: '192.168.1.0/24', purpose: 'Core infrastructure' },
{ id: 3, name: 'Trusted', subnet: '192.168.3.0/24', purpose: 'Trusted devices' },
{ id: 10, name: 'Family', subnet: '192.168.10.0/24', purpose: 'Family devices' },
{ id: 20, name: 'Guest', subnet: '192.168.20.0/24', purpose: 'Guest network' },
{ id: 30, name: 'IoT', subnet: '192.168.30.0/24', purpose: 'IoT devices, Home Assistant' },
{ id: 50, name: 'Production', subnet: '192.168.50.0/24', purpose: 'Production services' }
],
wifi: [
{ ssid: 'Will of D.', vlan: 'default' },
{ ssid: 'Will of D. IoT', vlan: 30 },
{ ssid: 'Family of D.', vlan: 10 }
]
};
export const staticHosts: Host[] = [
{
name: 'ubuntu',
ip: '192.168.50.61',
type: 'vm',
role: 'Primary Docker Host',
containers: ['traefik', 'jellyfin', 'immich', 'authentik', 'gitea', 'prometheus', 'grafana', 'sonarr', 'radarr', 'sabnzbd', 'qbittorrent', 'lidarr', 'readarr', 'bazarr', 'tdarr', 'portainer', 'vaultwarden', 'loki', 'uptimekuma', 'cadvisor', 'nodeexporter', 'alertmanager', 'ollama', 'litellm', 'codeserver-ai', 'glance', 'gotify', 'prowlarr', 'jellyseerr', 'jellystat', 'jellysweep', 'navidrome', 'flaresolverr', 'gluetun', 'crowdsec', 'postgres-shared', 'immich_postgres', 'immich_redis', 'immich_server', 'immich_machine_learning', 'filebrowser', 'dockge', 'jfa-go', 'it-tools', 'bentopdf', 'maintainerr']
},
{
name: 'grizzley',
ip: '192.168.50.84',
type: 'rpi5',
role: 'Edge Services',
containers: ['traefik', 'frigate', 'scrypted', 'cloudflared']
},
{
name: 'ice',
ip: '192.168.50.197',
type: 'rpi5',
role: 'Spare/Development',
containers: []
},
{
name: 'panda',
ip: '192.168.30.196',
type: 'rpi5',
role: 'Home Assistant',
containers: []
},
{
name: 'truenas',
ip: '192.168.50.12',
type: 'physical',
role: 'Storage (NAS)',
containers: ['qdrant']
},
{
name: 'proxmox',
ip: '192.168.50.11',
type: 'physical',
role: 'Hypervisor',
containers: []
}
];
const containerDetails: Record<string, { description: string; ports?: string[]; importance: 1|2|3|4|5 }> = {
traefik: { description: 'Reverse proxy and load balancer', ports: ['80', '443'], importance: 5 },
jellyfin: { description: 'Media server', ports: ['8096', '9090'], importance: 5 },
immich: { description: 'Photo and video management', importance: 4 },
authentik: { description: 'Identity provider and SSO', importance: 5 },
gitea: { description: 'Self-hosted Git service', ports: ['3000', '2222'], importance: 4 },
prometheus: { description: 'Monitoring and metrics', ports: ['9090'], importance: 4 },
grafana: { description: 'Metrics visualization', ports: ['3000'], importance: 4 },
sonarr: { description: 'TV show management', importance: 4 },
radarr: { description: 'Movie management', importance: 4 },
tdarr: { description: 'Video transcoding', importance: 3 },
frigate: { description: 'NVR with local AI', importance: 4 },
vaultwarden: { description: 'Password manager', importance: 5 },
portainer: { description: 'Container management UI', ports: ['9000', '9443'], importance: 3 },
ollama: { description: 'Local LLM runtime', importance: 4 },
litellm: { description: 'LLM API gateway', ports: ['4000'], importance: 4 },
codeserver: { description: 'Browser-based VS Code', ports: ['8443'], importance: 3 },
};
function createNodesFromData(): TopologyNode[] {
const nodes: TopologyNode[] = [];
nodes.push({
id: 'gateway',
type: 'gateway',
name: 'UniFi Gateway',
data: {
status: 'running',
metadata: { model: 'UniFi Dream Machine Pro', ip: '192.168.1.1' },
importance: 5,
description: 'Main network gateway and firewall'
}
});
staticNetworkInfo.vlans.forEach((vlan) => {
nodes.push({
id: `vlan-${vlan.id}`,
type: 'vlan',
name: `VLAN ${vlan.id}: ${vlan.name}`,
data: {
status: 'running',
metadata: { subnet: vlan.subnet, purpose: vlan.purpose },
importance: 4,
description: vlan.purpose || '',
parentId: 'gateway'
}
});
});
staticNetworkInfo.wifi.forEach((wifi) => {
nodes.push({
id: `wifi-${wifi.ssid.replace(/\s+/g, '-')}`,
type: 'wifi',
name: wifi.ssid,
data: {
status: 'running',
metadata: { vlan: wifi.vlan },
importance: 3,
parentId: 'gateway'
}
});
});
const hostTypeMap: Record<string, 'host_physical' | 'host_vm' | 'host_container'> = {
physical: 'host_physical',
vm: 'host_vm',
rpi5: 'host_container',
container: 'host_container'
};
staticHosts.forEach((host) => {
const hostNode: TopologyNode = {
id: host.name,
type: hostTypeMap[host.type] || 'host_physical',
name: `${host.name} (${host.ip})`,
data: {
ip: host.ip,
status: 'running',
metadata: { role: host.role, type: host.type, containerCount: host.containers.length },
importance: host.role.includes('Primary') ? 5 : 4,
description: host.role,
parentId: 'vlan-50'
}
};
nodes.push(hostNode);
host.containers.forEach((container) => {
const details = containerDetails[container.replace(/-/g, '')] || { description: container, importance: 3 };
const portStr = details.ports ? details.ports.join(', ') : undefined;
nodes.push({
id: `${host.name}-${container}`,
type: 'service',
name: container,
data: {
status: 'running',
metadata: {
host: host.name,
image: `${container}:latest`,
ports: portStr
},
category: getCategory(container),
importance: details.importance,
description: details.description,
parentId: host.name
}
});
});
});
nodes.push({
id: 'truenas-nfs',
type: 'mount',
name: '/mnt/truenas/media',
data: {
status: 'running',
metadata: { type: 'nfs', server: '192.168.50.12' },
importance: 5,
description: 'TrueNAS NFS mount for media storage',
parentId: 'truenas'
}
});
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
nodes.push({
id: `path-${path.replace(/\//g, '-')}`,
type: 'path',
name: path,
data: {
status: 'running',
metadata: { type: 'filesystem' },
importance: 4,
parentId: 'truenas-nfs'
}
});
});
return nodes;
}
function createEdgesFromData(): TopologyEdge[] {
const edges: TopologyEdge[] = [];
edges.push({ id: 'e-gateway-vlan50', source: 'gateway', target: 'vlan-50' });
staticNetworkInfo.vlans.forEach((vlan) => {
if (vlan.id !== 50) {
edges.push({ id: `e-gateway-vlan${vlan.id}`, source: 'gateway', target: `vlan-${vlan.id}` });
}
});
staticNetworkInfo.wifi.forEach((wifi) => {
edges.push({ id: `e-gateway-wifi-${wifi.ssid.replace(/\s+/g, '-')}`, source: 'gateway', target: `wifi-${wifi.ssid.replace(/\s+/g, '-')}` });
});
staticHosts.forEach((host) => {
edges.push({ id: `e-vlan50-${host.name}`, source: 'vlan-50', target: host.name });
host.containers.forEach((container) => {
edges.push({ id: `e-${host.name}-${container}`, source: host.name, target: `${host.name}-${container}` });
});
});
edges.push({ id: 'e-truenas-nfs', source: 'truenas', target: 'truenas-nfs' });
['/movies', '/tv', '/music', '/photos'].forEach((path) => {
edges.push({ id: `e-nfs-${path.replace(/\//g, '-')}`, source: 'truenas-nfs', target: `path-${path.replace(/\//g, '-')}` });
});
return edges;
}
export const initialNodes = createNodesFromData();
export const initialEdges = createEdgesFromData();

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -29,6 +31,37 @@ body {
height: 100vh;
}
/* ── Custom Scrollbar ────────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ── Glassmorphism Utility ───────────────────────────────── */
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-panel {
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.05);
border-left: 1px solid rgba(255, 255, 255, 0.05);
}
/* ── Skip link (a11y) ────────────────────────────────────── */
.skip-link {
position: absolute;

View File

@@ -41,6 +41,13 @@ interface TopologyState {
staleWarningDismissed: boolean;
collapsedNodes: string[];
token: string | null;
setToken: (token: string | null) => void;
logout: () => void;
settingsOpen: boolean;
toggleSettings: () => void;
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
setNodes: (nodes: TopologyNode[]) => void;
setEdges: (edges: TopologyEdge[]) => void;
@@ -84,8 +91,8 @@ export const useTopologyStore = create<TopologyState>()(
searchQuery: '',
typeFilters: ALL_NODE_TYPES,
statusFilter: 'all',
leftPanelOpen: true,
rightPanelOpen: true,
leftPanelOpen: typeof window !== 'undefined' ? window.innerWidth >= 768 : true,
rightPanelOpen: false,
lastUpdated: null,
isLoading: false,
pollInterval: 30000,
@@ -101,6 +108,12 @@ export const useTopologyStore = create<TopologyState>()(
connectionStatus: 'polling',
staleWarningDismissed: false,
collapsedNodes: [],
token: null,
settingsOpen: false,
setToken: (token) => set({ token }),
logout: () => set({ token: null }),
toggleSettings: () => set((state) => ({ settingsOpen: !state.settingsOpen })),
setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }),
@@ -116,7 +129,13 @@ export const useTopologyStore = create<TopologyState>()(
path.push(currentNode.data.parentId);
currentNode = state.nodes.find(n => n.id === currentNode?.data?.parentId);
}
set({ selectedNodeId: nodeId, highlightPath: path });
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
set({
selectedNodeId: nodeId,
highlightPath: path,
rightPanelOpen: true,
...(isMobile ? { leftPanelOpen: false } : {})
});
},
setViewMode: (mode) => set({ viewMode: mode }),
setOrientation: (orientation) => set({ orientation }),
@@ -130,8 +149,20 @@ export const useTopologyStore = create<TopologyState>()(
};
}),
setStatusFilter: (filter) => set({ statusFilter: filter }),
toggleLeftPanel: () => set((state) => ({ leftPanelOpen: !state.leftPanelOpen })),
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
toggleLeftPanel: () => set((state) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return {
leftPanelOpen: !state.leftPanelOpen,
...(isMobile && !state.leftPanelOpen ? { rightPanelOpen: false } : {})
};
}),
toggleRightPanel: () => set((state) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return {
rightPanelOpen: !state.rightPanelOpen,
...(isMobile && !state.rightPanelOpen ? { leftPanelOpen: false } : {})
};
}),
setLastUpdated: (date) => set({ lastUpdated: date }),
setIsLoading: (loading) => set({ isLoading: loading }),
setPollInterval: (interval) => set({ pollInterval: interval }),
@@ -201,15 +232,14 @@ export const useTopologyStore = create<TopologyState>()(
searchQuery: state.searchQuery,
typeFilters: state.typeFilters,
statusFilter: state.statusFilter,
leftPanelOpen: state.leftPanelOpen,
rightPanelOpen: state.rightPanelOpen,
pollInterval: state.pollInterval,
collapsedNodes: state.collapsedNodes,
nodes: state.nodes,
edges: state.edges,
hosts: state.hosts,
networkInfo: state.networkInfo,
lastUpdated: state.lastUpdated
lastUpdated: state.lastUpdated,
token: state.token
})
}), { name: 'TopologyStore' }));

View File

@@ -57,6 +57,16 @@ export interface Host {
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
}
export interface HostConfig {
id?: string;
name: string;
ip: string;
sshUser: string;
sshKeyPath?: string | null;
sshPort: number;
hostType: string;
}
export interface VLAN {
id: number;
name: string;

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/CommandPalette.tsx","./src/components/FileBrowser.tsx","./src/components/Header.tsx","./src/components/LeftPanel.tsx","./src/components/RightPanel.tsx","./src/components/StaleWarning.tsx","./src/components/TerminalPanel.tsx","./src/components/Dashboard/HostChart.tsx","./src/components/Dashboard/MetricsBar.tsx","./src/components/Graph/TopologyGraph.tsx","./src/data/staticConfig.ts","./src/services/discovery.ts","./src/services/sshDiscovery.ts","./src/store/topologyStore.test.ts","./src/store/topologyStore.ts","./src/types/index.ts","./src/utils/colors.test.ts","./src/utils/colors.ts"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/CommandPalette.tsx","./src/components/FileBrowser.tsx","./src/components/Header.tsx","./src/components/LeftPanel.tsx","./src/components/Login.tsx","./src/components/RightPanel.tsx","./src/components/StaleWarning.tsx","./src/components/TerminalPanel.tsx","./src/components/Dashboard/HostChart.tsx","./src/components/Dashboard/MetricsBar.tsx","./src/components/Graph/TopologyGraph.tsx","./src/components/Settings/HostConfigTab.tsx","./src/components/Settings/SettingsOverlay.tsx","./src/components/Settings/TopologyNodeTab.tsx","./src/services/discovery.ts","./src/services/sshDiscovery.ts","./src/store/topologyStore.test.ts","./src/store/topologyStore.ts","./src/types/index.ts","./src/utils/colors.test.ts","./src/utils/colors.ts"],"version":"5.6.3"}