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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
6
dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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
302
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
88
prisma/migrations/20260223234241_init/migration.sql
Normal file
88
prisma/migrations/20260223234241_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
75
prisma/schema.prisma
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
27
server/middleware/auth.ts
Normal 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
78
server/routes/auth.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
50
server/routes/hosts.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
71
server/routes/topology.ts
Normal 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;
|
||||
39
src/App.tsx
39
src/App.tsx
@@ -17,6 +17,8 @@ import CommandPalette from './components/CommandPalette';
|
||||
import StaleWarning from './components/StaleWarning';
|
||||
import TerminalPanel from './components/TerminalPanel';
|
||||
import MetricsBar from './components/Dashboard/MetricsBar';
|
||||
import Login from './components/Login';
|
||||
import SettingsOverlay from './components/Settings/SettingsOverlay';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -60,6 +62,7 @@ function App() {
|
||||
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
|
||||
})));
|
||||
|
||||
const token = useTopologyStore((s) => s.token);
|
||||
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
|
||||
|
||||
const {
|
||||
@@ -69,6 +72,8 @@ function App() {
|
||||
pollInterval,
|
||||
terminalOpen,
|
||||
terminalHost,
|
||||
toggleLeftPanel,
|
||||
toggleRightPanel,
|
||||
} = useTopologyStore(useShallow((s) => ({
|
||||
leftPanelOpen: s.leftPanelOpen,
|
||||
rightPanelOpen: s.rightPanelOpen,
|
||||
@@ -76,6 +81,8 @@ function App() {
|
||||
pollInterval: s.pollInterval,
|
||||
terminalOpen: s.terminalOpen,
|
||||
terminalHost: s.terminalHost,
|
||||
toggleLeftPanel: s.toggleLeftPanel,
|
||||
toggleRightPanel: s.toggleRightPanel,
|
||||
})));
|
||||
|
||||
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
|
||||
@@ -99,7 +106,10 @@ function App() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${useTopologyStore.getState().token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -253,6 +263,10 @@ function App() {
|
||||
};
|
||||
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
|
||||
|
||||
if (!token) {
|
||||
return <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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
148
src/components/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
236
src/components/Settings/HostConfigTab.tsx
Normal file
236
src/components/Settings/HostConfigTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/Settings/SettingsOverlay.tsx
Normal file
73
src/components/Settings/SettingsOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
src/components/Settings/TopologyNodeTab.tsx
Normal file
224
src/components/Settings/TopologyNodeTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
Reference in New Issue
Block a user