diff --git a/package-lock.json b/package-lock.json index bfb0278..3208222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pve-scripts-local", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-query": "^5.90.3", @@ -1212,6 +1213,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2006,6 +2045,61 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2021,6 +2115,324 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -2039,6 +2451,133 @@ } } }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -3104,7 +3643,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4003,6 +4542,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4959,6 +5510,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6054,6 +6611,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8756,6 +9322,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.6", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", @@ -10554,6 +11189,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index ec3d8d7..3e73373 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-query": "^5.90.3", diff --git a/server.js b/server.js index ab89406..ec5dcb2 100644 --- a/server.js +++ b/server.js @@ -131,6 +131,55 @@ class ScriptExecutionHandler { return null; } + /** + * Parse Web UI URL from terminal output + * @param {string} output - Terminal output to parse + * @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise + */ + parseWebUIUrl(output) { + // First, strip ANSI color codes to make pattern matching more reliable + const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); + + // Look for URL patterns with any valid IP address (private or public) + const patterns = [ + // HTTP/HTTPS URLs with IP and port + /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi, + // URLs without explicit port (assume default ports) + /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi, + // URLs with trailing slash and port + /https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi, + // URLs with just IP and port (no protocol) + /(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi, + // URLs with just IP (no protocol, no port) + /(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi, + ]; + + // Try patterns on both original and cleaned output + const outputsToTry = [output, cleanOutput]; + + for (const testOutput of outputsToTry) { + for (const pattern of patterns) { + const matches = [...testOutput.matchAll(pattern)]; + for (const match of matches) { + if (match[1]) { + const ip = match[1]; + const port = match[2] || (match[0].startsWith('https') ? '443' : '80'); + + // Validate IP address format + if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) { + return { + ip: ip, + port: parseInt(port, 10) + }; + } + } + } + } + } + + return null; + } + /** * Create installation record * @param {string} scriptName - Name of the script @@ -364,6 +413,18 @@ class ScriptExecutionHandler { this.updateInstallationRecord(installationId, { container_id: containerId }); } + // Parse for Web UI URL + const webUIUrl = this.parseWebUIUrl(output); + if (webUIUrl && installationId) { + const { ip, port } = webUIUrl; + if (ip && port) { + this.updateInstallationRecord(installationId, { + web_ui_ip: ip, + web_ui_port: port + }); + } + } + this.sendMessage(ws, { type: 'output', data: output, @@ -447,6 +508,18 @@ class ScriptExecutionHandler { this.updateInstallationRecord(installationId, { container_id: containerId }); } + // Parse for Web UI URL + const webUIUrl = this.parseWebUIUrl(data); + if (webUIUrl && installationId) { + const { ip, port } = webUIUrl; + if (ip && port) { + this.updateInstallationRecord(installationId, { + web_ui_ip: ip, + web_ui_port: port + }); + } + } + // Handle data output this.sendMessage(ws, { type: 'output', diff --git a/src/app/_components/ContextualHelpIcon.tsx b/src/app/_components/ContextualHelpIcon.tsx index 4a93fb2..7d459e1 100644 --- a/src/app/_components/ContextualHelpIcon.tsx +++ b/src/app/_components/ContextualHelpIcon.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { HelpModal } from './HelpModal'; -import { Button } from './ui/button'; import { HelpCircle } from 'lucide-react'; interface ContextualHelpIconProps { @@ -26,15 +25,13 @@ export function ContextualHelpIcon({ return ( <> - + Installation Status: Track success, failure, or in-progress installations
  • Server Association: Know which server each script is installed on
  • Container ID: Link scripts to specific LXC containers
  • +
  • Web UI Access: Track and access Web UI IP addresses and ports
  • Execution Logs: View output and logs from script installations
  • Filtering: Filter by server, status, or search terms
  • @@ -335,8 +336,47 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' +
    +

    Web UI Access

    +

    + Automatically detect and access Web UI interfaces for your installed scripts. +

    + +
    +

    💡 How it works:

    +
      +
    • • Scripts automatically detect URLs like http://10.10.10.1:3000 during installation
    • +
    • • Re-detect button runs hostname -I inside the container via SSH
    • +
    • • Port defaults to 80, but uses script metadata when available
    • +
    • • Web UI buttons are disabled when container is stopped
    • +
    +
    +
    + +
    +

    Actions Dropdown

    +

    + Clean interface with all actions organized in a dropdown menu. +

    + +
    +
    -

    Container Control (NEW)

    +

    Container Control

    Directly control LXC containers from the installed scripts page via SSH.

    diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 12f1477..8aca574 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -9,6 +9,13 @@ import { ScriptInstallationCard } from './ScriptInstallationCard'; import { ConfirmationModal } from './ConfirmationModal'; import { ErrorModal } from './ErrorModal'; import { getContrastColor } from '../../lib/colorUtils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from './ui/dropdown-menu'; interface InstalledScript { id: number; @@ -30,6 +37,8 @@ interface InstalledScript { output_log: string | null; execution_mode: 'local' | 'ssh'; container_status?: 'running' | 'stopped' | 'unknown'; + web_ui_ip: string | null; + web_ui_port: number | null; } export function InstalledScriptsTab() { @@ -41,7 +50,7 @@ export function InstalledScriptsTab() { const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [editingScriptId, setEditingScriptId] = useState(null); - const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' }); + const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); const [showAddForm, setShowAddForm] = useState(false); const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' }); const [showAutoDetectForm, setShowAutoDetectForm] = useState(false); @@ -92,7 +101,7 @@ export function InstalledScriptsTab() { onSuccess: () => { void refetchScripts(); setEditingScriptId(null); - setEditFormData({ script_name: '', container_id: '' }); + setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); }, onError: (error) => { alert(`Error updating script: ${error.message}`); @@ -206,7 +215,30 @@ export function InstalledScriptsTab() { message: error.message ?? 'Cleanup failed. Please try again.' }); // Clear status after 5 seconds - setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000); + setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000); + } + }); + + // Auto-detect Web UI mutation + const autoDetectWebUIMutation = api.installedScripts.autoDetectWebUI.useMutation({ + onSuccess: (data) => { + console.log('✅ Auto-detect WebUI success:', data); + void refetchScripts(); + setAutoDetectStatus({ + type: 'success', + message: data.message ?? 'Web UI IP detected successfully!' + }); + // Clear status after 5 seconds + setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); + }, + onError: (error) => { + console.error('❌ Auto-detect Web UI error:', error); + setAutoDetectStatus({ + type: 'error', + message: error.message ?? 'Auto-detect failed. Please try again.' + }); + // Clear status after 5 seconds + setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000); } }); @@ -648,13 +680,15 @@ export function InstalledScriptsTab() { setEditingScriptId(script.id); setEditFormData({ script_name: script.script_name, - container_id: script.container_id ?? '' + container_id: script.container_id ?? '', + web_ui_ip: script.web_ui_ip ?? '', + web_ui_port: script.web_ui_port?.toString() ?? '' }); }; const handleCancelEdit = () => { setEditingScriptId(null); - setEditFormData({ script_name: '', container_id: '' }); + setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); }; const handleSaveEdit = () => { @@ -673,11 +707,13 @@ export function InstalledScriptsTab() { id: editingScriptId, script_name: editFormData.script_name.trim(), container_id: editFormData.container_id.trim() || undefined, + web_ui_ip: editFormData.web_ui_ip.trim() || undefined, + web_ui_port: editFormData.web_ui_port.trim() ? parseInt(editFormData.web_ui_port, 10) : undefined, }); } }; - const handleInputChange = (field: 'script_name' | 'container_id', value: string) => { + const handleInputChange = (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => { setEditFormData(prev => ({ ...prev, [field]: value @@ -739,6 +775,54 @@ export function InstalledScriptsTab() { } }; + const handleAutoDetectWebUI = (script: InstalledScript) => { + console.log('🔍 Auto-detect WebUI clicked for script:', script); + console.log('Script validation:', { + hasContainerId: !!script.container_id, + isSSHMode: script.execution_mode === 'ssh', + containerId: script.container_id, + executionMode: script.execution_mode + }); + + if (!script.container_id || script.execution_mode !== 'ssh') { + console.log('❌ Auto-detect validation failed'); + setErrorModal({ + isOpen: true, + title: 'Auto-Detect Failed', + message: 'Auto-detect only works for SSH mode scripts with container ID', + details: 'This script does not have a valid container ID or is not in SSH mode.' + }); + return; + } + + console.log('✅ Calling autoDetectWebUIMutation.mutate with id:', script.id); + autoDetectWebUIMutation.mutate({ id: script.id }); + }; + + const handleOpenWebUI = (script: InstalledScript) => { + if (!script.web_ui_ip) { + setErrorModal({ + isOpen: true, + title: 'Web UI Access Failed', + message: 'No IP address configured for this script', + details: 'Please set the Web UI IP address before opening the interface.' + }); + return; + } + + const port = script.web_ui_port ?? 80; + const url = `http://${script.web_ui_ip}:${port}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + // Helper function to check if a script has any actions available + const hasActions = (script: InstalledScript) => { + if (script.container_id && script.execution_mode === 'ssh') return true; + if (script.web_ui_ip != null) return true; + if (!script.container_id || script.execution_mode !== 'ssh') return true; + return false; + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); @@ -1111,6 +1195,9 @@ export function InstalledScriptsTab() { onStartStop={(action) => handleStartStop(script, action)} onDestroy={() => handleDestroy(script)} isControlling={controllingScriptId === script.id} + onOpenWebUI={() => handleOpenWebUI(script)} + onAutoDetectWebUI={() => handleAutoDetectWebUI(script)} + isAutoDetecting={autoDetectWebUIMutation.isPending} /> ))}
    @@ -1146,6 +1233,9 @@ export function InstalledScriptsTab() { )} + + Web UI + handleSort('server_name')} @@ -1254,6 +1344,62 @@ export function InstalledScriptsTab() { ) )} + + {editingScriptId === script.id ? ( +
    + handleInputChange('web_ui_ip', e.target.value)} + className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="IP" + /> + : + handleInputChange('web_ui_port', e.target.value)} + className="w-20 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="Port" + /> +
    + ) : ( + script.web_ui_ip ? ( +
    + + {script.container_id && script.execution_mode === 'ssh' && ( + + )} +
    + ) : ( +
    + - + {script.container_id && script.execution_mode === 'ssh' && ( + + )} +
    + ) + )} + Edit - {script.container_id && ( - - )} - {/* Shell button - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - - )} - {/* Container Control Buttons - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - <> - - - - )} - {/* Fallback to old Delete button for non-SSH scripts */} - {(!script.container_id || script.execution_mode !== 'ssh') && ( - + {hasActions(script) && ( + + + + + + {script.container_id && ( + handleUpdateScript(script)} + disabled={containerStatuses.get(script.id) === 'stopped'} + className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20" + > + Update + + )} + {script.container_id && script.execution_mode === 'ssh' && ( + handleOpenShell(script)} + disabled={containerStatuses.get(script.id) === 'stopped'} + className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20" + > + Shell + + )} + {script.web_ui_ip && ( + handleOpenWebUI(script)} + disabled={containerStatuses.get(script.id) === 'stopped'} + className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20" + > + Open UI + + )} + {script.container_id && script.execution_mode === 'ssh' && ( + <> + + handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')} + disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'} + className={(containerStatuses.get(script.id) ?? 'unknown') === 'running' + ? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20" + : "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20" + } + > + {controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'} + + handleDestroy(script)} + disabled={controllingScriptId === script.id} + className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20" + > + {controllingScriptId === script.id ? 'Working...' : 'Destroy'} + + + )} + {(!script.container_id || script.execution_mode !== 'ssh') && ( + <> + + handleDeleteScript(Number(script.id))} + disabled={deleteScriptMutation.isPending} + className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20" + > + {deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'} + + + )} + + )} )} diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 37350d5..864d97e 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -3,6 +3,13 @@ import { Button } from './ui/button'; import { StatusBadge } from './Badge'; import { getContrastColor } from '../../lib/colorUtils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from './ui/dropdown-menu'; interface InstalledScript { id: number; @@ -24,13 +31,15 @@ interface InstalledScript { output_log: string | null; execution_mode: 'local' | 'ssh'; container_status?: 'running' | 'stopped' | 'unknown'; + web_ui_ip: string | null; + web_ui_port: number | null; } interface ScriptInstallationCardProps { script: InstalledScript; isEditing: boolean; - editFormData: { script_name: string; container_id: string }; - onInputChange: (field: 'script_name' | 'container_id', value: string) => void; + editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }; + onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void; onEdit: () => void; onSave: () => void; onCancel: () => void; @@ -44,6 +53,10 @@ interface ScriptInstallationCardProps { onStartStop: (action: 'start' | 'stop') => void; onDestroy: () => void; isControlling: boolean; + // Web UI props + onOpenWebUI: () => void; + onAutoDetectWebUI: () => void; + isAutoDetecting: boolean; } export function ScriptInstallationCard({ @@ -62,12 +75,23 @@ export function ScriptInstallationCard({ containerStatus, onStartStop, onDestroy, - isControlling + isControlling, + onOpenWebUI, + onAutoDetectWebUI, + isAutoDetecting }: ScriptInstallationCardProps) { const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); }; + // Helper function to check if a script has any actions available + const hasActions = (script: InstalledScript) => { + if (script.container_id && script.execution_mode === 'ssh') return true; + if (script.web_ui_ip != null) return true; + if (!script.container_id || script.execution_mode !== 'ssh') return true; + return false; + }; + return (
    + {/* Web UI */} +
    +
    IP:PORT
    + {isEditing ? ( +
    + onInputChange('web_ui_ip', e.target.value)} + className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="IP" + /> + : + onInputChange('web_ui_port', e.target.value)} + className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="Port" + /> +
    + ) : ( +
    + {script.web_ui_ip ? ( +
    + + {script.container_id && script.execution_mode === 'ssh' && ( + + )} +
    + ) : ( +
    + - + {script.container_id && script.execution_mode === 'ssh' && ( + + )} +
    + )} +
    + )} +
    + {/* Server */}
    Server
    @@ -198,63 +286,81 @@ export function ScriptInstallationCard({ > Edit - {script.container_id && ( - - )} - {/* Shell button - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - - )} - {/* Container Control Buttons - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - <> - - - - )} - {/* Fallback to old Delete button for non-SSH scripts */} - {(!script.container_id || script.execution_mode !== 'ssh') && ( - + {hasActions(script) && ( + + + + + + {script.container_id && ( + + Update + + )} + {script.container_id && script.execution_mode === 'ssh' && ( + + Shell + + )} + {script.web_ui_ip && ( + + Open UI + + )} + {script.container_id && script.execution_mode === 'ssh' && ( + <> + + onStartStop(containerStatus === 'running' ? 'stop' : 'start')} + disabled={isControlling || containerStatus === 'unknown'} + className={containerStatus === 'running' + ? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20" + : "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20" + } + > + {isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'} + + + {isControlling ? 'Working...' : 'Destroy'} + + + )} + {(!script.container_id || script.execution_mode !== 'ssh') && ( + <> + + + {isDeleting ? 'Deleting...' : 'Delete'} + + + )} + + )} )} diff --git a/src/app/_components/ui/button.tsx b/src/app/_components/ui/button.tsx index 8fb2a4b..78b54f4 100644 --- a/src/app/_components/ui/button.tsx +++ b/src/app/_components/ui/button.tsx @@ -37,6 +37,10 @@ const buttonVariants = cva( // Dark theme action button variants edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md", update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md", + shell: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md", + openui: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md", + start: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md", + stop: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md", delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100", save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100", cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md", diff --git a/src/app/_components/ui/dropdown-menu.tsx b/src/app/_components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..54d818d --- /dev/null +++ b/src/app/_components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import { cn } from "~/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index c6c0b1a..3a25704 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -83,7 +83,9 @@ export const installedScriptsRouter = createTRPCRouter({ server_id: z.number().optional(), execution_mode: z.enum(['local', 'ssh']), status: z.enum(['in_progress', 'success', 'failed']), - output_log: z.string().optional() + output_log: z.string().optional(), + web_ui_ip: z.string().optional(), + web_ui_port: z.number().optional() })) .mutation(async ({ input }) => { try { @@ -110,7 +112,9 @@ export const installedScriptsRouter = createTRPCRouter({ script_name: z.string().optional(), container_id: z.string().optional(), status: z.enum(['in_progress', 'success', 'failed']).optional(), - output_log: z.string().optional() + output_log: z.string().optional(), + web_ui_ip: z.string().optional(), + web_ui_port: z.number().optional() })) .mutation(async ({ input }) => { try { @@ -972,5 +976,177 @@ export const installedScriptsRouter = createTRPCRouter({ error: error instanceof Error ? error.message : 'Failed to destroy container' }; } + }), + + // Auto-detect Web UI IP and port + autoDetectWebUI: publicProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + try { + console.log('🔍 Auto-detect WebUI called with id:', input.id); + const db = getDatabase(); + const script = db.getInstalledScriptById(input.id); + + if (!script) { + console.log('❌ Script not found for id:', input.id); + return { + success: false, + error: 'Script not found' + }; + } + + const scriptData = script as any; + console.log('📋 Script data:', { + id: scriptData.id, + execution_mode: scriptData.execution_mode, + server_id: scriptData.server_id, + container_id: scriptData.container_id + }); + + // Only works for SSH mode scripts with container_id + if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) { + console.log('❌ Validation failed - not SSH mode or missing server/container ID'); + return { + success: false, + error: 'Auto-detect only works for SSH mode scripts with container ID' + }; + } + + // Get server info + const server = db.getServerById(Number(scriptData.server_id)); + if (!server) { + console.log('❌ Server not found for id:', scriptData.server_id); + return { + success: false, + error: 'Server not found' + }; + } + + console.log('🖥️ Server found:', { id: (server as any).id, name: (server as any).name, ip: (server as any).ip }); + + // Import SSH services + const { default: SSHService } = await import('~/server/ssh-service'); + const { default: SSHExecutionService } = await import('~/server/ssh-execution-service'); + const sshService = new SSHService(); + const sshExecutionService = new SSHExecutionService(); + + // Test SSH connection first + console.log('🔌 Testing SSH connection...'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const connectionTest = await sshService.testSSHConnection(server as any); + if (!(connectionTest as any).success) { + console.log('❌ SSH connection failed:', (connectionTest as any).error); + return { + success: false, + error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}` + }; + } + + console.log('✅ SSH connection successful'); + + // Run hostname -I inside the container + // Use pct exec instead of pct enter -c (which doesn't exist) + const hostnameCommand = `pct exec ${scriptData.container_id} -- hostname -I`; + console.log('🚀 Running command:', hostnameCommand); + let commandOutput = ''; + + await new Promise((resolve, reject) => { + void sshExecutionService.executeCommand( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + server as any, + hostnameCommand, + (data: string) => { + console.log('📤 Command output chunk:', data); + commandOutput += data; + }, + (error: string) => { + console.log('❌ Command error:', error); + reject(new Error(error)); + }, + (exitCode: number) => { + console.log('🏁 Command finished with exit code:', exitCode); + if (exitCode !== 0) { + reject(new Error(`Command failed with exit code ${exitCode}`)); + } else { + resolve(); + } + } + ); + }); + + // Parse output to get first IP address + console.log('📝 Full command output:', commandOutput); + const ips = commandOutput.trim().split(/\s+/); + const detectedIp = ips[0]; + console.log('🔍 Parsed IPs:', ips); + console.log('🎯 Detected IP:', detectedIp); + + if (!detectedIp || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(detectedIp)) { + console.log('❌ Invalid IP address detected:', detectedIp); + return { + success: false, + error: 'Could not detect valid IP address from container' + }; + } + + // Get the script's interface_port from metadata (prioritize metadata over existing database values) + let detectedPort = 80; // Default fallback + + try { + // Import localScriptsService to get script metadata + const { localScriptsService } = await import('~/server/services/localScripts'); + + // Get all scripts and find the one matching our script name + const allScripts = await localScriptsService.getAllScripts(); + + // Extract script slug from script_name (remove .sh extension) + const scriptSlug = scriptData.script_name.replace(/\.sh$/, ''); + console.log('🔍 Looking for script with slug:', scriptSlug); + + const scriptMetadata = allScripts.find(script => script.slug === scriptSlug); + + if (scriptMetadata?.interface_port) { + detectedPort = scriptMetadata.interface_port; + console.log('📋 Found interface_port in metadata:', detectedPort); + } else { + console.log('📋 No interface_port found in metadata, using default port 80'); + detectedPort = 80; // Default to port 80 if no metadata port found + } + } catch (error) { + console.log('⚠️ Error getting script metadata, using default port 80:', error); + detectedPort = 80; // Default to port 80 if metadata lookup fails + } + + console.log('🎯 Final detected port:', detectedPort); + + // Update the database with detected IP and port + console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort); + const updateResult = db.updateInstalledScript(input.id, { + web_ui_ip: detectedIp, + web_ui_port: detectedPort + }); + + if (updateResult.changes === 0) { + console.log('❌ Database update failed - no changes made'); + return { + success: false, + error: 'Failed to update database with detected IP' + }; + } + + console.log('✅ Successfully updated database'); + return { + success: true, + message: `Successfully detected IP: ${detectedIp}:${detectedPort}`, + detectedIp, + detectedPort: detectedPort + }; + } catch (error) { + console.error('Error in autoDetectWebUI:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP' + }; + } }) }); diff --git a/src/server/database.js b/src/server/database.js index ff6ceb4..aa9fe46 100644 --- a/src/server/database.js +++ b/src/server/database.js @@ -78,6 +78,24 @@ class DatabaseService { UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL `); + // Migration: Add web_ui_ip column to existing installed_scripts table + try { + this.db.exec(` + ALTER TABLE installed_scripts ADD COLUMN web_ui_ip TEXT + `); + } catch (e) { + // Column already exists, ignore error + } + + // Migration: Add web_ui_port column to existing installed_scripts table + try { + this.db.exec(` + ALTER TABLE installed_scripts ADD COLUMN web_ui_port INTEGER + `); + } catch (e) { + // Column already exists, ignore error + } + // Create installed_scripts table if it doesn't exist this.db.exec(` CREATE TABLE IF NOT EXISTS installed_scripts ( @@ -90,7 +108,9 @@ class DatabaseService { installation_date DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')), output_log TEXT, - FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE + web_ui_ip TEXT, + web_ui_port INTEGER, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL ) `); @@ -162,14 +182,16 @@ class DatabaseService { * @param {string} scriptData.execution_mode * @param {string} scriptData.status * @param {string} [scriptData.output_log] + * @param {string} [scriptData.web_ui_ip] + * @param {number} [scriptData.web_ui_port] */ createInstalledScript(scriptData) { - const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData; + const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData; const stmt = this.db.prepare(` - INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); - return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null); + return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null, web_ui_ip || null, web_ui_port || null); } getAllInstalledScripts() { @@ -232,9 +254,11 @@ class DatabaseService { * @param {string} [updateData.container_id] * @param {string} [updateData.status] * @param {string} [updateData.output_log] + * @param {string} [updateData.web_ui_ip] + * @param {number} [updateData.web_ui_port] */ updateInstalledScript(id, updateData) { - const { script_name, container_id, status, output_log } = updateData; + const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData; const updates = []; const values = []; @@ -254,6 +278,14 @@ class DatabaseService { updates.push('output_log = ?'); values.push(output_log); } + if (web_ui_ip !== undefined) { + updates.push('web_ui_ip = ?'); + values.push(web_ui_ip); + } + if (web_ui_port !== undefined) { + updates.push('web_ui_port = ?'); + values.push(web_ui_port); + } if (updates.length === 0) { return { changes: 0 };