From e3b9cee5bcde0995b15d14d46cdafa9278dba53c Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 14 Oct 2025 15:01:32 +0200 Subject: [PATCH 1/5] 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 --- scripts/ct/actualbudget.sh | 67 +++++++ scripts/ct/snipeit.sh | 84 ++++++++ scripts/install/actualbudget-install.sh | 97 ++++++++++ scripts/install/snipeit-install.sh | 91 +++++++++ server.js | 67 +++++++ src/app/_components/ContextualHelpIcon.tsx | 8 +- src/app/_components/InstalledScriptsTab.tsx | 156 ++++++++++++++- .../_components/ScriptInstallationCard.tsx | 87 ++++++++- src/server/api/routers/installedScripts.ts | 180 +++++++++++++++++- src/server/database.js | 42 +++- 10 files changed, 857 insertions(+), 22 deletions(-) create mode 100644 scripts/ct/actualbudget.sh create mode 100644 scripts/ct/snipeit.sh create mode 100644 scripts/install/actualbudget-install.sh create mode 100644 scripts/install/snipeit-install.sh diff --git a/scripts/ct/actualbudget.sh b/scripts/ct/actualbudget.sh new file mode 100644 index 00000000..62b48f76 --- /dev/null +++ b/scripts/ct/actualbudget.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 community-scripts ORG +# Author: MickLesk (CanbiZ) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://actualbudget.org/ + +APP="Actual Budget" +var_tags="${var_tags:-finance}" +var_cpu="${var_cpu:-2}" +var_ram="${var_ram:-2048}" +var_disk="${var_disk:-4}" +var_os="${var_os:-debian}" +var_version="${var_version:-13}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + + if [[ ! -f /opt/actualbudget_version.txt ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + NODE_VERSION="22" setup_nodejs + RELEASE=$(curl -fsSL https://api.github.com/repos/actualbudget/actual/releases/latest | grep "tag_name" | awk '{print substr($2, 3, length($2)-4) }') + if [[ -f /opt/actualbudget-data/config.json ]]; then + if [[ ! -f /opt/actualbudget_version.txt ]] || [[ "${RELEASE}" != "$(cat /opt/actualbudget_version.txt)" ]]; then + msg_info "Stopping Service" + systemctl stop actualbudget + msg_ok "Stopped Service" + + msg_info "Updating ${APP} to ${RELEASE}" + $STD npm update -g @actual-app/sync-server + echo "${RELEASE}" >/opt/actualbudget_version.txt + msg_ok "Updated ${APP} to ${RELEASE}" + + msg_info "Starting Service" + systemctl start actualbudget + msg_ok "Started Service" + else + msg_info "${APP} is already up to date" + fi + else + msg_info "Old Installation Found, you need to migrate your data and recreate to a new container" + msg_info "Please follow the instructions on the ${APP} website to migrate your data" + msg_info "https://actualbudget.org/docs/backup-restore/backup" + exit 1 + fi + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" +echo -e "${INFO}${YW} Access it using the following URL:${CL}" +echo -e "${TAB}${GATEWAY}${BGN}https://${IP}:5006${CL}" diff --git a/scripts/ct/snipeit.sh b/scripts/ct/snipeit.sh new file mode 100644 index 00000000..1571e44b --- /dev/null +++ b/scripts/ct/snipeit.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 community-scripts ORG +# Author: Michel Roegl-Brunner (michelroegl-brunner) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://snipeitapp.com/ + +APP="SnipeIT" +var_tags="${var_tags:-asset-management;foss}" +var_cpu="${var_cpu:-2}" +var_ram="${var_ram:-2048}" +var_disk="${var_disk:-4}" +var_os="${var_os:-debian}" +var_version="${var_version:-12}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + if [[ ! -d /opt/snipe-it ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + if ! grep -q "client_max_body_size[[:space:]]\+100M;" /etc/nginx/conf.d/snipeit.conf; then + sed -i '/index index.php;/i \ client_max_body_size 100M;' /etc/nginx/conf.d/snipeit.conf + fi + + if check_for_gh_release "snipe-it" "snipe/snipe-it"; then + msg_info "Stopping Services" + systemctl stop nginx + msg_ok "Services Stopped" + + msg_info "Creating backup" + mv /opt/snipe-it /opt/snipe-it-backup + msg_ok "Backup created" + + fetch_and_deploy_gh_release "snipe-it" "snipe/snipe-it" "tarball" + [[ "$(php -v 2>/dev/null)" == PHP\ 8.2* ]] && PHP_VERSION="8.3" PHP_MODULE="common,ctype,ldap,fileinfo,iconv,mysql,soap,xsl" PHP_FPM="YES" setup_php + sed -i 's/php8.2/php8.3/g' /etc/nginx/conf.d/snipeit.conf + setup_composer + + msg_info "Updating ${APP}" + $STD apt-get update + $STD apt-get -y upgrade + cp /opt/snipe-it-backup/.env /opt/snipe-it/.env + cp -r /opt/snipe-it-backup/public/uploads/ /opt/snipe-it/public/uploads/ + cp -r /opt/snipe-it-backup/storage/private_uploads /opt/snipe-it/storage/private_uploads + cd /opt/snipe-it/ + export COMPOSER_ALLOW_SUPERUSER=1 + $STD composer install --no-dev --optimize-autoloader --no-interaction + $STD composer dump-autoload + $STD php artisan migrate --force + $STD php artisan config:clear + $STD php artisan route:clear + $STD php artisan cache:clear + $STD php artisan view:clear + chown -R www-data: /opt/snipe-it + chmod -R 755 /opt/snipe-it + rm -rf /opt/snipe-it-backup + msg_ok "Updated ${APP}" + + msg_info "Starting Service" + systemctl start nginx + msg_ok "Started Service" + msg_ok "Update Successful" + fi + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" +echo -e "${INFO}${YW} Access it using the following URL:${CL}" +echo -e "${TAB}${GATEWAY}${BGN}http://${IP}${CL}" diff --git a/scripts/install/actualbudget-install.sh b/scripts/install/actualbudget-install.sh new file mode 100644 index 00000000..356a4113 --- /dev/null +++ b/scripts/install/actualbudget-install.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 community-scripts ORG +# Author: MickLesk (CanbiZ) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://actualbudget.org/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +msg_info "Installing Dependencies" +$STD apt install -y \ + make \ + g++ +msg_ok "Installed Dependencies" + +msg_info "Installing Actual Budget" +cd /opt +RELEASE=$(curl -fsSL https://api.github.com/repos/actualbudget/actual/releases/latest | grep "tag_name" | awk '{print substr($2, 3, length($2)-4) }') +NODE_VERSION="22" setup_nodejs +mkdir -p /opt/actualbudget-data/{server-files,upload,migrate,user-files,migrations,config} +chown -R root:root /opt/actualbudget-data +chmod -R 755 /opt/actualbudget-data + +cat </opt/actualbudget-data/config.json +{ + "port": 5006, + "hostname": "::", + "serverFiles": "/opt/actualbudget-data/server-files", + "userFiles": "/opt/actualbudget-data/user-files", + "trustedProxies": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "::1/128", + "fc00::/7" + ], + "https": { + "key": "/opt/actualbudget/selfhost.key", + "cert": "/opt/actualbudget/selfhost.crt" + } +} +EOF + +mkdir -p /opt/actualbudget +cd /opt/actualbudget || exit +$STD npm install --location=global @actual-app/sync-server +$STD openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout selfhost.key -out selfhost.crt <"/opt/actualbudget_version.txt" +msg_ok "Installed Actual Budget" + +msg_info "Creating Service" +cat </etc/systemd/system/actualbudget.service +[Unit] +Description=Actual Budget Service +After=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/opt/actualbudget +Environment=ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB=20 +Environment=ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB=50 +Environment=ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=20 +ExecStart=/usr/bin/actual-server --config /opt/actualbudget-data/config.json +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF +systemctl enable -q --now actualbudget +msg_ok "Created Service" + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt -y autoremove +$STD apt -y autoclean +$STD apt -y clean +msg_ok "Cleaned" diff --git a/scripts/install/snipeit-install.sh b/scripts/install/snipeit-install.sh new file mode 100644 index 00000000..a3e9a268 --- /dev/null +++ b/scripts/install/snipeit-install.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 community-scripts ORG +# Author: Michel Roegl-Brunner (michelroegl-brunner) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://snipeitapp.com/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +msg_info "Installing Dependencies" +$STD apt-get install -y \ + git \ + nginx +msg_ok "Installed Dependencies" + +PHP_VERSION="8.3" PHP_MODULE="common,ctype,ldap,fileinfo,iconv,mysql,soap,xsl" PHP_FPM="YES" setup_php +setup_composer +fetch_and_deploy_gh_release "snipe-it" "snipe/snipe-it" "tarball" +setup_mariadb + +msg_info "Setting up database" +DB_NAME=snipeit_db +DB_USER=snipeit +DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13) +$STD mariadb -u root -e "CREATE DATABASE $DB_NAME;" +$STD mariadb -u root -e "CREATE USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';" +$STD mariadb -u root -e "GRANT ALL ON $DB_NAME.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;" +{ + echo "SnipeIT-Credentials" + echo "SnipeIT Database User: $DB_USER" + echo "SnipeIT Database Password: $DB_PASS" + echo "SnipeIT Database Name: $DB_NAME" +} >>~/snipeit.creds +msg_ok "Set up database" + +msg_info "Configuring Snipe-IT" +cd /opt/snipe-it +cp .env.example .env +IPADDRESS=$(hostname -I | awk '{print $1}') + +sed -i -e "s|^APP_URL=.*|APP_URL=http://$IPADDRESS|" \ + -e "s|^DB_DATABASE=.*|DB_DATABASE=$DB_NAME|" \ + -e "s|^DB_USERNAME=.*|DB_USERNAME=$DB_USER|" \ + -e "s|^DB_PASSWORD=.*|DB_PASSWORD=$DB_PASS|" .env + +chown -R www-data: /opt/snipe-it +chmod -R 755 /opt/snipe-it +export COMPOSER_ALLOW_SUPERUSER=1 +$STD composer install --no-dev --optimize-autoloader --no-interaction +$STD php artisan key:generate --force +msg_ok "Configured SnipeIT" + +msg_info "Creating Service" +cat </etc/nginx/conf.d/snipeit.conf +server { + listen 80; + root /opt/snipe-it/public; + server_name $IPADDRESS; + client_max_body_size 100M; + index index.php; + + location / { + try_files \$uri \$uri/ /index.php?\$query_string; + } + + location ~ \.php\$ { + include fastcgi.conf; + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_split_path_info ^(.+\.php)(/.+)\$; + fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; + include fastcgi_params; + } +} +EOF +systemctl reload nginx +msg_ok "Created Service" + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt-get -y autoremove +$STD apt-get -y autoclean +msg_ok "Cleaned" diff --git a/server.js b/server.js index ab89406c..40d7d2bf 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 {Object|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,15 @@ class ScriptExecutionHandler { this.updateInstallationRecord(installationId, { container_id: containerId }); } + // Parse for Web UI URL + const webUIUrl = this.parseWebUIUrl(output); + if (webUIUrl && installationId) { + this.updateInstallationRecord(installationId, { + web_ui_ip: webUIUrl.ip, + web_ui_port: webUIUrl.port + }); + } + this.sendMessage(ws, { type: 'output', data: output, @@ -447,6 +505,15 @@ class ScriptExecutionHandler { this.updateInstallationRecord(installationId, { container_id: containerId }); } + // Parse for Web UI URL + const webUIUrl = this.parseWebUIUrl(data); + if (webUIUrl && installationId) { + this.updateInstallationRecord(installationId, { + web_ui_ip: webUIUrl.ip, + web_ui_port: webUIUrl.port + }); + } + // Handle data output this.sendMessage(ws, { type: 'output', diff --git a/src/app/_components/ContextualHelpIcon.tsx b/src/app/_components/ContextualHelpIcon.tsx index 4a93fb23..73b05709 100644 --- a/src/app/_components/ContextualHelpIcon.tsx +++ b/src/app/_components/ContextualHelpIcon.tsx @@ -26,15 +26,13 @@ export function ContextualHelpIcon({ return ( <> - + (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 +94,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 +208,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); } }); @@ -361,7 +386,7 @@ export function InstalledScriptsTab() { console.log('Status check triggered - scripts length:', scripts.length); fetchContainerStatuses(); } - }, [scripts.length, fetchContainerStatuses]); + }, [scripts.length]); // Remove fetchContainerStatuses from dependencies to prevent infinite loop // Cleanup timeout on unmount useEffect(() => { @@ -648,13 +673,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 +700,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 +768,46 @@ 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'); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); @@ -1111,6 +1180,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 +1218,9 @@ export function InstalledScriptsTab() { )} + + Web UI + handleSort('server_name')} @@ -1254,6 +1329,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' && ( + + )} +
+ ) + )} + )} + {/* Open UI button - only show when web_ui_ip exists */} + {script.web_ui_ip && ( + + )} {/* Container Control Buttons - only show for SSH scripts with container_id */} {script.container_id && script.execution_mode === 'ssh' && ( <> diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 37350d51..6f7bc195 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -24,13 +24,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 +46,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,7 +68,10 @@ export function ScriptInstallationCard({ containerStatus, onStartStop, onDestroy, - isControlling + isControlling, + onOpenWebUI, + onAutoDetectWebUI, + isAutoDetecting }: ScriptInstallationCardProps) { const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); @@ -143,6 +152,67 @@ export function ScriptInstallationCard({ )} + {/* Web UI */} +
+
Web UI
+ {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
@@ -221,6 +291,17 @@ export function ScriptInstallationCard({ Shell )} + {/* Open UI button - only show when web_ui_ip exists */} + {script.web_ui_ip && ( + + )} {/* Container Control Buttons - only show for SSH scripts with container_id */} {script.container_id && script.execution_mode === 'ssh' && ( <> diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index c6c0b1a8..0b755f60 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 || !detectedIp.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) { + 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 && 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 bca0659f..413c16a6 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,6 +108,8 @@ class DatabaseService { installation_date DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')), output_log TEXT, + 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 }; From 77f3bcb5d2d3b9d8ad24a537aefe8cbecf803338 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 14 Oct 2025 15:07:58 +0200 Subject: [PATCH 2/5] 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 --- src/app/_components/InstalledScriptsTab.tsx | 16 ++++++---------- src/app/_components/ScriptInstallationCard.tsx | 18 +++++++++++------- src/app/_components/ui/button.tsx | 4 ++++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index 1223d045..00e1ab8a 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -1361,7 +1361,7 @@ export function InstalledScriptsTab() { @@ -1471,12 +1471,8 @@ export function InstalledScriptsTab() { diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 6f7bc195..2e0c4258 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -154,7 +154,7 @@ export function ScriptInstallationCard({ {/* Web UI */}
-
Web UI
+
IP:PORT
{isEditing ? (
@@ -187,7 +190,7 @@ export function ScriptInstallationCard({ @@ -308,7 +312,7 @@ export function ScriptInstallationCard({
) : ( script.web_ui_ip ? ( -
+
@@ -1361,7 +1361,7 @@ export function InstalledScriptsTab() {
+
+

Web UI Access (NEW)

+

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

+
    +
  • Auto-Detection: Automatically detects Web UI URLs from script installation output
  • +
  • IP & Port Tracking: Stores and displays Web UI IP addresses and ports
  • +
  • One-Click Access: Click IP:port to open Web UI in new tab
  • +
  • Manual Detection: Re-detect IP using hostname -I inside container
  • +
  • Port Detection: Uses script metadata to get correct port (e.g., actualbudget:5006)
  • +
  • Editable Fields: Manually edit IP and port values as needed
  • +
+
+

💡 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 (NEW)

+

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

+
    +
  • Edit Button: Always visible for quick script editing
  • +
  • Actions Dropdown: Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete
  • +
  • Smart Visibility: Dropdown only appears when actions are available
  • +
  • Color Coding: Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)
  • +
  • Auto-Close: Dropdown closes after clicking any action
  • +
  • Disabled States: Actions are disabled when container is stopped
  • +
+
+
-

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 f6aa2123..b077128f 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; @@ -44,6 +51,7 @@ export function InstalledScriptsTab() { 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; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); + const [openDropdownId, setOpenDropdownId] = useState(null); 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); @@ -808,6 +816,15 @@ export function InstalledScriptsTab() { window.open(url, '_blank', 'noopener,noreferrer'); }; + // Helper function to check if a script has any actions available + const hasActions = (script: InstalledScript) => { + return !!( + (script.container_id && script.execution_mode === 'ssh') || // Update, Shell, Start/Stop, Destroy + script.web_ui_ip || // Open UI + (!script.container_id || script.execution_mode !== 'ssh') // Delete for non-SSH scripts + ); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); @@ -1433,70 +1450,81 @@ export function InstalledScriptsTab() { > Edit - {script.container_id && ( - - )} - {/* Shell button - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - - )} - {/* Open UI button - only show when web_ui_ip exists */} - {script.web_ui_ip && ( - - )} - {/* 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 58102ae8..eb264ba4 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; @@ -77,6 +84,15 @@ export function ScriptInstallationCard({ return new Date(dateString).toLocaleString(); }; + // Helper function to check if a script has any actions available + const hasActions = (script: InstalledScript) => { + return !!( + (script.container_id && script.execution_mode === 'ssh') || // Update, Shell, Start/Stop, Destroy + script.web_ui_ip || // Open UI + (!script.container_id || script.execution_mode !== 'ssh') // Delete for non-SSH scripts + ); + }; + return (
Edit - {script.container_id && ( - - )} - {/* Shell button - only show for SSH scripts with container_id */} - {script.container_id && script.execution_mode === 'ssh' && ( - - )} - {/* Open UI button - only show when web_ui_ip exists */} - {script.web_ui_ip && ( - - )} - {/* 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/dropdown-menu.tsx b/src/app/_components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..54d818d6 --- /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, +}; From 3e8ba6a9bf693c189c8e7b16ba9d6ef307f0edf5 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 14 Oct 2025 15:30:31 +0200 Subject: [PATCH 5/5] 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 --- server.js | 24 ++++++++++++------- src/app/_components/ContextualHelpIcon.tsx | 1 - src/app/_components/HelpModal.tsx | 4 ++-- src/app/_components/InstalledScriptsTab.tsx | 16 ++++++------- .../_components/ScriptInstallationCard.tsx | 11 ++++----- src/server/api/routers/installedScripts.ts | 4 ++-- 6 files changed, 31 insertions(+), 29 deletions(-) diff --git a/server.js b/server.js index 40d7d2bf..ec5dcb26 100644 --- a/server.js +++ b/server.js @@ -134,7 +134,7 @@ class ScriptExecutionHandler { /** * Parse Web UI URL from terminal output * @param {string} output - Terminal output to parse - * @returns {Object|null} - Object with ip and port if found, null otherwise + * @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 @@ -416,10 +416,13 @@ class ScriptExecutionHandler { // Parse for Web UI URL const webUIUrl = this.parseWebUIUrl(output); if (webUIUrl && installationId) { - this.updateInstallationRecord(installationId, { - web_ui_ip: webUIUrl.ip, - web_ui_port: webUIUrl.port - }); + const { ip, port } = webUIUrl; + if (ip && port) { + this.updateInstallationRecord(installationId, { + web_ui_ip: ip, + web_ui_port: port + }); + } } this.sendMessage(ws, { @@ -508,10 +511,13 @@ class ScriptExecutionHandler { // Parse for Web UI URL const webUIUrl = this.parseWebUIUrl(data); if (webUIUrl && installationId) { - this.updateInstallationRecord(installationId, { - web_ui_ip: webUIUrl.ip, - web_ui_port: webUIUrl.port - }); + const { ip, port } = webUIUrl; + if (ip && port) { + this.updateInstallationRecord(installationId, { + web_ui_ip: ip, + web_ui_port: port + }); + } } // Handle data output diff --git a/src/app/_components/ContextualHelpIcon.tsx b/src/app/_components/ContextualHelpIcon.tsx index 73b05709..7d459e15 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 { diff --git a/src/app/_components/HelpModal.tsx b/src/app/_components/HelpModal.tsx index f9a6443b..1cad0ac9 100644 --- a/src/app/_components/HelpModal.tsx +++ b/src/app/_components/HelpModal.tsx @@ -337,7 +337,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
-

Web UI Access (NEW)

+

Web UI Access

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

@@ -361,7 +361,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
-

Actions Dropdown (NEW)

+

Actions Dropdown

Clean interface with all actions organized in a dropdown menu.

diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index b077128f..8aca574e 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -51,7 +51,6 @@ export function InstalledScriptsTab() { 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; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); - const [openDropdownId, setOpenDropdownId] = useState(null); 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); @@ -394,7 +393,7 @@ export function InstalledScriptsTab() { console.log('Status check triggered - scripts length:', scripts.length); fetchContainerStatuses(); } - }, [scripts.length]); // Remove fetchContainerStatuses from dependencies to prevent infinite loop + }, [scripts.length, fetchContainerStatuses]); // Cleanup timeout on unmount useEffect(() => { @@ -811,18 +810,17 @@ export function InstalledScriptsTab() { return; } - const port = script.web_ui_port || 80; + 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) => { - return !!( - (script.container_id && script.execution_mode === 'ssh') || // Update, Shell, Start/Stop, Destroy - script.web_ui_ip || // Open UI - (!script.container_id || script.execution_mode !== 'ssh') // Delete for non-SSH scripts - ); + 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; }; @@ -1372,7 +1370,7 @@ export function InstalledScriptsTab() { 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} + {script.web_ui_ip}:{script.web_ui_port ?? 80} {script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && (