Compare commits
2 Commits
d40be883fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0910c966a5 | |||
| df02542c26 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,9 @@ dist-ssr/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|||||||
8
dist/index.html
vendored
8
dist/index.html
vendored
@@ -9,10 +9,10 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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-BsyZf1s0.js"></script>
|
<script type="module" crossorigin src="/assets/index-BAJgTfdz.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-C44rQwKI.js">
|
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-pGkIx_vZ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DpLh-vKM.js">
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BSwt2l3v.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-soHg8pn4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CYGIQDBj.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# Backend API + WebSocket server
|
# Backend API + WebSocket server
|
||||||
backend:
|
backend:
|
||||||
@@ -43,6 +45,26 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: homelab-topology
|
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/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -32,10 +35,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express-rate-limit": "^5.1.3",
|
"@types/express-rate-limit": "^5.1.3",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pino": "^7.0.4",
|
"@types/pino": "^7.0.4",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
@@ -49,6 +55,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
@@ -1338,6 +1345,75 @@
|
|||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1837,6 +1913,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -2051,6 +2137,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.11",
|
"version": "22.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||||
@@ -2878,6 +2982,20 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"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": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"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": "^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": {
|
"node_modules/buildcheck": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
@@ -3670,6 +3794,18 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -3684,6 +3820,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -4951,6 +5096,61 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5017,6 +5217,42 @@
|
|||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -5024,6 +5260,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -5254,6 +5496,26 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -5796,6 +6058,26 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
@@ -6225,6 +6507,26 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/safe-stable-stringify": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"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/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -39,10 +42,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express-rate-limit": "^5.1.3",
|
"@types/express-rate-limit": "^5.1.3",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pino": "^7.0.4",
|
"@types/pino": "^7.0.4",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
@@ -56,6 +62,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "~5.6.2",
|
"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 { homedir } from 'os';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { HostConfig } from './types';
|
import { HostConfig } from './types';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const prisma = new PrismaClient();
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export async function getHostConfigs(): Promise<HostConfig[]> {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
const dbConfigs = await prisma.hostConfig.findMany();
|
||||||
const data = JSON.parse(content);
|
return dbConfigs.map(h => ({
|
||||||
|
name: h.name,
|
||||||
if (!data.hosts || !Array.isArray(data.hosts)) {
|
ip: h.ip,
|
||||||
console.error('No hosts array in config');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const hosts = data.hosts.map((h: Partial<HostConfig>) => ({
|
|
||||||
name: h.name || '',
|
|
||||||
ip: h.ip || '',
|
|
||||||
sshUser: h.sshUser || 'bear',
|
sshUser: h.sshUser || 'bear',
|
||||||
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
|
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
|
||||||
sshPort: h.sshPort || 22,
|
sshPort: h.sshPort || 22,
|
||||||
})).filter((h: HostConfig) => h.name && h.ip);
|
hostType: h.hostType,
|
||||||
|
}));
|
||||||
console.error('Loaded hosts:', JSON.stringify(hosts));
|
} catch (error) {
|
||||||
return hosts;
|
console.error('Error fetching host configs from DB:', error);
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Config parse error:', e.message);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHostConfigs(): HostConfig[] {
|
export async function hasConfig(): Promise<boolean> {
|
||||||
const envHosts = parseEnvHosts();
|
try {
|
||||||
if (envHosts.length > 0) {
|
const count = await prisma.hostConfig.count();
|
||||||
return envHosts;
|
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 statsRouter from './routes/stats';
|
||||||
import filesRouter from './routes/files';
|
import filesRouter from './routes/files';
|
||||||
import terminalRouter from './routes/terminal';
|
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 { getHostConfigs } from './config';
|
||||||
import { requestLogger, logger } from './middleware/requestLogger';
|
import { requestLogger, logger } from './middleware/requestLogger';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
@@ -17,10 +21,13 @@ const app = express();
|
|||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const PORT = 3001;
|
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) ---
|
// --- Socket.IO setup (websocket-engineer skill) ---
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
origin: corsOrigin,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
pingInterval: 25000,
|
pingInterval: 25000,
|
||||||
@@ -45,7 +52,7 @@ app.use(helmet({
|
|||||||
|
|
||||||
// CORS — restrict to configured origins
|
// CORS — restrict to configured origins
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
origin: corsOrigin,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -85,18 +92,23 @@ app.get('/api/health', (_req, res) => {
|
|||||||
|
|
||||||
// --- Debug endpoint (dev only) ---
|
// --- Debug endpoint (dev only) ---
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
app.get('/api/debug-config', (_req, res) => {
|
app.get('/api/debug-config', async (_req, res) => {
|
||||||
const hosts = getHostConfigs();
|
const hosts = await getHostConfigs();
|
||||||
res.json({ hosts });
|
res.json({ hosts });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Routes ---
|
// --- Public Routes ---
|
||||||
app.use('/api', discoverRouter);
|
app.use('/api', authRouter);
|
||||||
app.use('/api', configRouter);
|
|
||||||
app.use('/api', statsRouter);
|
// --- Protected Routes ---
|
||||||
app.use('/api', filesRouter);
|
app.use('/api', requireAuth, discoverRouter);
|
||||||
app.use('/api', terminalRouter);
|
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) ---
|
// --- Global error handler (must be last) ---
|
||||||
app.use(errorHandler);
|
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 {
|
try {
|
||||||
// Find host config
|
// Find host config
|
||||||
const hostConfigs = getHostConfigs();
|
const hostConfigs = await getHostConfigs();
|
||||||
const hostConfig = hostConfigs.find(h => h.name === host);
|
const hostConfig = hostConfigs.find(h => h.name === host);
|
||||||
|
|
||||||
if (!hostConfig) {
|
if (!hostConfig) {
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { getHostConfigs } from '../config';
|
import { getHostConfigs } from '../config';
|
||||||
import { DiscoveryResponse } from '../types';
|
import { DiscoveryResponse } from '../types';
|
||||||
|
import { io } from '../index';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
interface HostDiscoveryResult {
|
interface HostDiscoveryResult {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,11 +38,11 @@ async function discoverHost(
|
|||||||
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
|
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
|
||||||
const keyArg = `-i ${keyPath}`;
|
const keyArg = `-i ${keyPath}`;
|
||||||
const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : '';
|
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 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 dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 });
|
||||||
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
|
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
|
||||||
|
|
||||||
let services: string[] = [];
|
let services: string[] = [];
|
||||||
if (hostType !== 'proxmox') {
|
if (hostType !== 'proxmox') {
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +53,7 @@ async function discoverHost(
|
|||||||
console.error(`DEBUG: ${name} systemd discovery failed`);
|
console.error(`DEBUG: ${name} systemd discovery failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
|
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
|
||||||
if (hostType === 'proxmox' || name === 'proxmox') {
|
if (hostType === 'proxmox' || name === 'proxmox') {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +66,7 @@ async function discoverHost(
|
|||||||
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' });
|
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 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 vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 });
|
||||||
const vmLines = vmOutput.trim().split('\n').slice(1);
|
const vmLines = vmOutput.trim().split('\n').slice(1);
|
||||||
@@ -77,7 +80,7 @@ async function discoverHost(
|
|||||||
console.error(`DEBUG: ${name} Proxmox discovery failed`);
|
console.error(`DEBUG: ${name} Proxmox discovery failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
ip,
|
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) => {
|
router.post('/discover', async (req, res) => {
|
||||||
try {
|
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) {
|
if (hosts.length === 0) {
|
||||||
const response: DiscoveryResponse = {
|
const response: DiscoveryResponse = {
|
||||||
hosts: [],
|
hosts: [],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
errors: ['No hosts configured'],
|
errors: ['No hosts configured'],
|
||||||
};
|
};
|
||||||
return res.json(response);
|
await cacheAndEmit(response);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: HostDiscoveryResult[] = [];
|
const results: HostDiscoveryResult[] = [];
|
||||||
@@ -134,7 +172,7 @@ router.post('/discover', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
results.forEach((result: HostDiscoveryResult) => {
|
results.forEach((result: HostDiscoveryResult) => {
|
||||||
if (!result.online && result.error) {
|
if (!result.online && result.error) {
|
||||||
@@ -155,15 +193,26 @@ router.post('/discover', async (req, res) => {
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(response);
|
await cacheAndEmit(response);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const response: DiscoveryResponse = {
|
console.error('Background discovery failed:', error);
|
||||||
hosts: [],
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
errors: [error.message || 'Discovery failed'],
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ router.get('/files/:host/:container', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { host, container } = req.params;
|
const { host, container } = req.params;
|
||||||
|
|
||||||
const hosts = getHostConfigs();
|
const hosts = await getHostConfigs();
|
||||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
if (!hostConfig) {
|
if (!hostConfig) {
|
||||||
@@ -240,7 +240,7 @@ router.get('/files/browse/:host', async (req, res) => {
|
|||||||
const { host } = req.params;
|
const { host } = req.params;
|
||||||
const path = (req.query.path as string) || '/';
|
const path = (req.query.path as string) || '/';
|
||||||
|
|
||||||
const hosts = getHostConfigs();
|
const hosts = await getHostConfigs();
|
||||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
if (!hostConfig) {
|
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;
|
const { host, container } = req.params;
|
||||||
|
|
||||||
// Find host config by name
|
// Find host config by name
|
||||||
const hosts = getHostConfigs();
|
const hosts = await getHostConfigs();
|
||||||
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
||||||
|
|
||||||
if (!hostConfig) {
|
if (!hostConfig) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router.post('/terminal/exec', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing host or command' });
|
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);
|
const hostConfig = hosts.find(h => h.name === hostName);
|
||||||
|
|
||||||
if (!hostConfig) {
|
if (!hostConfig) {
|
||||||
@@ -44,7 +44,7 @@ router.post('/terminal/exec', async (req, res) => {
|
|||||||
|
|
||||||
router.get('/terminal/hosts', async (_req, res) => {
|
router.get('/terminal/hosts', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const hosts = getHostConfigs();
|
const hosts = await getHostConfigs();
|
||||||
res.json({
|
res.json({
|
||||||
hosts: hosts.map(h => ({
|
hosts: hosts.map(h => ({
|
||||||
name: h.name,
|
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;
|
||||||
70
src/App.tsx
70
src/App.tsx
@@ -17,6 +17,8 @@ import CommandPalette from './components/CommandPalette';
|
|||||||
import StaleWarning from './components/StaleWarning';
|
import StaleWarning from './components/StaleWarning';
|
||||||
import TerminalPanel from './components/TerminalPanel';
|
import TerminalPanel from './components/TerminalPanel';
|
||||||
import MetricsBar from './components/Dashboard/MetricsBar';
|
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';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ function App() {
|
|||||||
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
|
setLastSuccessfulDiscovery: s.setLastSuccessfulDiscovery,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
const token = useTopologyStore((s) => s.token);
|
||||||
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
|
const setConnectionStatus = useTopologyStore((s) => s.setConnectionStatus);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -69,6 +72,8 @@ function App() {
|
|||||||
pollInterval,
|
pollInterval,
|
||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalHost,
|
terminalHost,
|
||||||
|
toggleLeftPanel,
|
||||||
|
toggleRightPanel,
|
||||||
} = useTopologyStore(useShallow((s) => ({
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
leftPanelOpen: s.leftPanelOpen,
|
leftPanelOpen: s.leftPanelOpen,
|
||||||
rightPanelOpen: s.rightPanelOpen,
|
rightPanelOpen: s.rightPanelOpen,
|
||||||
@@ -76,6 +81,8 @@ function App() {
|
|||||||
pollInterval: s.pollInterval,
|
pollInterval: s.pollInterval,
|
||||||
terminalOpen: s.terminalOpen,
|
terminalOpen: s.terminalOpen,
|
||||||
terminalHost: s.terminalHost,
|
terminalHost: s.terminalHost,
|
||||||
|
toggleLeftPanel: s.toggleLeftPanel,
|
||||||
|
toggleRightPanel: s.toggleRightPanel,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
|
const toggleCommandPalette = useTopologyStore((s) => s.toggleCommandPalette);
|
||||||
@@ -86,15 +93,23 @@ function App() {
|
|||||||
const pollIntervalRef = useRef(pollInterval);
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
pollIntervalRef.current = pollInterval;
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async (isBackgroundPoll = false) => {
|
||||||
if (isLoadingRef.current) return;
|
if (isLoadingRef.current) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
// Only set loading state if there are no existing nodes (initial fresh load)
|
||||||
|
// or if this is an explicit user refresh (not background poll).
|
||||||
|
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
|
||||||
|
if (isInitialLoad && !isBackgroundPoll) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
const response = await fetch(`${API_BASE_URL}/api/discover`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${useTopologyStore.getState().token}`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -184,18 +199,20 @@ function App() {
|
|||||||
}, [toggleCommandPalette]);
|
}, [toggleCommandPalette]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
|
||||||
|
loadData(!isInitialLoad); // Poll in background if we already have nodes
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalId = setInterval(loadData, pollIntervalRef.current);
|
// Rely on pollInterval from store state instead of ref
|
||||||
|
const intervalId = setInterval(() => loadData(true), pollInterval);
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [loadData]);
|
}, [loadData, pollInterval]);
|
||||||
|
|
||||||
// --- WebSocket connection (websocket-engineer skill) ---
|
// --- WebSocket connection (websocket-engineer skill) ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket: Socket = ioClient(API_BASE_URL, {
|
const socket: Socket = ioClient(API_BASE_URL, {
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['polling', 'websocket'],
|
||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 1000,
|
||||||
reconnectionDelayMax: 5000,
|
reconnectionDelayMax: 5000,
|
||||||
reconnectionAttempts: Infinity,
|
reconnectionAttempts: Infinity,
|
||||||
@@ -214,7 +231,14 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Listen for real-time topology updates
|
// Listen for real-time topology updates
|
||||||
|
|
||||||
|
let lastWsUpdate = 0;
|
||||||
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
|
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
|
||||||
|
// Throttle updates to max 1 per second to prevent UI freezes
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastWsUpdate < 1000) return;
|
||||||
|
lastWsUpdate = now;
|
||||||
|
|
||||||
if (data?.hosts) {
|
if (data?.hosts) {
|
||||||
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
|
||||||
name: h.name,
|
name: h.name,
|
||||||
@@ -239,6 +263,10 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
|
}, [setConnectionStatus, setNodes, setEdges, setLastUpdated]);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
<div className="h-screen w-screen flex flex-col bg-slate-900">
|
||||||
@@ -251,22 +279,37 @@ function App() {
|
|||||||
<Header onRefresh={loadData} isLoading={isLoading} />
|
<Header onRefresh={loadData} isLoading={isLoading} />
|
||||||
<MetricsBar />
|
<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 && (
|
{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 />
|
<TopologyGraph />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rightPanelOpen && (
|
{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>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<SettingsOverlay />
|
||||||
{terminalOpen && terminalHost && (
|
{terminalOpen && terminalHost && (
|
||||||
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
<TerminalPanel host={terminalHost} onClose={closeTerminal} />
|
||||||
)}
|
)}
|
||||||
@@ -288,9 +331,10 @@ const Footer = memo(function Footer() {
|
|||||||
const pollIntervalRef = useRef(pollInterval);
|
const pollIntervalRef = useRef(pollInterval);
|
||||||
pollIntervalRef.current = pollInterval;
|
pollIntervalRef.current = pollInterval;
|
||||||
|
|
||||||
const formatTime = (date: Date | null) => {
|
const formatTime = (date: Date | string | null) => {
|
||||||
if (!date) return 'Never';
|
if (!date) return 'Never';
|
||||||
return date.toLocaleTimeString();
|
const d = new Date(date);
|
||||||
|
return isNaN(d.getTime()) ? 'Never' : d.toLocaleTimeString();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ interface Command {
|
|||||||
action: () => void;
|
action: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeTypeLabels: Record<NodeType, string> = {
|
const nodeTypeLabels: Record<NodeType, string> = {
|
||||||
gateway: 'Gateway',
|
gateway: 'Gateway',
|
||||||
vlan: 'VLAN',
|
vlan: 'VLAN',
|
||||||
wifi: 'WiFi',
|
wifi: 'WiFi',
|
||||||
host_physical: 'Physical Host',
|
host_physical: 'Physical Host',
|
||||||
host_vm: 'VM Host',
|
host_vm: 'VM Host',
|
||||||
host_container: 'Container Host',
|
host_container: 'Container Host',
|
||||||
vm_lxc: 'LXC Container',
|
vm_lxc: 'LXC Container',
|
||||||
vm_qemu: 'QEMU VM',
|
vm_qemu: 'QEMU VM',
|
||||||
systemd_service: 'Systemd Service',
|
systemd_service: 'Systemd Service',
|
||||||
service: 'Service',
|
service: 'Service',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
mount: 'Mount',
|
mount: 'Mount',
|
||||||
path: 'Path',
|
path: 'Path',
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
gateway: <Router className="w-4 h-4" />,
|
gateway: <Router className="w-4 h-4" />,
|
||||||
@@ -47,14 +47,14 @@ const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
|||||||
function fuzzyMatch(pattern: string, text: string): boolean {
|
function fuzzyMatch(pattern: string, text: string): boolean {
|
||||||
const patternLower = pattern.toLowerCase();
|
const patternLower = pattern.toLowerCase();
|
||||||
const textLower = text.toLowerCase();
|
const textLower = text.toLowerCase();
|
||||||
|
|
||||||
let patternIdx = 0;
|
let patternIdx = 0;
|
||||||
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
|
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
|
||||||
if (textLower[i] === patternLower[patternIdx]) {
|
if (textLower[i] === patternLower[patternIdx]) {
|
||||||
patternIdx++;
|
patternIdx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return patternIdx === patternLower.length;
|
return patternIdx === patternLower.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ interface CommandPaletteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
||||||
const {
|
const {
|
||||||
commandPaletteOpen,
|
commandPaletteOpen,
|
||||||
toggleCommandPalette,
|
toggleCommandPalette,
|
||||||
typeFilters,
|
typeFilters,
|
||||||
toggleTypeFilter,
|
toggleTypeFilter,
|
||||||
@@ -170,7 +170,7 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nodeTypes: NodeType[] = [
|
const nodeTypes: NodeType[] = [
|
||||||
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
|
'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm',
|
||||||
'host_container', 'service', 'volume', 'mount', 'path'
|
'host_container', 'service', 'volume', 'mount', 'path'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -267,12 +267,12 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
|||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/60 backdrop-blur-md px-4"
|
||||||
onClick={handleBackdropClick}
|
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="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-3 border-b border-slate-700">
|
<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" />
|
<Search className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -281,43 +281,42 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
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
|
<button
|
||||||
onClick={toggleCommandPalette}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{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
|
No commands found
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(groupedCommands).map(([category, cmds]) => (
|
Object.entries(groupedCommands).map(([category, cmds]) => (
|
||||||
<div key={category}>
|
<div key={category} className="mb-2">
|
||||||
<div className="px-4 py-2 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
<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']]}
|
{categoryLabels[category as Command['category']]}
|
||||||
</div>
|
</div>
|
||||||
{cmds.map((cmd) => {
|
{cmds.map((cmd) => {
|
||||||
const currentIndex = globalIndex++;
|
const currentIndex = globalIndex++;
|
||||||
const isSelected = currentIndex === selectedIndex;
|
const isSelected = currentIndex === selectedIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={cmd.id}
|
key={cmd.id}
|
||||||
onClick={cmd.action}
|
onClick={cmd.action}
|
||||||
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-200 ${isSelected
|
||||||
isSelected
|
? 'bg-indigo-500/20 text-white shadow-[inset_4px_0_0_rgba(99,102,241,1)]'
|
||||||
? 'bg-indigo-500/20 text-white'
|
: 'text-slate-300 hover:bg-white/5'
|
||||||
: 'text-slate-300 hover:bg-slate-700/50'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<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}
|
{cmd.icon}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -339,17 +338,17 @@ export default function CommandPalette({ onRefresh }: CommandPaletteProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-2 border-t border-slate-700 flex items-center gap-4 text-xs text-slate-500">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">↑↓</kbd>
|
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">↑↓</kbd>
|
||||||
Navigate
|
Navigate
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Enter</kbd>
|
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Enter</kbd>
|
||||||
Select
|
Select
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-400">Esc</kbd>
|
<kbd className="px-1.5 py-0.5 bg-slate-800 border border-slate-700 rounded text-slate-300 shadow-sm">Esc</kbd>
|
||||||
Close
|
Close
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,25 +28,35 @@ export default function HostChart() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostData = useMemo(() => {
|
const hostData = useMemo(() => {
|
||||||
// Find host-type nodes
|
// Build a parent mapping of children counts in one O(N) pass
|
||||||
const hosts = nodes.filter(
|
const hostChildrenCounts = new Map<string, { total: number, running: number, stopped: number }>();
|
||||||
(n) =>
|
const hosts: typeof nodes = [];
|
||||||
n.type === 'host_physical' ||
|
|
||||||
n.type === 'host_vm' ||
|
nodes.forEach(n => {
|
||||||
n.type === 'host_container'
|
if (n.type === 'host_physical' || n.type === 'host_vm' || n.type === 'host_container') {
|
||||||
);
|
hosts.push(n);
|
||||||
|
if (!hostChildrenCounts.has(n.id)) {
|
||||||
|
hostChildrenCounts.set(n.id, { total: 0, running: 0, stopped: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.data.parentId) {
|
||||||
|
const parentCounts = hostChildrenCounts.get(n.data.parentId) || { total: 0, running: 0, stopped: 0 };
|
||||||
|
parentCounts.total++;
|
||||||
|
if (n.data.status === 'running') parentCounts.running++;
|
||||||
|
else if (n.data.status === 'stopped') parentCounts.stopped++;
|
||||||
|
hostChildrenCounts.set(n.data.parentId, parentCounts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Count children (services/containers) per host
|
|
||||||
return hosts
|
return hosts
|
||||||
.map((host) => {
|
.map((host) => {
|
||||||
const children = nodes.filter((n) => n.data.parentId === host.id);
|
const counts = hostChildrenCounts.get(host.id) || { total: 0, running: 0, stopped: 0 };
|
||||||
const running = children.filter((n) => n.data.status === 'running').length;
|
|
||||||
const stopped = children.filter((n) => n.data.status === 'stopped').length;
|
|
||||||
return {
|
return {
|
||||||
name: host.name,
|
name: host.name,
|
||||||
total: children.length,
|
total: counts.total,
|
||||||
running,
|
running: counts.running,
|
||||||
stopped,
|
stopped: counts.stopped,
|
||||||
status: host.data.status,
|
status: host.data.status,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ export default function MetricsBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last updated */}
|
{/* Last updated */}
|
||||||
{lastUpdated && (
|
{lastUpdated && !isNaN(new Date(lastUpdated).getTime()) && (
|
||||||
<div className="flex items-center gap-1.5" title="Last Discovery">
|
<div className="flex items-center gap-1.5" title="Last Discovery">
|
||||||
<Clock size={13} className="text-slate-500" />
|
<Clock size={13} className="text-slate-500" />
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
{lastUpdated.toLocaleTimeString()}
|
{new Date(lastUpdated).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Folder, File, ArrowLeft, X, RefreshCw, Terminal as TerminalIcon } from 'lucide-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';
|
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);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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();
|
const data = await response.json();
|
||||||
if (data.error) {
|
if (!response.ok) {
|
||||||
setError(data.error);
|
setError(data.error || `Failed to fetch files: ${response.status} ${response.statusText}`);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
} else {
|
} else {
|
||||||
setFiles(data.files || []);
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
setFiles([]);
|
||||||
|
} else {
|
||||||
|
setFiles(data.files || []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
|
const msg = err instanceof Error ? err.message : 'Failed to fetch files';
|
||||||
@@ -75,71 +82,72 @@ export default function FileBrowser({ host, initialPath = '/', onClose }: FileBr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4">
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
<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 items-center gap-2">
|
<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">
|
||||||
<span className="text-slate-400">/</span>
|
<div className="flex items-center gap-2 w-full sm:w-auto overflow-x-auto hide-scrollbar">
|
||||||
<span className="text-cyan-400 font-medium">{host}</span>
|
<span className="text-slate-400 font-bold">/</span>
|
||||||
<span className="text-slate-500">:</span>
|
<span className="text-cyan-400 font-bold tracking-tight whitespace-nowrap">{host}</span>
|
||||||
<input
|
<span className="text-slate-500 font-bold">:</span>
|
||||||
type="text"
|
<input
|
||||||
value={currentPath}
|
type="text"
|
||||||
onChange={(e) => setCurrentPath(e.target.value)}
|
value={currentPath}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && navigateTo(e.currentTarget.value)}
|
onChange={(e) => setCurrentPath(e.target.value)}
|
||||||
className="bg-slate-700 text-slate-200 px-2 py-1 rounded text-sm font-mono w-64"
|
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>
|
||||||
<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">
|
<div className="flex-1 overflow-auto p-2 bg-slate-900/40">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<RefreshCw className="w-6 h-6 text-slate-400 animate-spin" />
|
<RefreshCw className="w-6 h-6 text-cyan-500/50 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-red-400 p-4">{error}</div>
|
<div className="text-red-400 p-4 font-mono text-sm">{error}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">
|
||||||
{currentPath !== '/' && (
|
{currentPath !== '/' && (
|
||||||
<button
|
<button
|
||||||
onClick={goUp}
|
onClick={goUp}
|
||||||
className="flex items-center gap-2 w-full px-2 py-1 hover:bg-slate-800 rounded"
|
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" />
|
<ArrowLeft className="w-4 h-4 text-slate-400 group-hover:-translate-x-1 transition-transform" />
|
||||||
<span className="text-slate-400">..</span>
|
<span className="text-slate-400 font-bold">..</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<button
|
<button
|
||||||
key={file.path}
|
key={file.path}
|
||||||
onClick={() => file.type === 'directory' && navigateTo(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 ${
|
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' : ''
|
||||||
file.type !== 'directory' ? 'cursor-default' : ''
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{getFileIcon(file.type)}
|
||||||
{getFileIcon(file.type)}
|
<span className="text-slate-200 flex-1 text-left truncate pr-4">{file.name}</span>
|
||||||
<span className="text-slate-200 flex-1 text-left">{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">{formatSize(file.size)}</span>
|
<span className="text-slate-500 text-xs w-32 text-right hidden sm:block">{file.modified}</span>
|
||||||
<span className="text-slate-600 text-xs w-24">{file.modified}</span>
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, memo } from 'react';
|
import { useCallback, useMemo, memo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
NodeProps,
|
NodeProps,
|
||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
|
useReactFlow
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import dagre from 'dagre';
|
import dagre from 'dagre';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useTopologyStore } from '../../store/topologyStore';
|
import { useTopologyStore } from '../../store/topologyStore';
|
||||||
import { getNodeColor, getStatusColor } from '../../utils/colors';
|
import { getNodeColor, getStatusColor } from '../../utils/colors';
|
||||||
import { Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
|
import { Server, Network, Wifi, Box, Database, Folder, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { NodeType, ServiceCategory } from '../../types';
|
import { NodeType, ServiceCategory } from '../../types';
|
||||||
|
|
||||||
const nodeIcons: Record<NodeType, React.ReactNode> = {
|
const nodeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
@@ -35,60 +36,84 @@ const nodeIcons: Record<NodeType, React.ReactNode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
|
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
|
||||||
const highlightPath = useTopologyStore((s) => s.highlightPath);
|
const { highlightPath, collapsedNodes, toggleNodeCollapse } = useTopologyStore(useShallow(s => ({
|
||||||
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
|
highlightPath: s.highlightPath,
|
||||||
|
collapsedNodes: s.collapsedNodes,
|
||||||
|
toggleNodeCollapse: s.toggleNodeCollapse
|
||||||
|
})));
|
||||||
|
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string; hasChildren?: boolean };
|
||||||
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
|
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
|
||||||
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
const statusColor = getStatusColor(nodeData.status || 'unknown');
|
||||||
const isHighlighted = highlightPath.includes(id);
|
const isHighlighted = highlightPath.includes(id);
|
||||||
const isDimmed = highlightPath.length > 0 && !isHighlighted;
|
const isDimmed = highlightPath.length > 0 && !isHighlighted;
|
||||||
|
const isCollapsed = collapsedNodes.includes(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative group">
|
||||||
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
|
<div
|
||||||
|
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
|
||||||
? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
|
? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
|
||||||
: isHighlighted
|
: isHighlighted
|
||||||
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
|
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
|
||||||
: 'border-slate-600 hover:border-slate-500 hover:shadow-md hover:shadow-slate-700/30 hover:scale-[1.01]'
|
: 'border-slate-600 hover:border-slate-500 hover:shadow-md hover:shadow-slate-700/30 hover:scale-[1.01]'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
|
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
opacity: isDimmed ? 0.4 : 1
|
opacity: isDimmed ? 0.4 : 1
|
||||||
}}
|
}}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
|
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
style={{ backgroundColor: `${nodeColor}20` }}
|
style={{ backgroundColor: `${nodeColor}20` }}
|
||||||
>
|
>
|
||||||
<div style={{ color: nodeColor }} aria-hidden="true">
|
<div style={{ color: nodeColor }} aria-hidden="true">
|
||||||
{nodeIcons[nodeData.type || 'service']}
|
{nodeIcons[nodeData.type || 'service']}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-white truncate">
|
|
||||||
{nodeData.label}
|
|
||||||
</div>
|
|
||||||
{nodeData.ip && (
|
|
||||||
<div className="text-xs text-slate-500 font-mono">
|
|
||||||
{nodeData.ip}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-white truncate">
|
||||||
|
{nodeData.label}
|
||||||
|
</div>
|
||||||
|
{nodeData.ip && (
|
||||||
|
<div className="text-xs text-slate-500 font-mono">
|
||||||
|
{nodeData.ip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
aria-label={`Status: ${nodeData.status || 'unknown'}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
|
||||||
className="w-2.5 h-2.5 rounded-full"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
aria-label={`Status: ${nodeData.status || 'unknown'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
|
{nodeData.hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleNodeCollapse(id);
|
||||||
|
}}
|
||||||
|
className="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-800 border-2 border-slate-600 rounded-full flex items-center justify-center text-slate-400 hover:text-white hover:border-slate-500 hover:bg-slate-700 transition-colors z-10"
|
||||||
|
title={isCollapsed ? "Expand children" : "Collapse children"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -132,28 +157,50 @@ function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB
|
|||||||
return { nodes: layoutedNodes, edges };
|
return { nodes: layoutedNodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useFilteredNodes } from '../../store/topologyStore';
|
||||||
|
|
||||||
export default function TopologyGraph() {
|
export default function TopologyGraph() {
|
||||||
const {
|
const {
|
||||||
edges: storeEdges,
|
edges: storeEdges,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
getFilteredNodes,
|
|
||||||
orientation,
|
orientation,
|
||||||
viewMode,
|
viewMode,
|
||||||
highlightPath
|
highlightPath,
|
||||||
|
collapsedNodes
|
||||||
} = useTopologyStore(useShallow((s) => ({
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
edges: s.edges,
|
edges: s.edges,
|
||||||
selectedNodeId: s.selectedNodeId,
|
selectedNodeId: s.selectedNodeId,
|
||||||
setSelectedNode: s.setSelectedNode,
|
setSelectedNode: s.setSelectedNode,
|
||||||
getFilteredNodes: s.getFilteredNodes,
|
|
||||||
orientation: s.orientation,
|
orientation: s.orientation,
|
||||||
viewMode: s.viewMode,
|
viewMode: s.viewMode,
|
||||||
highlightPath: s.highlightPath,
|
highlightPath: s.highlightPath,
|
||||||
|
collapsedNodes: s.collapsedNodes
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
const filteredNodesList = useFilteredNodes();
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
|
// Watch for collapse/expand events to recenter the graph nicely
|
||||||
|
const collapsedNodesHash = collapsedNodes.join(',');
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for dagre to compute the new layout locations before centering
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// Find the first node available if none is selected
|
||||||
|
const currentNodes = useTopologyStore.getState().nodes;
|
||||||
|
const targetNodeId = selectedNodeId || currentNodes[0]?.id;
|
||||||
|
|
||||||
|
if (targetNodeId) {
|
||||||
|
fitView({ nodes: [{ id: targetNodeId }], duration: 800, maxZoom: 1 });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [collapsedNodesHash, fitView, selectedNodeId]);
|
||||||
|
|
||||||
// Memoize the layout computation instead of useState + useEffect
|
// Memoize the layout computation instead of useState + useEffect
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const filteredNodes = getFilteredNodes();
|
const filteredNodes = filteredNodesList;
|
||||||
|
|
||||||
if (filteredNodes.length === 0) {
|
if (filteredNodes.length === 0) {
|
||||||
return { nodes: [] as Node[], edges: [] as Edge[] };
|
return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||||
@@ -171,6 +218,7 @@ export default function TopologyGraph() {
|
|||||||
status: node.data.status,
|
status: node.data.status,
|
||||||
category: node.data.category,
|
category: node.data.category,
|
||||||
ip: node.data.ip,
|
ip: node.data.ip,
|
||||||
|
hasChildren: collapsedNodes.includes(node.id) || storeEdges.some(e => e.source === node.id && nodeIds.has(e.target))
|
||||||
},
|
},
|
||||||
selected: node.id === selectedNodeId,
|
selected: node.id === selectedNodeId,
|
||||||
}));
|
}));
|
||||||
@@ -209,7 +257,7 @@ export default function TopologyGraph() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return getLayoutedElements(newNodes, newEdges, orientation);
|
return getLayoutedElements(newNodes, newEdges, orientation);
|
||||||
}, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
|
}, [filteredNodesList, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||||
setSelectedNode(node.id);
|
setSelectedNode(node.id);
|
||||||
@@ -229,7 +277,7 @@ export default function TopologyGraph() {
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
minZoom={0.1}
|
minZoom={0.4}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
isLoading: storeLoading,
|
isLoading: storeLoading,
|
||||||
pollInterval,
|
pollInterval,
|
||||||
setPollInterval
|
setPollInterval,
|
||||||
|
toggleSettings
|
||||||
} = useTopologyStore(useShallow((s) => ({
|
} = useTopologyStore(useShallow((s) => ({
|
||||||
viewMode: s.viewMode,
|
viewMode: s.viewMode,
|
||||||
setViewMode: s.setViewMode,
|
setViewMode: s.setViewMode,
|
||||||
@@ -73,6 +74,7 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
isLoading: s.isLoading,
|
isLoading: s.isLoading,
|
||||||
pollInterval: s.pollInterval,
|
pollInterval: s.pollInterval,
|
||||||
setPollInterval: s.setPollInterval,
|
setPollInterval: s.setPollInterval,
|
||||||
|
toggleSettings: s.toggleSettings,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const loading = externalLoading ?? storeLoading;
|
const loading = externalLoading ?? storeLoading;
|
||||||
@@ -84,55 +86,55 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
}, [onRefresh]);
|
}, [onRefresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 bg-slate-800 border-b border-slate-700 px-4 flex items-center justify-between">
|
<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 items-center gap-4">
|
<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-2">
|
<div className="flex items-center gap-3 shrink-0 mr-2">
|
||||||
<div className="w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center">
|
<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" />
|
<Network className="w-5 h-5 text-white" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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 }) => (
|
{viewModes.map(({ mode, label, icon }) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
aria-pressed={viewMode === 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
|
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-400 border border-indigo-500/50'
|
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.2)]'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
<span className="hidden md:inline">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<label htmlFor="orientation-select" className="visually-hidden">Graph orientation</label>
|
||||||
<select
|
<select
|
||||||
id="orientation-select"
|
id="orientation-select"
|
||||||
value={orientation}
|
value={orientation}
|
||||||
onChange={(e) => setOrientation(e.target.value as 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 }) => (
|
{orientations.map(({ value, label }) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value} className="bg-slate-800">
|
||||||
{label}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 }) => {
|
{nodeTypeFilters.map(({ type, icon }) => {
|
||||||
const isActive = typeFilters.includes(type);
|
const isActive = typeFilters.includes(type);
|
||||||
const color = getNodeColor(type);
|
const color = getNodeColor(type);
|
||||||
@@ -142,14 +144,15 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
onClick={() => toggleTypeFilter(type)}
|
onClick={() => toggleTypeFilter(type)}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
|
aria-label={`Filter ${type.replace(/_/g, ' ')}`}
|
||||||
className={`p-2 rounded-md transition-colors ${isActive
|
className={`p-1.5 md:p-2 rounded-md transition-all duration-200 transform hover:scale-105 ${isActive
|
||||||
? 'border'
|
? 'border border-transparent'
|
||||||
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700'
|
: 'text-slate-500 hover:text-slate-300 hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
style={isActive ? {
|
style={isActive ? {
|
||||||
backgroundColor: `${color}20`,
|
backgroundColor: `${color}20`,
|
||||||
borderColor: `${color}50`,
|
borderColor: `${color}40`,
|
||||||
color: color
|
color: color,
|
||||||
|
boxShadow: `0 0 10px ${color}20`
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
@@ -158,44 +161,44 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
<label htmlFor="status-filter" className="visually-hidden">Status filter</label>
|
||||||
<select
|
<select
|
||||||
id="status-filter"
|
id="status-filter"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as 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="all" className="bg-slate-800">All Status</option>
|
||||||
<option value="running">Running</option>
|
<option value="running" className="bg-slate-800">Running</option>
|
||||||
<option value="stopped">Stopped</option>
|
<option value="stopped" className="bg-slate-800">Stopped</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
|
||||||
<Settings className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
<Settings className="w-4 h-4 text-slate-500" aria-hidden="true" />
|
||||||
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
|
<label htmlFor="poll-interval" className="visually-hidden">Poll interval</label>
|
||||||
<select
|
<select
|
||||||
id="poll-interval"
|
id="poll-interval"
|
||||||
value={pollInterval}
|
value={pollInterval}
|
||||||
onChange={(e) => setPollInterval(parseInt(e.target.value, 10))}
|
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={10000} className="bg-slate-800">10 seconds</option>
|
||||||
<option value={30000}>30 seconds</option>
|
<option value={30000} className="bg-slate-800">30 seconds</option>
|
||||||
<option value={60000}>1 minute</option>
|
<option value={60000} className="bg-slate-800">1 minute</option>
|
||||||
<option value={300000}>5 minutes</option>
|
<option value={300000} className="bg-slate-800">5 minutes</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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-400" aria-hidden="true" />
|
<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>
|
<label htmlFor="node-search" className="visually-hidden">Search nodes</label>
|
||||||
<input
|
<input
|
||||||
id="node-search"
|
id="node-search"
|
||||||
@@ -203,7 +206,7 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
placeholder="Search nodes..."
|
placeholder="Search nodes..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -211,32 +214,42 @@ export default function Header({ onRefresh, isLoading: externalLoading }: Header
|
|||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={loading ? 'Loading data' : 'Refresh data'}
|
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" />
|
<Loader2 className={`w-4 h-4 ${loading ? 'animate-spin text-indigo-400' : ''}`} aria-hidden="true" />
|
||||||
{loading ? 'Loading...' : 'Refresh'}
|
<span className="hidden lg:inline">{loading ? 'Loading...' : 'Refresh'}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={toggleLeftPanel}
|
onClick={toggleLeftPanel}
|
||||||
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
|
aria-label={leftPanelOpen ? 'Hide child nodes panel' : 'Show child nodes panel'}
|
||||||
aria-pressed={leftPanelOpen}
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleRightPanel}
|
onClick={toggleRightPanel}
|
||||||
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
|
aria-label={rightPanelOpen ? 'Hide details panel' : 'Show details panel'}
|
||||||
aria-pressed={rightPanelOpen}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
import { useTopologyStore } from '../store/topologyStore';
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
import { getNodeColor } from '../utils/colors';
|
import { getNodeColor } from '../utils/colors';
|
||||||
@@ -39,36 +39,48 @@ const typeLabels: Record<NodeType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LeftPanel() {
|
export default function LeftPanel() {
|
||||||
const { nodes, selectedNodeId, setSelectedNode } = useTopologyStore(
|
const { nodes, selectedNodeId, setSelectedNode, toggleLeftPanel } = useTopologyStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
nodes: s.nodes,
|
nodes: s.nodes,
|
||||||
selectedNodeId: s.selectedNodeId,
|
selectedNodeId: s.selectedNodeId,
|
||||||
setSelectedNode: s.setSelectedNode,
|
setSelectedNode: s.setSelectedNode,
|
||||||
|
toggleLeftPanel: s.toggleLeftPanel,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId);
|
|
||||||
|
|
||||||
const groupedChildren = useMemo(() => {
|
const { childNodes, groupedChildren } = useMemo(() => {
|
||||||
return childNodes.reduce((acc, node) => {
|
const children = nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
|
const grouped = children.reduce((acc, node) => {
|
||||||
if (!acc[node.type]) acc[node.type] = [];
|
if (!acc[node.type]) acc[node.type] = [];
|
||||||
acc[node.type].push(node);
|
acc[node.type].push(node);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<NodeType, TopologyNode[]>);
|
}, {} as Record<NodeType, TopologyNode[]>);
|
||||||
}, [childNodes]);
|
|
||||||
|
return { childNodes: children, groupedChildren: grouped };
|
||||||
|
}, [nodes, selectedNodeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel">
|
<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-12 px-4 flex items-center border-b border-slate-700">
|
<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 uppercase tracking-wide">
|
<div className="flex items-center">
|
||||||
{selectedNode ? 'Child Nodes' : 'Select a Node'}
|
<h2 className="text-sm font-semibold text-white tracking-wide">
|
||||||
</h2>
|
{selectedNode ? 'Child Nodes' : 'Select a Node'}
|
||||||
{childNodes.length > 0 && (
|
</h2>
|
||||||
<span className="ml-2 px-2 py-0.5 bg-slate-700 text-slate-300 text-xs rounded-full">
|
{childNodes.length > 0 && (
|
||||||
{childNodes.length}
|
<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">
|
||||||
</span>
|
{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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
@@ -91,9 +103,9 @@ export default function LeftPanel() {
|
|||||||
<button
|
<button
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onClick={() => setSelectedNode(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
|
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'
|
? 'bg-indigo-500/20 text-indigo-300 shadow-[0_0_10px_rgba(99,102,241,0.15)]'
|
||||||
: 'text-slate-300 hover:bg-slate-700'
|
: 'text-slate-300 hover:bg-white/5 hover:translate-x-1'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -119,7 +131,7 @@ export default function LeftPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Host metrics chart (data-visualizer skill) */}
|
{/* 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 />
|
<HostChart />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
|
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder, Focus } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useReactFlow } from '@xyflow/react';
|
||||||
import { useTopologyStore } from '../store/topologyStore';
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
|
||||||
import { TopologyNode } from '../types';
|
import { TopologyNode } from '../types';
|
||||||
@@ -17,19 +18,28 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function RightPanel() {
|
export default function RightPanel() {
|
||||||
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore(
|
const { nodes, selectedNodeId, setSelectedNode, openTerminal, getChildNodes } = useTopologyStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
nodes: s.nodes,
|
nodes: s.nodes,
|
||||||
selectedNodeId: s.selectedNodeId,
|
selectedNodeId: s.selectedNodeId,
|
||||||
setSelectedNode: s.setSelectedNode,
|
setSelectedNode: s.setSelectedNode,
|
||||||
openTerminal: s.openTerminal,
|
openTerminal: s.openTerminal,
|
||||||
|
getChildNodes: s.getChildNodes
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('details');
|
const [activeTab, setActiveTab] = useState<TabId>('details');
|
||||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||||
|
|
||||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
|
||||||
|
// Fallback to details tab if usage is active but node isn't a service
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'usage' && selectedNode && selectedNode.type !== 'service') {
|
||||||
|
setActiveTab('details');
|
||||||
|
}
|
||||||
|
}, [selectedNode, activeTab]);
|
||||||
|
|
||||||
const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
|
const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
|
||||||
let newIndex = currentIndex;
|
let newIndex = currentIndex;
|
||||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
@@ -53,10 +63,18 @@ export default function RightPanel() {
|
|||||||
tabEl?.focus();
|
tabEl?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
const children = getChildNodes();
|
||||||
|
const nodesToFocus = [{ id: selectedNodeId }, ...children.map(c => ({ id: c.id }))];
|
||||||
|
|
||||||
|
fitView({ nodes: nodesToFocus, duration: 800, padding: 0.2, maxZoom: 1.2 });
|
||||||
|
}, [selectedNodeId, getChildNodes, fitView]);
|
||||||
|
|
||||||
if (!selectedNode) {
|
if (!selectedNode) {
|
||||||
return (
|
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">
|
<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-500 text-sm text-center">
|
<div className="text-slate-400 text-sm text-center">
|
||||||
Select a node to view its details
|
Select a node to view its details
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -68,12 +86,20 @@ export default function RightPanel() {
|
|||||||
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
|
const isHost = selectedNode.type.startsWith('host_') || selectedNode.type.startsWith('vm_');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col" aria-label="Node details panel">
|
<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-12 px-4 flex items-center justify-between border-b border-slate-700">
|
<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 truncate">{selectedNode.name}</h2>
|
<h2 className="text-sm font-semibold text-white tracking-wide truncate pr-2">{selectedNode.name}</h2>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isHost && selectedNodeId && (
|
{isHost && selectedNodeId && (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleFocus}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
aria-label="Focus node and children"
|
||||||
|
title="Focus node and children"
|
||||||
|
>
|
||||||
|
<Focus className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFileBrowserOpen(true)}
|
onClick={() => setFileBrowserOpen(true)}
|
||||||
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
className="p-1 hover:bg-slate-700 rounded transition-colors"
|
||||||
@@ -100,7 +126,7 @@ export default function RightPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</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) => (
|
{tabs.map((tab, index) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -111,9 +137,9 @@ export default function RightPanel() {
|
|||||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||||
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-colors ${activeTab === tab.id
|
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-all duration-200 ${activeTab === tab.id
|
||||||
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
|
? 'text-indigo-300 bg-indigo-500/20 shadow-[inset_0_-2px_0_rgba(99,102,241,1)]'
|
||||||
: 'text-slate-400 hover:text-white'
|
: 'text-slate-400 hover:text-white hover:bg-white/5 hover:-translate-y-0.5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{tab.icon}</span>
|
<span aria-hidden="true">{tab.icon}</span>
|
||||||
@@ -184,7 +210,7 @@ function DetailsTab({ node, nodeColor, statusColor }: { node: TopologyNode; node
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1">Metadata</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)}
|
{JSON.stringify(node.data.metadata, null, 2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +233,7 @@ function ConfigTab({ node }: { node: TopologyNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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}
|
{node.data.config}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +252,7 @@ function FilesTab({ node }: { node: TopologyNode }) {
|
|||||||
{files.map((file: string, idx: number) => (
|
{files.map((file: string, idx: number) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
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" />
|
<FolderOpen className="w-4 h-4 text-slate-400" aria-hidden="true" />
|
||||||
<span className="font-mono text-xs text-slate-300 truncate">{file}</span>
|
<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 { FitAddon } from '@xterm/addon-fit';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { useTopologyStore } from '../store/topologyStore';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
|
const response = await fetch(`${API_BASE_URL}/api/terminal/exec`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({ host, command: cmd }),
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${useTopologyStore.getState().token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
host,
|
||||||
|
command: cmd,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.output) {
|
if (data.output) {
|
||||||
@@ -106,22 +113,26 @@ export default function TerminalPanel({ host, onClose }: TerminalProps) {
|
|||||||
}, [host, currentPath]);
|
}, [host, currentPath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-slate-900/95 flex flex-col">
|
<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="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
<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 gap-2">
|
<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">
|
||||||
<span className="text-cyan-400 font-mono">$</span>
|
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbar">
|
||||||
<span className="text-slate-200 font-medium">{host}</span>
|
<span className="text-cyan-400 font-mono font-bold">$</span>
|
||||||
<span className="text-slate-500">:</span>
|
<span className="text-slate-200 font-bold tracking-tight whitespace-nowrap">{host}</span>
|
||||||
<span className="text-slate-400 font-mono">{currentPath}</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>
|
</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>
|
||||||
<div ref={terminalRef} className="flex-1 p-2" />
|
|
||||||
</div>
|
</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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -29,6 +31,37 @@ body {
|
|||||||
height: 100vh;
|
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 (a11y) ────────────────────────────────────── */
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -94,39 +94,6 @@ describe('topologyStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
|
||||||
* Filtering
|
|
||||||
* ------------------------------------------------------------- */
|
|
||||||
|
|
||||||
describe('getFilteredNodes', () => {
|
|
||||||
test('returns all nodes when no filters active', () => {
|
|
||||||
useTopologyStore.getState().setNodes(mockNodes);
|
|
||||||
const filtered = useTopologyStore.getState().getFilteredNodes();
|
|
||||||
expect(filtered).toHaveLength(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters by search query', () => {
|
|
||||||
useTopologyStore.getState().setNodes(mockNodes);
|
|
||||||
useTopologyStore.getState().setSearchQuery('traefik');
|
|
||||||
const filtered = useTopologyStore.getState().getFilteredNodes();
|
|
||||||
expect(filtered.some(n => n.name === 'Traefik')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters by status', () => {
|
|
||||||
useTopologyStore.getState().setNodes(mockNodes);
|
|
||||||
useTopologyStore.getState().setStatusFilter('stopped');
|
|
||||||
const filtered = useTopologyStore.getState().getFilteredNodes();
|
|
||||||
expect(filtered.every(n => n.data.status === 'stopped')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters by type', () => {
|
|
||||||
useTopologyStore.getState().setNodes(mockNodes);
|
|
||||||
useTopologyStore.getState().toggleTypeFilter('service');
|
|
||||||
const filtered = useTopologyStore.getState().getFilteredNodes();
|
|
||||||
expect(filtered.every(n => n.type === 'service')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
* Type filter toggle
|
* Type filter toggle
|
||||||
* ------------------------------------------------------------- */
|
* ------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ interface TopologyState {
|
|||||||
highlightPath: string[];
|
highlightPath: string[];
|
||||||
connectionStatus: 'ws' | 'polling' | 'disconnected';
|
connectionStatus: 'ws' | 'polling' | 'disconnected';
|
||||||
staleWarningDismissed: boolean;
|
staleWarningDismissed: boolean;
|
||||||
|
collapsedNodes: string[];
|
||||||
|
|
||||||
|
token: string | null;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
logout: () => void;
|
||||||
|
|
||||||
|
settingsOpen: boolean;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
|
||||||
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
|
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
|
||||||
setNodes: (nodes: TopologyNode[]) => void;
|
setNodes: (nodes: TopologyNode[]) => void;
|
||||||
@@ -65,10 +73,10 @@ interface TopologyState {
|
|||||||
toggleCommandPalette: () => void;
|
toggleCommandPalette: () => void;
|
||||||
setHighlightPath: (ids: string[]) => void;
|
setHighlightPath: (ids: string[]) => void;
|
||||||
dismissStaleWarning: () => void;
|
dismissStaleWarning: () => void;
|
||||||
|
toggleNodeCollapse: (nodeId: string) => void;
|
||||||
|
|
||||||
getSelectedNode: () => TopologyNode | null;
|
getSelectedNode: () => TopologyNode | null;
|
||||||
getChildNodes: () => TopologyNode[];
|
getChildNodes: () => TopologyNode[];
|
||||||
getFilteredNodes: () => TopologyNode[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTopologyStore = create<TopologyState>()(
|
export const useTopologyStore = create<TopologyState>()(
|
||||||
@@ -83,8 +91,8 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
typeFilters: ALL_NODE_TYPES,
|
typeFilters: ALL_NODE_TYPES,
|
||||||
statusFilter: 'all',
|
statusFilter: 'all',
|
||||||
leftPanelOpen: true,
|
leftPanelOpen: typeof window !== 'undefined' ? window.innerWidth >= 768 : true,
|
||||||
rightPanelOpen: true,
|
rightPanelOpen: false,
|
||||||
lastUpdated: null,
|
lastUpdated: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
pollInterval: 30000,
|
pollInterval: 30000,
|
||||||
@@ -99,6 +107,13 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
highlightPath: [],
|
highlightPath: [],
|
||||||
connectionStatus: 'polling',
|
connectionStatus: 'polling',
|
||||||
staleWarningDismissed: false,
|
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 }),
|
setNodes: (nodes) => set({ nodes }),
|
||||||
setEdges: (edges) => set({ edges }),
|
setEdges: (edges) => set({ edges }),
|
||||||
@@ -114,7 +129,13 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
path.push(currentNode.data.parentId);
|
path.push(currentNode.data.parentId);
|
||||||
currentNode = state.nodes.find(n => n.id === 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 }),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
setOrientation: (orientation) => set({ orientation }),
|
setOrientation: (orientation) => set({ orientation }),
|
||||||
@@ -128,8 +149,20 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setStatusFilter: (filter) => set({ statusFilter: filter }),
|
setStatusFilter: (filter) => set({ statusFilter: filter }),
|
||||||
toggleLeftPanel: () => set((state) => ({ leftPanelOpen: !state.leftPanelOpen })),
|
toggleLeftPanel: () => set((state) => {
|
||||||
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
|
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 }),
|
setLastUpdated: (date) => set({ lastUpdated: date }),
|
||||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
setPollInterval: (interval) => set({ pollInterval: interval }),
|
setPollInterval: (interval) => set({ pollInterval: interval }),
|
||||||
@@ -145,6 +178,36 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
|
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
|
||||||
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
|
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
|
||||||
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
|
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
|
||||||
|
toggleNodeCollapse: (nodeId) => set((state) => {
|
||||||
|
const isCollapsing = !state.collapsedNodes.includes(nodeId);
|
||||||
|
|
||||||
|
if (isCollapsing) {
|
||||||
|
// Standard collapse behavior: add the node to the list.
|
||||||
|
return {
|
||||||
|
collapsedNodes: [...state.collapsedNodes, nodeId]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Expansion behavior (Progressive Disclosure):
|
||||||
|
// Remove the parent node from the collapsed list so its children are visible.
|
||||||
|
// But immediately collapse those newly revealed children so they don't burst open their own entire subtrees.
|
||||||
|
const immediateChildrenIds = state.nodes
|
||||||
|
.filter(n => n.data.parentId === nodeId)
|
||||||
|
.map(n => n.id);
|
||||||
|
|
||||||
|
const newCollapsedNodes = state.collapsedNodes.filter(id => id !== nodeId);
|
||||||
|
|
||||||
|
// Add the children to the collapsed list if they aren't already there
|
||||||
|
immediateChildrenIds.forEach(childId => {
|
||||||
|
if (!newCollapsedNodes.includes(childId)) {
|
||||||
|
newCollapsedNodes.push(childId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
collapsedNodes: newCollapsedNodes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
getSelectedNode: () => {
|
getSelectedNode: () => {
|
||||||
const { nodes, selectedNodeId } = get();
|
const { nodes, selectedNodeId } = get();
|
||||||
@@ -157,62 +220,6 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
if (!selectedNode) return [];
|
if (!selectedNode) return [];
|
||||||
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
},
|
|
||||||
|
|
||||||
getFilteredNodes: () => {
|
|
||||||
const { nodes, viewMode, searchQuery, typeFilters, statusFilter } = get();
|
|
||||||
|
|
||||||
let filtered = nodes;
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter(n =>
|
|
||||||
n.name.toLowerCase().includes(query) ||
|
|
||||||
n.data.ip?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
filtered = filtered.filter(n => n.data.status === statusFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
|
|
||||||
filtered = filtered.filter(n => typeFilters.includes(n.type));
|
|
||||||
}
|
|
||||||
|
|
||||||
let allowedTypes: NodeType[] = [];
|
|
||||||
if (viewMode === 'network') {
|
|
||||||
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
|
||||||
} else if (viewMode === 'host') {
|
|
||||||
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
|
||||||
} else if (viewMode === 'service') {
|
|
||||||
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
|
|
||||||
} else if (viewMode === 'filesystem') {
|
|
||||||
allowedTypes = ['volume', 'mount', 'path'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowedTypes.length > 0) {
|
|
||||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
||||||
const includeSet = new Set<string>();
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
if (allowedTypes.includes(node.type)) {
|
|
||||||
includeSet.add(node.id);
|
|
||||||
|
|
||||||
let current: TopologyNode | undefined = node;
|
|
||||||
while (current?.data?.parentId) {
|
|
||||||
const parentId = current.data.parentId;
|
|
||||||
includeSet.add(parentId);
|
|
||||||
current = nodeMap.get(parentId);
|
|
||||||
if (!current) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered = filtered.filter(n => includeSet.has(n.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -225,9 +232,14 @@ export const useTopologyStore = create<TopologyState>()(
|
|||||||
searchQuery: state.searchQuery,
|
searchQuery: state.searchQuery,
|
||||||
typeFilters: state.typeFilters,
|
typeFilters: state.typeFilters,
|
||||||
statusFilter: state.statusFilter,
|
statusFilter: state.statusFilter,
|
||||||
leftPanelOpen: state.leftPanelOpen,
|
pollInterval: state.pollInterval,
|
||||||
rightPanelOpen: state.rightPanelOpen,
|
collapsedNodes: state.collapsedNodes,
|
||||||
pollInterval: state.pollInterval
|
nodes: state.nodes,
|
||||||
|
edges: state.edges,
|
||||||
|
hosts: state.hosts,
|
||||||
|
networkInfo: state.networkInfo,
|
||||||
|
lastUpdated: state.lastUpdated,
|
||||||
|
token: state.token
|
||||||
})
|
})
|
||||||
}), { name: 'TopologyStore' }));
|
}), { name: 'TopologyStore' }));
|
||||||
|
|
||||||
@@ -243,4 +255,116 @@ export const useChildNodes = () => useTopologyStore((s) => {
|
|||||||
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
return nodes.filter(n => n.data.parentId === selectedNodeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useFilteredNodes = () => useTopologyStore((s) => s.getFilteredNodes());
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useFilteredNodes = () => {
|
||||||
|
const nodes = useTopologyStore((s) => s.nodes);
|
||||||
|
const viewMode = useTopologyStore((s) => s.viewMode);
|
||||||
|
const searchQuery = useTopologyStore((s) => s.searchQuery);
|
||||||
|
const typeFilters = useTopologyStore((s) => s.typeFilters);
|
||||||
|
const statusFilter = useTopologyStore((s) => s.statusFilter);
|
||||||
|
const collapsedNodes = useTopologyStore((s) => s.collapsedNodes);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
let filtered = nodes;
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(n =>
|
||||||
|
n.name.toLowerCase().includes(query) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(n => n.data.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
|
||||||
|
filtered = filtered.filter(n => typeFilters.includes(n.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowedTypes: NodeType[] = [];
|
||||||
|
if (viewMode === 'network') {
|
||||||
|
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
||||||
|
} else if (viewMode === 'host') {
|
||||||
|
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
|
||||||
|
} else if (viewMode === 'service') {
|
||||||
|
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
|
||||||
|
} else if (viewMode === 'filesystem') {
|
||||||
|
allowedTypes = ['volume', 'mount', 'path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedTypes.length > 0) {
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
const includeSet = new Set<string>();
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (allowedTypes.includes(node.type)) {
|
||||||
|
includeSet.add(node.id);
|
||||||
|
|
||||||
|
let current: TopologyNode | undefined = node;
|
||||||
|
while (current?.data?.parentId) {
|
||||||
|
const parentId = current.data.parentId;
|
||||||
|
includeSet.add(parentId);
|
||||||
|
current = nodeMap.get(parentId);
|
||||||
|
if (!current) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered = filtered.filter(n => includeSet.has(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out children of collapsed nodes
|
||||||
|
if (collapsedNodes.length > 0) {
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
const collapsedSet = new Set(collapsedNodes);
|
||||||
|
const visibilityCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
filtered = filtered.filter(node => {
|
||||||
|
// Start from the current node's parent
|
||||||
|
let currentId: string | undefined = node.data.parentId;
|
||||||
|
|
||||||
|
// Check if we already know the answer
|
||||||
|
if (currentId && visibilityCache.has(currentId)) {
|
||||||
|
return visibilityCache.get(currentId) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse up the tree to see if any ancestor is collapsed
|
||||||
|
let isHidden = false;
|
||||||
|
const traversalPath: string[] = [];
|
||||||
|
|
||||||
|
while (currentId) {
|
||||||
|
traversalPath.push(currentId);
|
||||||
|
|
||||||
|
// If this ancestor is collapsed, the child is hidden
|
||||||
|
if (collapsedSet.has(currentId)) {
|
||||||
|
isHidden = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we hit an ancestor in the cache, use its result
|
||||||
|
if (visibilityCache.has(currentId)) {
|
||||||
|
isHidden = visibilityCache.get(currentId) === false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move up to the next parent
|
||||||
|
currentId = nodeMap.get(currentId)?.data?.parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result for this specific parent (and all parents in its path)
|
||||||
|
let parentToCache = node.data.parentId;
|
||||||
|
if (parentToCache) {
|
||||||
|
// If it's hidden, the immediate parent must be marked hidden so sibling nodes immediately abort
|
||||||
|
visibilityCache.set(parentToCache, !isHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isHidden; // Show node if not hidden
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [nodes, viewMode, searchQuery, typeFilters, statusFilter, collapsedNodes]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface Host {
|
|||||||
vms?: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }>;
|
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 {
|
export interface VLAN {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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