feat: Add UI Access button and rearrange the Action Buttons in a Dropdown. (#146)
* feat: Add Web UI IP:Port tracking and access functionality - Add web_ui_ip and web_ui_port columns to installed_scripts table with migration - Update database CRUD methods to handle new Web UI fields - Add terminal output parsing to auto-detect Web UI URLs during installation - Create autoDetectWebUI mutation that runs hostname -I in containers via SSH - Add Web UI column to desktop table with editable IP and port fields - Add Open UI button that opens http://ip:port in new tab - Add Re-detect button for manual IP detection using script metadata - Update mobile card view with Web UI fields and buttons - Fix nested button hydration error in ContextualHelpIcon - Prioritize script metadata interface_port over existing database values - Use pct exec instead of pct enter for container command execution - Add comprehensive error handling and user feedback - Style auto-detect button with muted colors and Re-detect text Features: - Automatic Web UI detection during script installation - Manual IP detection with port lookup from script metadata - Editable IP and port fields in both desktop and mobile views - Clickable Web UI links that open in new tabs - Support for both local and SSH script executions - Proper port detection from script JSON metadata (e.g., actualbudget:5006) - Clean UI with subtle button styling and clear text labels * feat: Disable Open UI button when container is stopped - Add disabled state to Open UI button in desktop table when container is stopped - Update mobile card Open UI button to be disabled when container is stopped - Apply consistent styling with Shell and Update buttons - Prevent users from accessing Web UI when container is not running - Add cursor-not-allowed styling for disabled clickable IP links * feat: Align Re-detect buttons consistently in Web UI column - Change flex layout from space-x-2 to justify-between for consistent button alignment - Add flex-shrink-0 to prevent IP:port text and buttons from shrinking - Add ml-2 margin to Re-detect button for proper spacing - Apply changes to both desktop table and mobile card views - Buttons now align vertically regardless of IP:port text length * feat: Add actions dropdown menu with conditional Start/Stop colors and update help - Create dropdown-menu.tsx component using Radix UI primitives - Move all action buttons except Edit into dropdown menu - Keep Edit and Save/Cancel buttons always visible - Add conditional styling: Start (green), Stop (red) - Apply changes to both desktop table and mobile card views - Add smart visibility - dropdown only shows when actions available - Auto-close dropdown after clicking any action - Style dropdown to match existing button theme - Fix syntax error in dropdown-menu.tsx component - Update help section with Web UI Access and Actions Dropdown documentation - Add detailed explanations of auto-detection, IP/port tracking, and color coding * Fix TypeScript build error in server.js - Updated parseWebUIUrl JSDoc return type from Object|null to {ip: string, port: number}|null - This fixes the TypeScript error where 'ip' property was not recognized on type 'Object' - Build now completes successfully without errors
This commit is contained in:
committed by
GitHub
parent
58e1fb3cea
commit
ceef5c7bb9
680
package-lock.json
generated
680
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
73
server.js
73
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',
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button
|
||||
<div
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer inline-flex items-center justify-center rounded-md transition-colors ${className}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HelpModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -319,6 +319,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
<li>• <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
|
||||
<li>• <strong>Server Association:</strong> Know which server each script is installed on</li>
|
||||
<li>• <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
|
||||
<li>• <strong>Web UI Access:</strong> Track and access Web UI IP addresses and ports</li>
|
||||
<li>• <strong>Execution Logs:</strong> View output and logs from script installations</li>
|
||||
<li>• <strong>Filtering:</strong> Filter by server, status, or search terms</li>
|
||||
</ul>
|
||||
@@ -335,8 +336,47 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-blue-900/20 border-blue-700/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Web UI Access </h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Automatically detect and access Web UI interfaces for your installed scripts.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Auto-Detection:</strong> Automatically detects Web UI URLs from script installation output</li>
|
||||
<li>• <strong>IP & Port Tracking:</strong> Stores and displays Web UI IP addresses and ports</li>
|
||||
<li>• <strong>One-Click Access:</strong> Click IP:port to open Web UI in new tab</li>
|
||||
<li>• <strong>Manual Detection:</strong> Re-detect IP using <code>hostname -I</code> inside container</li>
|
||||
<li>• <strong>Port Detection:</strong> Uses script metadata to get correct port (e.g., actualbudget:5006)</li>
|
||||
<li>• <strong>Editable Fields:</strong> Manually edit IP and port values as needed</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-blue-900/30 rounded-lg border border-blue-700/30">
|
||||
<p className="text-sm font-medium text-blue-300">💡 How it works:</p>
|
||||
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||
<li>• Scripts automatically detect URLs like <code>http://10.10.10.1:3000</code> during installation</li>
|
||||
<li>• Re-detect button runs <code>hostname -I</code> inside the container via SSH</li>
|
||||
<li>• Port defaults to 80, but uses script metadata when available</li>
|
||||
<li>• Web UI buttons are disabled when container is stopped</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">Actions Dropdown </h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Clean interface with all actions organized in a dropdown menu.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Edit Button:</strong> Always visible for quick script editing</li>
|
||||
<li>• <strong>Actions Dropdown:</strong> Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete</li>
|
||||
<li>• <strong>Smart Visibility:</strong> Dropdown only appears when actions are available</li>
|
||||
<li>• <strong>Color Coding:</strong> Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)</li>
|
||||
<li>• <strong>Auto-Close:</strong> Dropdown closes after clicking any action</li>
|
||||
<li>• <strong>Disabled States:</strong> Actions are disabled when container is stopped</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Directly control LXC containers from the installed scripts page via SSH.
|
||||
</p>
|
||||
|
||||
@@ -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<number | null>(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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1146,6 +1233,9 @@ export function InstalledScriptsTab() {
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Web UI
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('server_name')}
|
||||
@@ -1254,6 +1344,62 @@ export function InstalledScriptsTab() {
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="flex items-center space-x-2 min-h-[2.5rem]">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editFormData.web_ui_port}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
script.web_ui_ip ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
onClick={() => handleOpenWebUI(script)}
|
||||
className="text-sm font-mono text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0"
|
||||
>
|
||||
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
||||
</button>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={() => handleAutoDetectWebUI(script)}
|
||||
disabled={autoDetectWebUIMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{autoDetectWebUIMutation.isPending ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={() => handleAutoDetectWebUI(script)}
|
||||
disabled={autoDetectWebUIMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{autoDetectWebUIMutation.isPending ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-left">
|
||||
<span
|
||||
className="text-sm px-3 py-1 rounded inline-block"
|
||||
@@ -1302,63 +1448,81 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
variant="update"
|
||||
size="sm"
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{/* Shell button - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<Button
|
||||
onClick={() => handleOpenShell(script)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
>
|
||||
Shell
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
|
||||
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className={(containerStatuses.get(script.id) ?? 'unknown') === 'running'
|
||||
? "bg-red-600 hover:bg-red-700 text-white border border-red-500 hover:border-red-400 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
: "bg-green-600 hover:bg-green-700 text-white border border-green-500 hover:border-green-400 hover:scale-105 hover:shadow-lg hover:shadow-green-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
}
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDestroy(script)}
|
||||
disabled={controllingScriptId === script.id}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="bg-red-800 hover:bg-red-900 text-white border border-red-600 hover:border-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-600/30 transition-all duration-200 disabled:hover:scale-100"
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
{hasActions(script) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="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"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
|
||||
{script.container_id && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
@@ -143,6 +167,70 @@ export function ScriptInstallationCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web UI */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editFormData.web_ui_port}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground">
|
||||
{script.web_ui_ip ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
onClick={onOpenWebUI}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className={`text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0 ${
|
||||
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
||||
</button>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={onAutoDetectWebUI}
|
||||
disabled={isAutoDetecting}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground">-</span>
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<button
|
||||
onClick={onAutoDetectWebUI}
|
||||
disabled={isAutoDetecting}
|
||||
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
|
||||
title="Re-detect IP and port"
|
||||
>
|
||||
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||
@@ -198,63 +286,81 @@ export function ScriptInstallationCard({
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={onUpdate}
|
||||
variant="update"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
disabled={containerStatus === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{/* Shell button - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<Button
|
||||
onClick={onShell}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
disabled={containerStatus === 'stopped'}
|
||||
>
|
||||
Shell
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
||||
disabled={isControlling || containerStatus === 'unknown'}
|
||||
variant={containerStatus === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDestroy}
|
||||
disabled={isControlling}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
{hasActions(script) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 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"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
|
||||
{script.container_id && (
|
||||
<DropdownMenuItem
|
||||
onClick={onUpdate}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
|
||||
>
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={onShell}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
|
||||
>
|
||||
Shell
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenWebUI}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||
>
|
||||
Open UI
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onDestroy}
|
||||
disabled={isControlling}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{isControlling ? 'Working...' : 'Destroy'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
198
src/app/_components/ui/dropdown-menu.tsx
Normal file
198
src/app/_components/ui/dropdown-menu.tsx
Normal file
@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -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<void>((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'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user