feat: non-blocking bulk-add, CORS hardening, credential preset access control (#31)

* devices: hide discovered neighbors that are already managed

Discovered Devices showed every unresolved MikroTik neighbor, even when
the neighbor was obviously the same device a managed node is reporting
through a different link / MAC / alias. Since we already know every
managed device interface MAC, primary IP, and system identity, we can
flag those rows.

- /api/devices/discovered now annotates each row with duplicate_of_device_id
  and duplicate_of_device_name when the neighbor matches a managed device
  by interface MAC, primary IP, or system identity.
- Add a Hide duplicates of managed devices toggle (default on) above the
  table, with a count of how many matches were found. When enabled, the
  list hides matched rows; when disabled, matches stay visible but are
  dimmed and tagged Already managed so the origin is clear.
- Suppress the Add to Manager action for rows that are already a
  managed device, since adding them would create a second record for the
  same hardware.

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* devices: safer duplicate handling, try-all safeguards, and timezone/firmware fixes

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* Non-blocking follow-ups: parsePort util, tests, bulk-add queue, preset rate limit

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

* fix(security): tighten CORS, Redis rate limits, and preset access controls

Require explicit CORS origins in production, align Socket.IO CORS with HTTP,
default Docker Compose to local HTTPS origins, and remove the in-memory
mutation limiter in favor of Redis. Also add operator-use flags for credential
presets in the UI and harden bulk-add job access and client error messages.

Co-authored-by: Denis <denis-ev@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Denis
2026-04-21 19:54:35 -07:00
committed by GitHub
co-authored by Cursor Agent
parent 87a6d9161a
commit a12f52ebc7
28 changed files with 1912 additions and 252 deletions
+4
View File
@@ -3,6 +3,10 @@ HTTP_PORT=80
HTTPS_PORT=443 HTTPS_PORT=443
TZ=UTC TZ=UTC
# Browser origins allowed to call the API (comma-separated). Required in production.
# Docker Compose defaults this for local https://localhost access; override for your domain.
# CORS_ORIGIN=https://manager.example.com,http://localhost:3000
# Database # Database
DB_PASSWORD=mikrotik_secure_pw DB_PASSWORD=mikrotik_secure_pw
+286
View File
@@ -39,10 +39,12 @@
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/ssh2": "^1.11.17", "@types/ssh2": "^1.11.17",
"@types/supertest": "^7.2.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-security": "^4.0.0", "eslint-plugin-security": "^4.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
@@ -1551,6 +1553,29 @@
"win32" "win32"
] ]
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.10", "version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -1696,6 +1721,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -1811,6 +1843,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -1947,6 +1986,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/superagent": {
"version": "8.1.9",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz",
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2454,6 +2517,13 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asn1": { "node_modules/asn1": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -2463,6 +2533,13 @@
"safer-buffer": "~2.1.0" "safer-buffer": "~2.1.0"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3063,6 +3140,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compressible": { "node_modules/compressible": {
"version": "2.0.18", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -3143,6 +3243,13 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -3271,6 +3378,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -3319,6 +3436,17 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -3509,6 +3637,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3939,6 +4083,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fb-watchman": { "node_modules/fb-watchman": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -4031,6 +4182,41 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4302,6 +4488,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -7203,6 +7405,90 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/superagent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supertest/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+2
View File
@@ -44,10 +44,12 @@
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/ssh2": "^1.11.17", "@types/ssh2": "^1.11.17",
"@types/supertest": "^7.2.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-security": "^4.0.0", "eslint-plugin-security": "^4.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
+1
View File
@@ -287,6 +287,7 @@ CREATE TABLE IF NOT EXISTS credential_presets (
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
ALTER TABLE credential_presets ADD COLUMN IF NOT EXISTS allow_operator_use BOOLEAN NOT NULL DEFAULT TRUE;
-- Wireless security profiles (WPA/WPA2/WPA3 config) -- Wireless security profiles (WPA/WPA2/WPA3 config)
CREATE TABLE IF NOT EXISTS wireless_security_profiles ( CREATE TABLE IF NOT EXISTS wireless_security_profiles (
+11 -2
View File
@@ -18,8 +18,14 @@ import { redis } from './config/redis';
import { runMigrations } from './db/migrate'; import { runMigrations } from './db/migrate';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
import { PollerService } from './services/PollerService'; import { PollerService } from './services/PollerService';
import {
setBulkAddPollerService,
startBulkAddWorker,
stopBulkAddWorker,
} from './services/DeviceBulkAddWorker';
import { verifyToken } from './middleware/auth'; import { verifyToken } from './middleware/auth';
import { decrypt } from './utils/crypto'; import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
import authRoutes from './routes/auth'; import authRoutes from './routes/auth';
import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices'; import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices';
@@ -44,7 +50,7 @@ const PORT = parseInt(process.env.PORT || '3001', 10);
// ─── Socket.io ──────────────────────────────────────────────────────────────── // ─── Socket.io ────────────────────────────────────────────────────────────────
const io = new SocketServer(httpServer, { const io = new SocketServer(httpServer, {
cors: { origin: '*', methods: ['GET', 'POST'] }, cors: socketIoCorsOptions(),
path: '/socket.io', path: '/socket.io',
}); });
@@ -168,7 +174,7 @@ terminalNs.on('connection', (socket) => {
// ─── Middleware ─────────────────────────────────────────────────────────────── // ─── Middleware ───────────────────────────────────────────────────────────────
app.use(helmet({ contentSecurityPolicy: false })); app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: '*', credentials: true })); app.use(cors(corsMiddlewareOptions()));
app.use(compression()); app.use(compression());
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -234,9 +240,11 @@ async function start(): Promise<void> {
const pollerService = new PollerService(); const pollerService = new PollerService();
pollerService.setSocketServer(io); pollerService.setSocketServer(io);
setDevicesPoller(pollerService); setDevicesPoller(pollerService);
setBulkAddPollerService(pollerService);
setTopologyPoller(pollerService); setTopologyPoller(pollerService);
setClientsPoller(pollerService); setClientsPoller(pollerService);
await pollerService.start(); await pollerService.start();
await startBulkAddWorker();
// Start HTTP server // Start HTTP server
httpServer.listen(PORT, '0.0.0.0', () => { httpServer.listen(PORT, '0.0.0.0', () => {
@@ -246,6 +254,7 @@ async function start(): Promise<void> {
// Graceful shutdown // Graceful shutdown
const shutdown = async () => { const shutdown = async () => {
console.log('Shutting down...'); console.log('Shutting down...');
await stopBulkAddWorker();
await pollerService.stop(); await pollerService.stop();
await redis.quit().catch(() => {}); await redis.quit().catch(() => {});
await pool.end(); await pool.end();
+37
View File
@@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from 'express';
import { redis } from '../config/redis';
/**
* Fixed-window rate limit using Redis INCR (shared across API replicas).
*/
export function rateLimitRedis(options: {
windowSec: number;
max: number;
keyPrefix: string;
}): (req: Request, res: Response, next: NextFunction) => void {
const { windowSec, max, keyPrefix } = options;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
next();
return;
}
const uid = req.user?.userId;
const key = `rl:${keyPrefix}:${uid != null ? `u:${uid}` : `ip:${req.ip || 'unknown'}`}`;
try {
const n = await redis.incr(key);
if (n === 1) {
await redis.expire(key, windowSec);
}
if (n > max) {
const ttl = await redis.ttl(key);
const retryAfterSec = Math.max(1, ttl > 0 ? ttl : windowSec);
res.setHeader('Retry-After', String(retryAfterSec));
res.status(429).json({ error: 'Too many requests. Please try again later.' });
return;
}
} catch (e) {
console.warn('[rateLimitRedis] Redis error, allowing request:', (e as Error).message);
}
next();
};
}
@@ -0,0 +1,129 @@
jest.mock('../../config/database');
jest.mock('../../config/redis', () => ({
redis: {
incr: jest.fn().mockResolvedValue(1),
expire: jest.fn().mockResolvedValue(1),
ttl: jest.fn().mockResolvedValue(60),
},
}));
jest.mock('../../middleware/auth', () => ({
requireAuth: (_req: unknown, _res: unknown, next: () => void) => next(),
requireAdmin: (
req: { user?: { role: string } },
res: { status: (c: number) => { json: (b: unknown) => void } },
next: () => void
) => {
if (req.user?.role !== 'admin') {
res.status(403).json({ error: 'Admin access required' });
return;
}
next();
},
}));
jest.mock('../../utils/crypto', () => ({
encrypt: (s: string) => `enc:${s}`,
}));
import request from 'supertest';
import express from 'express';
import credentialPresetsRoutes from '../credentialPresets';
import { query, queryOne } from '../../config/database';
const mockedQuery = jest.mocked(query);
const mockedQueryOne = jest.mocked(queryOne);
function makeApp(role: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as express.Request & { user?: { userId: number; username: string; role: string } }).user = {
userId: 1,
username: 'test',
role,
};
next();
});
app.use('/', credentialPresetsRoutes);
return app;
}
describe('credentialPresets routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('GET / returns public list', async () => {
mockedQuery.mockResolvedValueOnce([
{
id: 1,
name: 'Default',
api_username: 'admin',
api_password_encrypted: 'x',
api_port: 8728,
ssh_username: null,
ssh_password_encrypted: null,
ssh_port: null,
notes: null,
allow_operator_use: true,
created_at: 't',
updated_at: 't',
},
]);
const app = makeApp('viewer');
const res = await request(app).get('/');
expect(res.status).toBe(200);
expect(res.body[0]).toMatchObject({
id: 1,
name: 'Default',
has_api_password: true,
has_ssh_password: false,
});
expect(res.body[0].api_password_encrypted).toBeUndefined();
});
it('POST / rejects non-admin', async () => {
const app = makeApp('operator');
const res = await request(app).post('/').send({
name: 'x',
api_username: 'a',
api_password: 'p',
});
expect(res.status).toBe(403);
});
it('POST / requires name, api_username, api_password', async () => {
const app = makeApp('admin');
const res = await request(app).post('/').send({ name: 'only' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/required/);
});
it('POST / creates preset when admin', async () => {
mockedQueryOne.mockResolvedValueOnce(null);
mockedQuery.mockResolvedValueOnce([
{
id: 2,
name: 'Lab',
api_username: 'admin',
api_password_encrypted: 'enc:p',
api_port: 8728,
ssh_username: null,
ssh_password_encrypted: null,
ssh_port: null,
notes: null,
allow_operator_use: true,
created_at: 't',
updated_at: 't',
},
]);
const app = makeApp('admin');
const res = await request(app).post('/').send({
name: 'Lab',
api_username: 'admin',
api_password: 'secret',
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: 2, name: 'Lab', has_api_password: true });
});
});
+33 -8
View File
@@ -2,10 +2,17 @@ import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database'; import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth'; import { requireAuth, requireAdmin } from '../middleware/auth';
import { encrypt } from '../utils/crypto'; import { encrypt } from '../utils/crypto';
import { rateLimitRedis } from '../middleware/rateLimitRedis';
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
const presetMutationLimiter = rateLimitRedis({
windowSec: 60,
max: 30,
keyPrefix: 'credential-preset',
});
export interface CredentialPresetRow { export interface CredentialPresetRow {
id: number; id: number;
name: string; name: string;
@@ -16,6 +23,8 @@ export interface CredentialPresetRow {
ssh_password_encrypted: string | null; ssh_password_encrypted: string | null;
ssh_port: number | null; ssh_port: number | null;
notes: string | null; notes: string | null;
/** When false, only admins may use this preset when adding/updating devices. */
allow_operator_use: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -30,6 +39,7 @@ interface CredentialPresetPublic {
ssh_username: string | null; ssh_username: string | null;
ssh_port: number | null; ssh_port: number | null;
notes: string | null; notes: string | null;
allow_operator_use: boolean;
has_api_password: boolean; has_api_password: boolean;
has_ssh_password: boolean; has_ssh_password: boolean;
created_at: string; created_at: string;
@@ -45,6 +55,7 @@ function toPublic(row: CredentialPresetRow): CredentialPresetPublic {
ssh_username: row.ssh_username, ssh_username: row.ssh_username,
ssh_port: row.ssh_port, ssh_port: row.ssh_port,
notes: row.notes, notes: row.notes,
allow_operator_use: row.allow_operator_use !== false,
has_api_password: !!row.api_password_encrypted, has_api_password: !!row.api_password_encrypted,
has_ssh_password: !!row.ssh_password_encrypted, has_ssh_password: !!row.ssh_password_encrypted,
created_at: row.created_at, created_at: row.created_at,
@@ -54,15 +65,20 @@ function toPublic(row: CredentialPresetRow): CredentialPresetPublic {
// GET /api/credential-presets — any authenticated user can list (the Add // GET /api/credential-presets — any authenticated user can list (the Add
// Device modal needs this for its picker), but secrets are never exposed. // Device modal needs this for its picker), but secrets are never exposed.
router.get('/', async (_req: Request, res: Response) => { router.get('/', async (req: Request, res: Response) => {
const role = req.user?.role;
const rows = await query<CredentialPresetRow>( const rows = await query<CredentialPresetRow>(
`SELECT * FROM credential_presets ORDER BY name ASC` `SELECT * FROM credential_presets ORDER BY name ASC`
); );
res.json(rows.map(toPublic)); const filtered =
role === 'admin'
? rows
: rows.filter((r) => (r as CredentialPresetRow).allow_operator_use !== false);
res.json(filtered.map(toPublic));
}); });
// POST /api/credential-presets — admin only // POST /api/credential-presets — admin only
router.post('/', requireAdmin, async (req: Request, res: Response) => { router.post('/', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
const { const {
name, name,
api_username, api_username,
@@ -72,6 +88,7 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => {
ssh_password, ssh_password,
ssh_port, ssh_port,
notes, notes,
allow_operator_use,
} = req.body as { } = req.body as {
name?: string; name?: string;
api_username?: string; api_username?: string;
@@ -81,6 +98,7 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => {
ssh_password?: string | null; ssh_password?: string | null;
ssh_port?: number | null; ssh_port?: number | null;
notes?: string | null; notes?: string | null;
allow_operator_use?: boolean;
}; };
if (!name || !api_username || !api_password) { if (!name || !api_username || !api_password) {
@@ -100,11 +118,13 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => {
const encApi = encrypt(api_password); const encApi = encrypt(api_password);
const encSsh = ssh_password ? encrypt(ssh_password) : null; const encSsh = ssh_password ? encrypt(ssh_password) : null;
const allowOp = allow_operator_use !== false;
const rows = await query<CredentialPresetRow>( const rows = await query<CredentialPresetRow>(
`INSERT INTO credential_presets `INSERT INTO credential_presets
(name, api_username, api_password_encrypted, api_port, (name, api_username, api_password_encrypted, api_port,
ssh_username, ssh_password_encrypted, ssh_port, notes) ssh_username, ssh_password_encrypted, ssh_port, notes, allow_operator_use)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -115,13 +135,14 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => {
encSsh, encSsh,
ssh_port ?? null, ssh_port ?? null,
notes || null, notes || null,
allowOp,
] ]
); );
return res.status(201).json(toPublic(rows[0])); return res.status(201).json(toPublic(rows[0]));
}); });
// PUT /api/credential-presets/:id — admin only // PUT /api/credential-presets/:id — admin only
router.put('/:id', requireAdmin, async (req: Request, res: Response) => { router.put('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
@@ -141,6 +162,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
ssh_port, ssh_port,
notes, notes,
clear_ssh_password, clear_ssh_password,
allow_operator_use,
} = req.body as { } = req.body as {
name?: string; name?: string;
api_username?: string; api_username?: string;
@@ -152,6 +174,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
notes?: string | null; notes?: string | null;
// Explicit flag to remove a previously-saved SSH password // Explicit flag to remove a previously-saved SSH password
clear_ssh_password?: boolean; clear_ssh_password?: boolean;
allow_operator_use?: boolean;
}; };
if (typeof name === 'string' && name && name !== existing.name) { if (typeof name === 'string' && name && name !== existing.name) {
@@ -177,8 +200,9 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
ssh_password_encrypted = $6, ssh_password_encrypted = $6,
ssh_port = $7, ssh_port = $7,
notes = $8, notes = $8,
allow_operator_use = COALESCE($9, allow_operator_use),
updated_at = NOW() updated_at = NOW()
WHERE id = $9`, WHERE id = $10`,
[ [
name ?? null, name ?? null,
api_username ?? null, api_username ?? null,
@@ -188,6 +212,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
newSshPass, newSshPass,
ssh_port === undefined ? existing.ssh_port : ssh_port, ssh_port === undefined ? existing.ssh_port : ssh_port,
notes === undefined ? existing.notes : (notes || null), notes === undefined ? existing.notes : (notes || null),
allow_operator_use === undefined ? null : allow_operator_use,
id, id,
] ]
); );
@@ -200,7 +225,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
}); });
// DELETE /api/credential-presets/:id — admin only // DELETE /api/credential-presets/:id — admin only
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => { router.delete('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const result = await query( const result = await query(
+95 -172
View File
@@ -1,4 +1,5 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { query, queryOne } from '../config/database'; import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth'; import { requireAuth, requireWrite } from '../middleware/auth';
import { encrypt, decrypt } from '../utils/crypto'; import { encrypt, decrypt } from '../utils/crypto';
@@ -6,11 +7,17 @@ import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { PollerService } from '../services/PollerService'; import { PollerService } from '../services/PollerService';
import type { CredentialPresetRow } from './credentialPresets'; import type { CredentialPresetRow } from './credentialPresets';
import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation';
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { redis } from '../config/redis';
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
// Resolve a credential preset id into decrypted credentials. Returns null if // Resolve a credential preset id into decrypted credentials. Returns null if
// no id was provided; throws a readable error if the id is invalid. // no id was provided; throws a readable error if the id is invalid.
async function loadCredentialPreset( async function loadCredentialPreset(
id: number | null | undefined id: number | null | undefined,
ctx?: CreateDeviceContext
): Promise<{ ): Promise<{
api_username: string; api_username: string;
api_password: string; api_password: string;
@@ -25,6 +32,12 @@ async function loadCredentialPreset(
[id] [id]
); );
if (!preset) throw new Error(`Credential preset ${id} not found`); if (!preset) throw new Error(`Credential preset ${id} not found`);
const allowOp = preset.allow_operator_use !== false;
if (ctx?.requestingUserRole === 'operator' && !allowOp) {
const err = new Error('This credential preset is restricted to administrators');
(err as Error & { statusCode?: number }).statusCode = 403;
throw err;
}
return { return {
api_username: preset.api_username, api_username: preset.api_username,
api_password: decrypt(preset.api_password_encrypted), api_password: decrypt(preset.api_password_encrypted),
@@ -165,165 +178,71 @@ router.get('/discovered', async (_req: Request, res: Response) => {
// POST /api/devices // POST /api/devices
router.post('/', requireWrite, async (req: Request, res: Response) => { router.post('/', requireWrite, async (req: Request, res: Response) => {
const { const result = await createDeviceFromBody(req.body, pollerService, {
name, ip_address, device_type = 'router', notes, requestingUserRole: req.user?.role,
credential_preset_id, });
} = req.body as { name?: string; ip_address?: string; device_type?: string; notes?: string; credential_preset_id?: number | null }; return res.status(result.status).json(result.body);
});
// If a preset is supplied, its credentials win over anything else in the const BULK_ADD_META_TTL_SEC = 86400;
// body — the client just needs to say "use preset 3". Anything the body
// also sends (like ssh_username) is ignored in favor of preset values, so // POST /api/devices/bulk-add/jobs — enqueue Try-All style adds (survives tab close)
// "deploying" a preset always produces a consistent device record. router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response) => {
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null; const { items } = req.body as { items?: unknown };
try { if (!Array.isArray(items) || items.length === 0) {
preset = await loadCredentialPreset(credential_preset_id ?? null); return res.status(400).json({ error: 'items must be a non-empty array' });
} catch (err) {
return res.status(400).json({ error: (err as Error).message });
} }
if (items.length > 500) {
const api_username: string | undefined = preset?.api_username ?? req.body.api_username; return res.status(400).json({ error: 'items array exceeds maximum of 500' });
const api_password: string | undefined = preset?.api_password ?? req.body.api_password;
const parsePort = (v: unknown, fallback: number): number => {
if (v == null || v === '') return fallback;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) ? n : fallback;
};
const api_port: number = preset?.api_port ?? parsePort(req.body.api_port, 8728);
const ssh_username: string | null = preset ? preset.ssh_username : (req.body.ssh_username ?? null);
const ssh_password: string | null = preset ? preset.ssh_password : (req.body.ssh_password ?? null);
const ssh_port: number = preset?.ssh_port ?? parsePort(req.body.ssh_port, 22);
const combineWithDeviceId =
typeof req.body.combine_with_device_id === 'number' ? req.body.combine_with_device_id : null;
const forceReplaceBySerial = req.body.force_replace_existing_by_serial === true;
if (!name || !ip_address || !api_username || !api_password) {
return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' });
} }
const jobId = randomUUID();
// Test connection and detect hardware serial before saving const ownerUserId = req.user!.userId;
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000); await redis.set(
let detectedSerial: string | null = null; `device-bulk-add:${jobId}:meta`,
try { JSON.stringify({
await testClient.connect(); status: 'queued',
const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]); total: items.length,
detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null; processed: 0,
} catch (err) { owner_user_id: ownerUserId,
return res.status(422).json({ created_at: new Date().toISOString(),
error: `Cannot connect to device: ${(err as Error).message}`, }),
}); 'EX',
} finally { BULK_ADD_META_TTL_SEC
testClient.disconnect();
}
if (detectedSerial) {
const existingBySerial = await queryOne<{
id: number;
name: string;
ip_address: string;
serial_number: string;
}>(
`SELECT id, name, ip_address, serial_number
FROM devices
WHERE serial_number = $1`,
[detectedSerial]
);
if (existingBySerial) {
const shouldCombine =
combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id;
if (!shouldCombine && !forceReplaceBySerial) {
return res.status(409).json({
error: 'duplicate_serial',
code: 'duplicate_serial',
existing_device: existingBySerial,
candidate: {
serial_number: detectedSerial,
identity: name,
ip_address,
},
});
}
const encryptedPass = encrypt(api_password);
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
await query(
`UPDATE devices SET
name=COALESCE($1,name),
ip_address=$2,
api_port=$3,
api_username=$4,
api_password_encrypted=$5,
ssh_port=$6,
ssh_username=COALESCE($7,ssh_username),
ssh_password_encrypted=COALESCE($8,ssh_password_encrypted),
device_type=COALESCE($9,device_type),
notes=COALESCE($10,notes),
updated_at=NOW()
WHERE id = $11`,
[
name,
ip_address,
api_port,
api_username,
encryptedPass,
ssh_port,
ssh_username,
encryptedSshPass,
device_type,
notes || null,
existingBySerial.id,
]
);
if (pollerService) {
await pollerService.scheduleDeviceSync(existingBySerial.id, 'full');
}
const updatedExisting = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
[existingBySerial.id]
);
if (!updatedExisting) {
return res.status(500).json({
error: 'Duplicate merge succeeded but device row could not be reloaded',
device_id: existingBySerial.id,
});
}
return res.status(200).json({
...updatedExisting,
merged_from_duplicate: true,
});
}
}
const encryptedPass = encrypt(api_password);
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
const rows = await query<{ id: number }>(
`INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted,
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`,
[name, ip_address, api_port, api_username, encryptedPass,
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null]
); );
await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC);
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[]);
return res.status(202).json({ job_id: jobId, total: items.length });
});
const newId = rows[0].id; // POST /api/devices/bulk-add/jobs/:jobId/cancel — request cooperative cancel
router.post('/bulk-add/jobs/:jobId/cancel', requireWrite, async (req: Request, res: Response) => {
// Trigger full sync in background const state = await getBulkAddJobState(req.params.jobId);
if (pollerService) { if (!state.found) {
await pollerService.scheduleDeviceSync(newId, 'full'); return res.status(404).json({ error: 'Job not found or expired' });
} }
const ownerId = state.meta.owner_user_id;
if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
await redis.set(`device-bulk-add:${req.params.jobId}:cancel`, '1', 'EX', BULK_ADD_META_TTL_SEC);
return res.json({ message: 'Cancel requested' });
});
const device = await queryOne( // GET /api/devices/bulk-add/jobs/:jobId — poll job status and incremental results
`SELECT id, name, ip_address, api_port, api_username, model, serial_number, router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Response) => {
firmware_version, ros_version, device_type, status, last_seen, notes, created_at const state = await getBulkAddJobState(req.params.jobId);
FROM devices WHERE id = $1`, if (!state.found) {
[newId] return res.status(404).json({ error: 'Job not found or expired' });
); }
const ownerId = state.meta.owner_user_id;
return res.status(201).json(device); if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
return res.json({
job_id: req.params.jobId,
...state.meta,
results: state.results,
});
}); });
// GET /api/devices/:id // GET /api/devices/:id
@@ -392,35 +311,39 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => {
credential_preset_id?: number | null; credential_preset_id?: number | null;
}; };
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
try {
preset = await loadCredentialPreset(credential_preset_id ?? null);
} catch (err) {
return res.status(400).json({ error: (err as Error).message });
}
// When a preset is applied, its values take precedence over anything else
// in the body so "apply preset" has a single unambiguous meaning.
const api_port = preset?.api_port ?? req.body.api_port;
const api_username = preset?.api_username ?? req.body.api_username;
const api_password = preset?.api_password ?? req.body.api_password;
const ssh_port = preset?.ssh_port ?? req.body.ssh_port;
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
const existing = await queryOne<{ const existing = await queryOne<{
id: number; id: number;
ip_address: string; ip_address: string;
api_port: number; api_port: number;
api_username: string; api_username: string;
api_password_encrypted: string; api_password_encrypted: string;
ssh_port: number | null;
}>( }>(
`SELECT id, ip_address, api_port, api_username, api_password_encrypted `SELECT id, ip_address, api_port, api_username, api_password_encrypted, ssh_port
FROM devices WHERE id = $1`, FROM devices WHERE id = $1`,
[req.params.id] [req.params.id]
); );
if (!existing) return res.status(404).json({ error: 'Device not found' }); if (!existing) return res.status(404).json({ error: 'Device not found' });
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
try {
preset = await loadCredentialPreset(credential_preset_id ?? null, {
requestingUserRole: req.user?.role,
});
} catch (err) {
const status = (err as Error & { statusCode?: number }).statusCode ?? 400;
return res.status(status).json({ error: (err as Error).message });
}
// When a preset is applied, its values take precedence over anything else
// in the body so "apply preset" has a single unambiguous meaning.
const api_port = preset?.api_port ?? parsePort(req.body.api_port, existing.api_port);
const api_username = preset?.api_username ?? req.body.api_username;
const api_password = preset?.api_password ?? req.body.api_password;
const ssh_port = preset?.ssh_port ?? parsePort(req.body.ssh_port, existing.ssh_port ?? 22);
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
// If the user is changing the IP (or port / username / password), verify the // If the user is changing the IP (or port / username / password), verify the
// RouterOS API is still reachable with the new values before persisting — // RouterOS API is still reachable with the new values before persisting —
// otherwise we could silently brick the device record. // otherwise we could silently brick the device record.
@@ -430,7 +353,7 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => {
const presetReplacesApiCreds = !!preset; const presetReplacesApiCreds = !!preset;
if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) { if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) {
const testIp = ip_address ?? existing.ip_address; const testIp = ip_address ?? existing.ip_address;
const testPort = (typeof api_port === 'number' ? api_port : undefined) ?? existing.api_port; const testPort = api_port;
const testUser = api_username ?? existing.api_username; const testUser = api_username ?? existing.api_username;
const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted); const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted);
const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000); const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000);
@@ -439,7 +362,7 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => {
testClient.disconnect(); testClient.disconnect();
} catch (err) { } catch (err) {
return res.status(422).json({ return res.status(422).json({
error: `Cannot connect to device with new settings: ${(err as Error).message}`, error: safeConnectionError('PUT /devices/:id', err),
}); });
} }
} }
+185
View File
@@ -0,0 +1,185 @@
import { Queue, Worker, Job } from 'bullmq';
import { createRedisConnection } from '../config/redis';
import { redis } from '../config/redis';
import { createDeviceFromBody, type CreateDeviceInput } from './deviceCreation';
import type { PollerService } from './PollerService';
const QUEUE_NAME = 'bulk-add-devices';
const JOB_TTL_SEC = 86400;
let pollerService: PollerService | null = null;
let queue: Queue | null = null;
let worker: Worker | null = null;
export function setBulkAddPollerService(p: PollerService): void {
pollerService = p;
}
function metaKey(jobId: string): string {
return `device-bulk-add:${jobId}:meta`;
}
function resultsKey(jobId: string): string {
return `device-bulk-add:${jobId}:results`;
}
export type BulkAddResultRow = {
ip: string;
identity: string;
ok: boolean;
message: string;
};
interface BulkJobPayload {
jobId: string;
items: CreateDeviceInput[];
}
async function readMeta(jobId: string): Promise<Record<string, unknown>> {
const raw = await redis.get(metaKey(jobId));
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
async function writeMeta(jobId: string, patch: Record<string, unknown>): Promise<void> {
const cur = await readMeta(jobId);
const next = { ...cur, ...patch };
await redis.set(metaKey(jobId), JSON.stringify(next), 'EX', JOB_TTL_SEC);
}
async function readResults(jobId: string): Promise<BulkAddResultRow[]> {
const raw = await redis.get(resultsKey(jobId));
if (!raw) return [];
try {
return JSON.parse(raw) as BulkAddResultRow[];
} catch {
return [];
}
}
async function appendResults(jobId: string, rows: BulkAddResultRow[]): Promise<void> {
const cur = await readResults(jobId);
const next = [...cur, ...rows];
await redis.set(resultsKey(jobId), JSON.stringify(next), 'EX', JOB_TTL_SEC);
}
async function processJob(job: Job<BulkJobPayload>): Promise<void> {
const { jobId, items } = job.data;
await writeMeta(jobId, { status: 'active', processed: 0 });
const poller = pollerService;
for (let i = 0; i < items.length; i++) {
const cancel = await redis.get(`device-bulk-add:${jobId}:cancel`);
if (cancel) {
await writeMeta(jobId, {
status: 'cancelled',
processed: i,
current_name: null,
cancelled_at: new Date().toISOString(),
});
return;
}
const item = items[i];
const identity = item.name || item.ip_address || '';
await writeMeta(jobId, {
processed: i,
current_name: identity,
});
const result = await createDeviceFromBody(
{
...item,
device_type: item.device_type || 'router',
},
poller
);
let failMsg = 'Failed';
if (!result.ok) {
const b = result.body;
if (b.code === 'duplicate_serial') {
failMsg = 'Duplicate serial — review manually before combining';
} else if (typeof b.error === 'string') {
failMsg = b.error;
}
}
const row: BulkAddResultRow = {
ip: item.ip_address || '',
identity,
ok: result.ok,
message: result.ok
? (result.body.merged_from_duplicate ? 'Updated (duplicate serial)' : 'Added')
: failMsg,
};
await appendResults(jobId, [row]);
await writeMeta(jobId, { processed: i + 1 });
}
await writeMeta(jobId, {
status: 'completed',
processed: items.length,
current_name: null,
completed_at: new Date().toISOString(),
});
}
export async function enqueueBulkAddJob(jobId: string, items: CreateDeviceInput[]): Promise<void> {
if (!queue) {
queue = new Queue(QUEUE_NAME, { connection: createRedisConnection() });
}
await queue.add('run', { jobId, items } satisfies BulkJobPayload, {
attempts: 1,
removeOnComplete: { age: 3600, count: 100 },
removeOnFail: { age: 86400 },
});
}
export async function getBulkAddJobState(jobId: string): Promise<{
found: boolean;
meta: Record<string, unknown>;
results: BulkAddResultRow[];
}> {
const meta = await readMeta(jobId);
if (!meta || Object.keys(meta).length === 0) {
return { found: false, meta: {}, results: [] };
}
const results = await readResults(jobId);
return { found: true, meta, results };
}
export async function startBulkAddWorker(): Promise<void> {
if (worker) return;
worker = new Worker<BulkJobPayload>(
QUEUE_NAME,
async (job) => {
try {
await processJob(job);
} catch (err) {
const { jobId } = job.data;
console.error('[BulkAddWorker] job failed:', job?.id, err);
await writeMeta(jobId, {
status: 'failed',
error: 'Batch job failed. Check server logs for details.',
failed_at: new Date().toISOString(),
});
throw err;
}
},
{ connection: createRedisConnection(), concurrency: 1 }
);
worker.on('failed', (job, err) => {
console.error('[BulkAddWorker] job failed:', job?.id, err);
});
}
export async function stopBulkAddWorker(): Promise<void> {
await worker?.close();
worker = null;
await queue?.close();
queue = null;
}
+242
View File
@@ -0,0 +1,242 @@
import { query, queryOne } from '../config/database';
import { encrypt, decrypt } from '../utils/crypto';
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { RouterOSClient } from './mikrotik/RouterOSClient';
import type { PollerService } from './PollerService';
import type { CredentialPresetRow } from '../routes/credentialPresets';
export type CreateDeviceContext = {
/** JWT role of the caller ('admin' | 'operator' | 'viewer'). Preset use may be restricted for operators. */
requestingUserRole?: string;
};
async function loadCredentialPreset(
id: number | null | undefined,
ctx?: CreateDeviceContext
): Promise<{
api_username: string;
api_password: string;
api_port: number | null;
ssh_username: string | null;
ssh_password: string | null;
ssh_port: number | null;
} | null> {
if (id === null || id === undefined) return null;
const preset = await queryOne<CredentialPresetRow>(
`SELECT * FROM credential_presets WHERE id = $1`,
[id]
);
if (!preset) throw new Error(`Credential preset ${id} not found`);
const allowOp = (preset as CredentialPresetRow & { allow_operator_use?: boolean }).allow_operator_use !== false;
if (ctx?.requestingUserRole === 'operator' && !allowOp) {
const err = new Error('This credential preset is restricted to administrators');
(err as Error & { statusCode?: number }).statusCode = 403;
throw err;
}
return {
api_username: preset.api_username,
api_password: decrypt(preset.api_password_encrypted),
api_port: preset.api_port,
ssh_username: preset.ssh_username,
ssh_password: preset.ssh_password_encrypted ? decrypt(preset.ssh_password_encrypted) : null,
ssh_port: preset.ssh_port,
};
}
export interface CreateDeviceInput {
name?: string;
ip_address?: string;
device_type?: string;
notes?: string;
credential_preset_id?: number | null;
api_username?: string;
api_password?: string;
api_port?: unknown;
ssh_username?: string | null;
ssh_password?: string | null;
ssh_port?: unknown;
combine_with_device_id?: number;
force_replace_existing_by_serial?: boolean;
}
export type CreateDeviceResult =
| { ok: true; status: 200 | 201; body: Record<string, unknown> }
| { ok: false; status: number; body: Record<string, unknown> };
/**
* Shared device creation logic used by POST /api/devices and bulk-add worker.
*/
export async function createDeviceFromBody(
input: CreateDeviceInput,
pollerService: PollerService | null,
ctx?: CreateDeviceContext
): Promise<CreateDeviceResult> {
const {
name,
ip_address,
device_type = 'router',
notes,
credential_preset_id,
} = input;
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
try {
preset = await loadCredentialPreset(credential_preset_id ?? null, ctx);
} catch (err) {
const status = (err as Error & { statusCode?: number }).statusCode ?? 400;
return { ok: false, status, body: { error: (err as Error).message } };
}
const api_username: string | undefined = preset?.api_username ?? input.api_username;
const api_password: string | undefined = preset?.api_password ?? input.api_password;
const api_port: number = preset?.api_port ?? parsePort(input.api_port, 8728);
const ssh_username: string | null = preset ? preset.ssh_username : (input.ssh_username ?? null);
const ssh_password: string | null = preset ? preset.ssh_password : (input.ssh_password ?? null);
const ssh_port: number = preset?.ssh_port ?? parsePort(input.ssh_port, 22);
const combineWithDeviceId =
typeof input.combine_with_device_id === 'number' ? input.combine_with_device_id : null;
const forceReplaceBySerial = input.force_replace_existing_by_serial === true;
if (!name || !ip_address || !api_username || !api_password) {
return {
ok: false,
status: 400,
body: { error: 'name, ip_address, api_username, api_password are required' },
};
}
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
let detectedSerial: string | null = null;
try {
await testClient.connect();
const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null;
} catch (err) {
return {
ok: false,
status: 422,
body: { error: safeConnectionError('createDeviceFromBody', err) },
};
} finally {
testClient.disconnect();
}
if (detectedSerial) {
const existingBySerial = await queryOne<{
id: number;
name: string;
ip_address: string;
serial_number: string;
}>(
`SELECT id, name, ip_address, serial_number
FROM devices
WHERE serial_number = $1`,
[detectedSerial]
);
if (existingBySerial) {
const shouldCombine =
combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id;
if (!shouldCombine && !forceReplaceBySerial) {
return {
ok: false,
status: 409,
body: {
error: 'duplicate_serial',
code: 'duplicate_serial',
existing_device: existingBySerial,
candidate: {
serial_number: detectedSerial,
identity: name,
ip_address,
},
},
};
}
const encryptedPass = encrypt(api_password);
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
await query(
`UPDATE devices SET
name=COALESCE($1,name),
ip_address=$2,
api_port=$3,
api_username=$4,
api_password_encrypted=$5,
ssh_port=$6,
ssh_username=COALESCE($7,ssh_username),
ssh_password_encrypted=COALESCE($8,ssh_password_encrypted),
device_type=COALESCE($9,device_type),
notes=COALESCE($10,notes),
updated_at=NOW()
WHERE id = $11`,
[
name,
ip_address,
api_port,
api_username,
encryptedPass,
ssh_port,
ssh_username,
encryptedSshPass,
device_type,
notes || null,
existingBySerial.id,
]
);
if (pollerService) {
await pollerService.scheduleDeviceSync(existingBySerial.id, 'full');
}
const updatedExisting = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
[existingBySerial.id]
);
if (!updatedExisting) {
return {
ok: false,
status: 500,
body: {
error: 'Duplicate merge succeeded but device row could not be reloaded',
device_id: existingBySerial.id,
},
};
}
return {
ok: true,
status: 200,
body: { ...updatedExisting, merged_from_duplicate: true },
};
}
}
const encryptedPass = encrypt(api_password);
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
const rows = await query<{ id: number }>(
`INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted,
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`,
[name, ip_address, api_port, api_username, encryptedPass,
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null]
);
const newId = rows[0].id;
if (pollerService) {
await pollerService.scheduleDeviceSync(newId, 'full');
}
const device = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
[newId]
);
return { ok: true, status: 201, body: device as Record<string, unknown> };
}
@@ -0,0 +1,56 @@
import {
corsMiddlewareOptions,
resetCorsResolvedCacheForTests,
socketIoCorsOptions,
} from '../corsOrigins';
describe('corsOrigins', () => {
const prev = { ...process.env };
afterEach(() => {
process.env = { ...prev };
resetCorsResolvedCacheForTests();
});
it('uses wildcard in test when CORS_ORIGIN is unset', () => {
process.env.NODE_ENV = 'test';
delete process.env.CORS_ORIGIN;
expect(corsMiddlewareOptions()).toMatchObject({
origin: '*',
credentials: false,
});
expect(socketIoCorsOptions()).toMatchObject({
origin: '*',
credentials: false,
});
});
it('parses comma-separated CORS_ORIGIN in production', () => {
process.env.NODE_ENV = 'production';
process.env.CORS_ORIGIN = 'https://a.example.com, https://b.example.com ';
const cors = corsMiddlewareOptions();
expect(cors.credentials).toBe(true);
expect(typeof cors.origin).toBe('function');
const originFn = cors.origin as (
o: string | undefined,
fn: (e: Error | null, ok?: boolean) => void,
) => void;
originFn(undefined, (err, ok) => {
expect(err).toBeNull();
expect(ok).toBe(true);
});
originFn('https://a.example.com', (err, ok) => {
expect(err).toBeNull();
expect(ok).toBe(true);
});
originFn('https://evil.com', (err, ok) => {
expect(err).toBeNull();
expect(ok).toBe(false);
});
expect(socketIoCorsOptions()).toEqual({
origin: ['https://a.example.com', 'https://b.example.com'],
methods: ['GET', 'POST'],
credentials: true,
});
});
});
@@ -0,0 +1,20 @@
import { parsePort } from '../parsePort';
describe('parsePort', () => {
it('returns fallback for null, undefined, empty string', () => {
expect(parsePort(null, 8728)).toBe(8728);
expect(parsePort(undefined, 22)).toBe(22);
expect(parsePort('', 8728)).toBe(8728);
});
it('parses numbers and numeric strings', () => {
expect(parsePort(8729, 8728)).toBe(8729);
expect(parsePort('8729', 8728)).toBe(8729);
expect(parsePort('22', 22)).toBe(22);
});
it('returns fallback for NaN and non-numeric strings', () => {
expect(parsePort('abc', 8728)).toBe(8728);
expect(parsePort(NaN, 22)).toBe(22);
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { CorsOptions } from 'cors';
/** Browser dev servers and typical local access through nginx. */
const DEFAULT_DEV_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost',
'http://127.0.0.1',
'https://localhost',
'https://127.0.0.1',
];
type Resolved =
| { kind: 'wildcard' }
| { kind: 'list'; allowed: string[] };
function parseList(raw: string): string[] {
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
function resolveCors(): Resolved {
const nodeEnv = process.env.NODE_ENV || 'development';
const raw = process.env.CORS_ORIGIN?.trim();
if (raw === '*') {
if (nodeEnv === 'production') {
console.error(
'CORS_ORIGIN=* is not allowed in production. Set a comma-separated list of browser origins (e.g. https://manager.example.com).',
);
process.exit(1);
}
return { kind: 'wildcard' };
}
if (!raw) {
if (nodeEnv === 'production') {
console.error(
'CORS_ORIGIN is required in production (comma-separated origins, e.g. https://manager.example.com).',
);
process.exit(1);
}
if (nodeEnv === 'test') {
return { kind: 'wildcard' };
}
return { kind: 'list', allowed: DEFAULT_DEV_ORIGINS };
}
return { kind: 'list', allowed: parseList(raw) };
}
let resolvedCache: Resolved | null = null;
function getResolved(): Resolved {
if (!resolvedCache) resolvedCache = resolveCors();
return resolvedCache;
}
/** Clears memoized CORS resolution (for Jest when env changes between cases). */
export function resetCorsResolvedCacheForTests(): void {
resolvedCache = null;
}
/** Express `cors` middleware options (no `origin: *` with `credentials: true`). */
export function corsMiddlewareOptions(): CorsOptions {
const resolved = getResolved();
if (resolved.kind === 'wildcard') {
return { origin: '*', credentials: false };
}
const allowed = resolved.allowed;
return {
origin(origin, callback) {
if (!origin) {
callback(null, true);
return;
}
if (allowed.includes(origin)) {
callback(null, true);
return;
}
callback(null, false);
},
credentials: true,
};
}
/** Socket.IO server `cors` field — must align with HTTP CORS. */
export function socketIoCorsOptions(): {
origin: string | string[];
methods: string[];
credentials: boolean;
} {
const resolved = getResolved();
if (resolved.kind === 'wildcard') {
return { origin: '*', methods: ['GET', 'POST'], credentials: false };
}
return {
origin: resolved.allowed,
methods: ['GET', 'POST'],
credentials: true,
};
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Parse API/SSH port from request bodies (string or number). Invalid values
* fall back to the provided default (matches existing device routes behavior).
*/
export function parsePort(value: unknown, fallback: number): number {
if (value == null || value === '') return fallback;
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
return Number.isFinite(n) ? n : fallback;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Return a short, stable message for API clients; log full detail server-side only.
*/
export function safeConnectionError(context: string, err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
console.warn(`[${context}]`, raw);
return 'Cannot connect to device. Check IP, port, credentials, and that the API service is reachable.';
}
+1
View File
@@ -31,6 +31,7 @@ services:
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
PORT: 3001 PORT: 3001
depends_on: depends_on:
postgres: postgres:
+1
View File
@@ -32,6 +32,7 @@ services:
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
PORT: 3001 PORT: 3001
depends_on: depends_on:
postgres: postgres:
+464 -1
View File
@@ -40,7 +40,8 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^5.0.11" "vite": "^5.0.11",
"vitest": "^3.0.5"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -1465,6 +1466,17 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -1562,6 +1574,13 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1935,6 +1954,121 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@@ -2211,6 +2345,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async-function": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -2382,6 +2526,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2472,6 +2626,23 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2489,6 +2660,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2906,6 +3087,16 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3153,6 +3344,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -3510,6 +3708,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3526,6 +3734,16 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4700,6 +4918,13 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4719,6 +4944,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5091,6 +5326,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5927,6 +6179,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/socket.io-client": { "node_modules/socket.io-client": {
"version": "4.8.3", "version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
@@ -5965,6 +6224,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6090,6 +6363,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -6216,6 +6509,20 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6264,6 +6571,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6584,6 +6921,115 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6689,6 +7135,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -2
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src" "lint": "eslint src",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.17.1", "@tanstack/react-query": "^5.17.1",
@@ -42,6 +43,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^5.0.11" "vite": "^5.0.11",
"vitest": "^3.0.5"
} }
} }
@@ -7,6 +7,7 @@ import {
type DuplicateSerialError, type DuplicateSerialError,
} from '../../services/api'; } from '../../services/api';
import ConfirmDuplicateModal from './ConfirmDuplicateModal'; import ConfirmDuplicateModal from './ConfirmDuplicateModal';
import { parsePort } from '../../utils/parsePort';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -62,10 +63,10 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
return { return {
name: form.name, name: form.name,
ip_address: form.ip_address, ip_address: form.ip_address,
api_port: parseInt(form.api_port, 10), api_port: parsePort(form.api_port, 8728),
api_username: form.api_username, api_username: form.api_username,
api_password: form.api_password, api_password: form.api_password,
ssh_port: parseInt(form.ssh_port, 10), ssh_port: parsePort(form.ssh_port, 22),
ssh_username: form.ssh_username || undefined, ssh_username: form.ssh_username || undefined,
ssh_password: form.ssh_password || undefined, ssh_password: form.ssh_password || undefined,
device_type: form.device_type as import('../../types').DeviceType, device_type: form.device_type as import('../../types').DeviceType,
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, FormEvent } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react'; import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react';
import { devicesApi } from '../../services/api'; import { devicesApi } from '../../services/api';
import { parsePort } from '../../utils/parsePort';
import type { Device, DeviceType, IpAddress } from '../../types'; import type { Device, DeviceType, IpAddress } from '../../types';
interface Props { interface Props {
@@ -90,9 +91,9 @@ export default function EditDeviceModal({ device, onClose, onSuccess }: Props) {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
name: form.name, name: form.name,
ip_address: form.ip_address, ip_address: form.ip_address,
api_port: parseInt(form.api_port, 10) || 8728, api_port: parsePort(form.api_port, 8728),
api_username: form.api_username, api_username: form.api_username,
ssh_port: parseInt(form.ssh_port, 10) || 22, ssh_port: parsePort(form.ssh_port, 22),
ssh_username: form.ssh_username || null, ssh_username: form.ssh_username || null,
device_type: form.device_type as DeviceType, device_type: form.device_type as DeviceType,
notes: form.notes, notes: form.notes,
@@ -1,13 +1,14 @@
import { useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react'; import { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react';
import { import {
credentialPresetsApi, credentialPresetsApi,
devicesApi, devicesApi,
type BulkAddDeviceItem,
type DiscoveredDevice, type DiscoveredDevice,
type DuplicateSerialError,
} from '../../services/api'; } from '../../services/api';
import type { DeviceType } from '../../types'; import type { DeviceType } from '../../types';
import { parsePort } from '../../utils/parsePort';
interface Props { interface Props {
discoveredDevices: DiscoveredDevice[]; discoveredDevices: DiscoveredDevice[];
@@ -17,11 +18,6 @@ interface Props {
type ResultItem = { ip: string; identity: string; ok: boolean; message: string }; type ResultItem = { ip: string; identity: string; ok: boolean; message: string };
function isDuplicateSerialError(v: unknown): v is DuplicateSerialError {
if (!v || typeof v !== 'object') return false;
const obj = v as { error?: unknown; code?: unknown; existing_device?: unknown };
return obj.error === 'duplicate_serial' && obj.code === 'duplicate_serial' && !!obj.existing_device;
}
export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSuccess }: Props) { export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSuccess }: Props) {
const [mode, setMode] = useState<'preset' | 'manual'>('preset'); const [mode, setMode] = useState<'preset' | 'manual'>('preset');
const [presetId, setPresetId] = useState<number | null>(null); const [presetId, setPresetId] = useState<number | null>(null);
@@ -37,13 +33,14 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultItem[] | null>(null); const [results, setResults] = useState<ResultItem[] | null>(null);
const [cancelled, setCancelled] = useState(false); const [cancelled, setCancelled] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({ const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({
current: 0, current: 0,
total: 0, total: 0,
currentName: '', currentName: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const cancelRef = useRef(false); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { data: presets = [] } = useQuery({ const { data: presets = [] } = useQuery({
queryKey: ['credential-presets'], queryKey: ['credential-presets'],
@@ -56,11 +53,75 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
[discoveredDevices] [discoveredDevices]
); );
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const buildItems = (): BulkAddDeviceItem[] => {
return targets.map((d) => {
const name = d.identity || d.address;
if (mode === 'preset') {
return {
name,
ip_address: d.address,
credential_preset_id: presetId!,
};
}
return {
name,
ip_address: d.address,
api_username: manual.api_username,
api_password: manual.api_password,
api_port: parsePort(manual.api_port, 8728),
ssh_username: manual.ssh_username || undefined,
ssh_password: manual.ssh_password || undefined,
ssh_port: parsePort(manual.ssh_port, 22),
device_type: manual.device_type,
};
});
};
const pollOnce = async (id: string) => {
try {
const { data } = await devicesApi.bulkAddStatus(id);
const total = typeof data.total === 'number' ? data.total : 0;
const processed = typeof data.processed === 'number' ? data.processed : 0;
const currentName = typeof data.current_name === 'string' ? data.current_name : '';
setProgress({
current: Math.min(processed, total || 1),
total: total || targets.length,
currentName,
});
if (Array.isArray(data.results)) {
setResults(data.results as ResultItem[]);
}
const st = String(data.status || '');
if (st === 'completed' || st === 'failed' || st === 'cancelled') {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
setRunning(false);
setJobId(null);
if (st === 'cancelled') setCancelled(true);
if (st === 'failed' && data.error) {
setError(String(data.error));
}
const ok = (data.results as ResultItem[] | undefined)?.filter((r) => r.ok).length ?? 0;
if (ok > 0) onSuccess();
}
} catch {
// keep polling unless job missing
}
};
const handleRun = async () => { const handleRun = async () => {
setError(''); setError('');
setCancelled(false); setCancelled(false);
cancelRef.current = false;
setResults(null); setResults(null);
setJobId(null);
if (!targets.length) { if (!targets.length) {
setError('No discovered devices with a usable IP address.'); setError('No discovered devices with a usable IP address.');
return; return;
@@ -76,52 +137,36 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
setRunning(true); setRunning(true);
setProgress({ current: 0, total: targets.length, currentName: '' }); setProgress({ current: 0, total: targets.length, currentName: '' });
const out: ResultItem[] = [];
for (let i = 0; i < targets.length; i++) {
if (cancelRef.current) break;
const d = targets[i];
const name = d.identity || d.address;
setProgress({ current: i + 1, total: targets.length, currentName: name });
const payload =
mode === 'preset'
? {
name,
ip_address: d.address,
credential_preset_id: presetId!,
}
: {
name,
ip_address: d.address,
api_username: manual.api_username,
api_password: manual.api_password,
api_port: parseInt(manual.api_port, 10) || 8728,
ssh_username: manual.ssh_username || undefined,
ssh_password: manual.ssh_password || undefined,
ssh_port: parseInt(manual.ssh_port, 10) || 22,
device_type: manual.device_type,
};
try {
await devicesApi.create(payload);
out.push({ ip: d.address, identity: name, ok: true, message: 'Added' });
setResults([...out]);
} catch (err: unknown) {
const data = (err as { response?: { data?: unknown } })?.response?.data;
const msg = isDuplicateSerialError(data) try {
? `Duplicate serial mismatch: candidate ${data.candidate.serial_number}, existing ${data.existing_device.serial_number}. Review manually before combining.` const items = buildItems();
: ((data as { message?: string; error?: string } | undefined)?.message || const { data } = await devicesApi.bulkAddEnqueue(items);
(typeof (data as { error?: unknown } | undefined)?.error === 'string' const id = data.job_id;
? String((data as { error?: string }).error) setJobId(id);
: '') || setProgress({ current: 0, total: data.total, currentName: '' });
'Failed'); await pollOnce(id);
out.push({ ip: d.address, identity: name, ok: false, message: msg }); if (pollRef.current) clearInterval(pollRef.current);
setResults([...out]); pollRef.current = setInterval(() => {
void pollOnce(id);
}, 1200);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'Failed to start bulk add job';
setError(msg);
setRunning(false);
}
};
const handleCancel = async () => {
if (jobId) {
try {
await devicesApi.bulkAddCancel(jobId);
} catch {
// ignore
} }
} }
setResults(out); setCancelled(true);
setRunning(false);
setProgress((p) => ({ ...p, currentName: '' }));
if (cancelRef.current) setCancelled(true);
}; };
const okCount = results?.filter((r) => r.ok).length ?? 0; const okCount = results?.filter((r) => r.ok).length ?? 0;
@@ -140,12 +185,14 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<p className="text-sm text-gray-600 dark:text-slate-300"> <p className="text-sm text-gray-600 dark:text-slate-300">
Attempt to add <strong>{targets.length}</strong> discovered device(s) in one run. Attempt to add <strong>{targets.length}</strong> discovered device(s) in one run.
Progress runs <strong>on the server</strong> — you can leave this page and reopen later (job id is shown while running).
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => setMode('preset')} onClick={() => setMode('preset')}
disabled={running}
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'preset' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`} className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'preset' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
> >
Use Preset Use Preset
@@ -153,6 +200,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
<button <button
type="button" type="button"
onClick={() => setMode('manual')} onClick={() => setMode('manual')}
disabled={running}
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'manual' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`} className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'manual' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
> >
Enter Manually Enter Manually
@@ -162,7 +210,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
{mode === 'preset' ? ( {mode === 'preset' ? (
<div> <div>
<label className="label flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" />Credential Preset</label> <label className="label flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" />Credential Preset</label>
<select className="input" value={presetId ?? ''} onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)}> <select className="input" value={presetId ?? ''} onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)} disabled={running}>
<option value="">Select preset...</option> <option value="">Select preset...</option>
{presets.map((p) => ( {presets.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
@@ -175,31 +223,35 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="label">API Username *</label> <label className="label">API Username *</label>
<input className="input" value={manual.api_username} onChange={(e) => setManual((m) => ({ ...m, api_username: e.target.value }))} /> <input className="input" value={manual.api_username} disabled={running} onChange={(e) => setManual((m) => ({ ...m, api_username: e.target.value }))} />
</div> </div>
<div> <div>
<label className="label">API Port</label> <label className="label">API Port</label>
<input className="input" type="number" value={manual.api_port} onChange={(e) => setManual((m) => ({ ...m, api_port: e.target.value }))} /> <input className="input" type="number" value={manual.api_port} disabled={running} onChange={(e) => setManual((m) => ({ ...m, api_port: e.target.value }))} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="label">API Password *</label> <label className="label">API Password *</label>
<input className="input" type="password" value={manual.api_password} onChange={(e) => setManual((m) => ({ ...m, api_password: e.target.value }))} /> <input className="input" type="password" value={manual.api_password} disabled={running} onChange={(e) => setManual((m) => ({ ...m, api_password: e.target.value }))} />
</div> </div>
<div> <div>
<label className="label">SSH Username</label> <label className="label">SSH Username</label>
<input className="input" value={manual.ssh_username} onChange={(e) => setManual((m) => ({ ...m, ssh_username: e.target.value }))} /> <input className="input" value={manual.ssh_username} disabled={running} onChange={(e) => setManual((m) => ({ ...m, ssh_username: e.target.value }))} />
</div> </div>
<div> <div>
<label className="label">SSH Port</label> <label className="label">SSH Port</label>
<input className="input" type="number" value={manual.ssh_port} onChange={(e) => setManual((m) => ({ ...m, ssh_port: e.target.value }))} /> <input className="input" type="number" value={manual.ssh_port} disabled={running} onChange={(e) => setManual((m) => ({ ...m, ssh_port: e.target.value }))} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="label">SSH Password</label> <label className="label">SSH Password</label>
<input className="input" type="password" value={manual.ssh_password} onChange={(e) => setManual((m) => ({ ...m, ssh_password: e.target.value }))} /> <input className="input" type="password" value={manual.ssh_password} disabled={running} onChange={(e) => setManual((m) => ({ ...m, ssh_password: e.target.value }))} />
</div> </div>
</div> </div>
)} )}
{jobId && running && (
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono break-all">Job: {jobId}</p>
)}
{error && ( {error && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> <div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" /> <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
@@ -226,7 +278,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="button" type="button"
onClick={() => { cancelRef.current = true; }} onClick={() => void handleCancel()}
className="btn-secondary text-xs py-1 px-2" className="btn-secondary text-xs py-1 px-2"
> >
Stop / Cancel Stop / Cancel
@@ -279,7 +331,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
<button type="button" onClick={onClose} className="btn-secondary">Close</button> <button type="button" onClick={onClose} className="btn-secondary">Close</button>
<button <button
type="button" type="button"
onClick={handleRun} onClick={() => void handleRun()}
disabled={running} disabled={running}
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
> >
@@ -9,6 +9,7 @@ import {
type CredentialPreset, type CredentialPreset,
type CredentialPresetInput, type CredentialPresetInput,
} from '../../services/api'; } from '../../services/api';
import { parsePort } from '../../utils/parsePort';
interface FormState { interface FormState {
name: string; name: string;
@@ -20,6 +21,7 @@ interface FormState {
ssh_port: string; ssh_port: string;
notes: string; notes: string;
clear_ssh_password: boolean; clear_ssh_password: boolean;
allow_operator_use: boolean;
} }
const EMPTY_FORM: FormState = { const EMPTY_FORM: FormState = {
@@ -32,20 +34,22 @@ const EMPTY_FORM: FormState = {
ssh_port: '22', ssh_port: '22',
notes: '', notes: '',
clear_ssh_password: false, clear_ssh_password: false,
allow_operator_use: true,
}; };
function toInput(f: FormState, isEdit: boolean): CredentialPresetInput { function toInput(f: FormState, isEdit: boolean): CredentialPresetInput {
const input: CredentialPresetInput = { const input: CredentialPresetInput = {
name: f.name || undefined, name: f.name || undefined,
api_username: f.api_username || undefined, api_username: f.api_username || undefined,
api_port: f.api_port ? parseInt(f.api_port, 10) : null, api_port: f.api_port ? parsePort(f.api_port, 8728) : null,
ssh_username: f.ssh_username || null, ssh_username: f.ssh_username || null,
ssh_port: f.ssh_port ? parseInt(f.ssh_port, 10) : null, ssh_port: f.ssh_port ? parsePort(f.ssh_port, 22) : null,
notes: f.notes || null, notes: f.notes || null,
}; };
if (f.api_password) input.api_password = f.api_password; if (f.api_password) input.api_password = f.api_password;
if (f.ssh_password) input.ssh_password = f.ssh_password; if (f.ssh_password) input.ssh_password = f.ssh_password;
else if (isEdit && f.clear_ssh_password) input.clear_ssh_password = true; else if (isEdit && f.clear_ssh_password) input.clear_ssh_password = true;
input.allow_operator_use = f.allow_operator_use;
return input; return input;
} }
@@ -80,6 +84,7 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
ssh_port: p.ssh_port != null ? String(p.ssh_port) : '', ssh_port: p.ssh_port != null ? String(p.ssh_port) : '',
notes: p.notes ?? '', notes: p.notes ?? '',
clear_ssh_password: false, clear_ssh_password: false,
allow_operator_use: p.allow_operator_use !== false,
}); });
setError(''); setError('');
setModal({ mode: 'edit', id: p.id }); setModal({ mode: 'edit', id: p.id });
@@ -159,6 +164,11 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
<th className="table-header px-4 py-2.5 text-left">API</th> <th className="table-header px-4 py-2.5 text-left">API</th>
<th className="table-header px-4 py-2.5 text-left">SSH</th> <th className="table-header px-4 py-2.5 text-left">SSH</th>
<th className="table-header px-4 py-2.5 text-left">Notes</th> <th className="table-header px-4 py-2.5 text-left">Notes</th>
{isAdmin && (
<th className="table-header px-4 py-2.5 text-left whitespace-nowrap">
Operator access
</th>
)}
<th className="table-header px-4 py-2.5 w-24" /> <th className="table-header px-4 py-2.5 w-24" />
</tr> </tr>
</thead> </thead>
@@ -190,6 +200,11 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 max-w-xs truncate"> <td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 max-w-xs truncate">
{p.notes || <span className="text-gray-300 dark:text-slate-600">—</span>} {p.notes || <span className="text-gray-300 dark:text-slate-600">—</span>}
</td> </td>
{isAdmin && (
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
{p.allow_operator_use !== false ? 'All users' : 'Admins only'}
</td>
)}
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{isAdmin && ( {isAdmin && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -351,6 +366,20 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
/> />
</div> </div>
<label className="flex items-start gap-2 text-sm text-gray-600 dark:text-slate-400 select-none">
<input
type="checkbox"
className="rounded mt-0.5"
checked={form.allow_operator_use}
onChange={(e) => set('allow_operator_use', e.target.checked)}
/>
<span>
Allow operator accounts to select this preset when adding or
updating devices. Uncheck for admin-only presets (for example
shared break-glass credentials).
</span>
</label>
{error && ( {error && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> <div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" /> <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
+39
View File
@@ -88,10 +88,46 @@ export interface DuplicateSerialError {
}; };
} }
export interface BulkAddDeviceItem {
name?: string;
ip_address?: string;
device_type?: string;
notes?: string;
credential_preset_id?: number;
api_username?: string;
api_password?: string;
api_port?: number;
ssh_username?: string;
ssh_password?: string;
ssh_port?: number;
}
export interface BulkAddJobResponse {
job_id: string;
total: number;
}
export interface BulkAddJobStatus {
job_id: string;
status?: string;
total?: number;
processed?: number;
current_name?: string | null;
results?: { ip: string; identity: string; ok: boolean; message: string }[];
error?: string;
[key: string]: unknown;
}
export const devicesApi = { export const devicesApi = {
list: () => api.get<Device[]>('/devices'), list: () => api.get<Device[]>('/devices'),
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'), discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
get: (id: number) => api.get<Device>(`/devices/${id}`), get: (id: number) => api.get<Device>(`/devices/${id}`),
bulkAddEnqueue: (items: BulkAddDeviceItem[]) =>
api.post<BulkAddJobResponse>('/devices/bulk-add/jobs', { items }),
bulkAddStatus: (jobId: string) =>
api.get<BulkAddJobStatus>(`/devices/bulk-add/jobs/${encodeURIComponent(jobId)}`),
bulkAddCancel: (jobId: string) =>
api.post<{ message: string }>(`/devices/bulk-add/jobs/${encodeURIComponent(jobId)}/cancel`),
create: ( create: (
data: (Partial<Device> & { data: (Partial<Device> & {
api_password?: string; api_password?: string;
@@ -197,6 +233,8 @@ export interface CredentialPreset {
ssh_username: string | null; ssh_username: string | null;
ssh_port: number | null; ssh_port: number | null;
notes: string | null; notes: string | null;
/** When false, only admins may select this preset in Add Device flows. */
allow_operator_use: boolean;
has_api_password: boolean; has_api_password: boolean;
has_ssh_password: boolean; has_ssh_password: boolean;
created_at: string; created_at: string;
@@ -213,6 +251,7 @@ export interface CredentialPresetInput {
ssh_port?: number | null; ssh_port?: number | null;
notes?: string | null; notes?: string | null;
clear_ssh_password?: boolean; clear_ssh_password?: boolean;
allow_operator_use?: boolean;
} }
export const credentialPresetsApi = { export const credentialPresetsApi = {
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { parsePort } from './parsePort';
describe('parsePort', () => {
it('uses fallback for empty', () => {
expect(parsePort('', 8728)).toBe(8728);
expect(parsePort(null, 22)).toBe(22);
});
it('parses valid', () => {
expect(parsePort('8729', 8728)).toBe(8729);
expect(parsePort(22, 8728)).toBe(22);
});
it('rejects invalid', () => {
expect(parsePort('x', 8728)).toBe(8728);
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Parse API/SSH port from form fields (string or number). Invalid values use fallback.
*/
export function parsePort(value: unknown, fallback: number): number {
if (value == null || value === '') return fallback;
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
return Number.isFinite(n) ? n : fallback;
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});