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:
Generated
+464
-1
@@ -40,7 +40,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.0.11"
|
||||
"vite": "^5.0.11",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1465,6 +1466,17 @@
|
||||
"@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": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
@@ -1562,6 +1574,13 @@
|
||||
"@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": {
|
||||
"version": "1.0.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
@@ -2211,6 +2345,16 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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_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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -2472,6 +2626,23 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2489,6 +2660,16 @@
|
||||
"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": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -2906,6 +3087,16 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3153,6 +3344,13 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -3510,6 +3708,16 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -3526,6 +3734,16 @@
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4700,6 +4918,13 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -5091,6 +5326,23 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5927,6 +6179,13 @@
|
||||
"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": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
@@ -5965,6 +6224,20 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -6216,6 +6509,20 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6264,6 +6571,36 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -6689,6 +7135,23 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.1",
|
||||
@@ -42,6 +43,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.0.11"
|
||||
"vite": "^5.0.11",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type DuplicateSerialError,
|
||||
} from '../../services/api';
|
||||
import ConfirmDuplicateModal from './ConfirmDuplicateModal';
|
||||
import { parsePort } from '../../utils/parsePort';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -62,10 +63,10 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
return {
|
||||
name: form.name,
|
||||
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_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_password: form.ssh_password || undefined,
|
||||
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 { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { parsePort } from '../../utils/parsePort';
|
||||
import type { Device, DeviceType, IpAddress } from '../../types';
|
||||
|
||||
interface Props {
|
||||
@@ -90,9 +91,9 @@ export default function EditDeviceModal({ device, onClose, onSuccess }: Props) {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
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,
|
||||
ssh_port: parseInt(form.ssh_port, 10) || 22,
|
||||
ssh_port: parsePort(form.ssh_port, 22),
|
||||
ssh_username: form.ssh_username || null,
|
||||
device_type: form.device_type as DeviceType,
|
||||
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 { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
credentialPresetsApi,
|
||||
devicesApi,
|
||||
type BulkAddDeviceItem,
|
||||
type DiscoveredDevice,
|
||||
type DuplicateSerialError,
|
||||
} from '../../services/api';
|
||||
import type { DeviceType } from '../../types';
|
||||
import { parsePort } from '../../utils/parsePort';
|
||||
|
||||
interface Props {
|
||||
discoveredDevices: DiscoveredDevice[];
|
||||
@@ -17,11 +18,6 @@ interface Props {
|
||||
|
||||
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) {
|
||||
const [mode, setMode] = useState<'preset' | 'manual'>('preset');
|
||||
const [presetId, setPresetId] = useState<number | null>(null);
|
||||
@@ -37,13 +33,14 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultItem[] | null>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentName: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const { data: presets = [] } = useQuery({
|
||||
queryKey: ['credential-presets'],
|
||||
@@ -56,11 +53,75 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
|
||||
[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 () => {
|
||||
setError('');
|
||||
setCancelled(false);
|
||||
cancelRef.current = false;
|
||||
setResults(null);
|
||||
setJobId(null);
|
||||
if (!targets.length) {
|
||||
setError('No discovered devices with a usable IP address.');
|
||||
return;
|
||||
@@ -76,52 +137,36 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
|
||||
|
||||
setRunning(true);
|
||||
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)
|
||||
? `Duplicate serial mismatch: candidate ${data.candidate.serial_number}, existing ${data.existing_device.serial_number}. Review manually before combining.`
|
||||
: ((data as { message?: string; error?: string } | undefined)?.message ||
|
||||
(typeof (data as { error?: unknown } | undefined)?.error === 'string'
|
||||
? String((data as { error?: string }).error)
|
||||
: '') ||
|
||||
'Failed');
|
||||
out.push({ ip: d.address, identity: name, ok: false, message: msg });
|
||||
setResults([...out]);
|
||||
try {
|
||||
const items = buildItems();
|
||||
const { data } = await devicesApi.bulkAddEnqueue(items);
|
||||
const id = data.job_id;
|
||||
setJobId(id);
|
||||
setProgress({ current: 0, total: data.total, currentName: '' });
|
||||
await pollOnce(id);
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
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);
|
||||
setRunning(false);
|
||||
setProgress((p) => ({ ...p, currentName: '' }));
|
||||
if (cancelRef.current) setCancelled(true);
|
||||
setCancelled(true);
|
||||
};
|
||||
|
||||
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">
|
||||
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||
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>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
Use Preset
|
||||
@@ -153,6 +200,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
|
||||
<button
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
Enter Manually
|
||||
@@ -162,7 +210,7 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu
|
||||
{mode === 'preset' ? (
|
||||
<div>
|
||||
<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>
|
||||
{presets.map((p) => (
|
||||
<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>
|
||||
<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>
|
||||
<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 className="col-span-2">
|
||||
<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>
|
||||
<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>
|
||||
<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 className="col-span-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{jobId && running && (
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono break-all">Job: {jobId}</p>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { cancelRef.current = true; }}
|
||||
onClick={() => void handleCancel()}
|
||||
className="btn-secondary text-xs py-1 px-2"
|
||||
>
|
||||
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={handleRun}
|
||||
onClick={() => void handleRun()}
|
||||
disabled={running}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type CredentialPreset,
|
||||
type CredentialPresetInput,
|
||||
} from '../../services/api';
|
||||
import { parsePort } from '../../utils/parsePort';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -20,6 +21,7 @@ interface FormState {
|
||||
ssh_port: string;
|
||||
notes: string;
|
||||
clear_ssh_password: boolean;
|
||||
allow_operator_use: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
@@ -32,20 +34,22 @@ const EMPTY_FORM: FormState = {
|
||||
ssh_port: '22',
|
||||
notes: '',
|
||||
clear_ssh_password: false,
|
||||
allow_operator_use: true,
|
||||
};
|
||||
|
||||
function toInput(f: FormState, isEdit: boolean): CredentialPresetInput {
|
||||
const input: CredentialPresetInput = {
|
||||
name: f.name || 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_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,
|
||||
};
|
||||
if (f.api_password) input.api_password = f.api_password;
|
||||
if (f.ssh_password) input.ssh_password = f.ssh_password;
|
||||
else if (isEdit && f.clear_ssh_password) input.clear_ssh_password = true;
|
||||
input.allow_operator_use = f.allow_operator_use;
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -80,6 +84,7 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
|
||||
ssh_port: p.ssh_port != null ? String(p.ssh_port) : '',
|
||||
notes: p.notes ?? '',
|
||||
clear_ssh_password: false,
|
||||
allow_operator_use: p.allow_operator_use !== false,
|
||||
});
|
||||
setError('');
|
||||
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">SSH</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" />
|
||||
</tr>
|
||||
</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">
|
||||
{p.notes || <span className="text-gray-300 dark:text-slate-600">—</span>}
|
||||
</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">
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -351,6 +366,20 @@ export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolea
|
||||
/>
|
||||
</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 && (
|
||||
<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" />
|
||||
|
||||
@@ -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 = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
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: (
|
||||
data: (Partial<Device> & {
|
||||
api_password?: string;
|
||||
@@ -197,6 +233,8 @@ export interface CredentialPreset {
|
||||
ssh_username: string | null;
|
||||
ssh_port: number | 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_ssh_password: boolean;
|
||||
created_at: string;
|
||||
@@ -213,6 +251,7 @@ export interface CredentialPresetInput {
|
||||
ssh_port?: number | null;
|
||||
notes?: string | null;
|
||||
clear_ssh_password?: boolean;
|
||||
allow_operator_use?: boolean;
|
||||
}
|
||||
|
||||
export const credentialPresetsApi = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user