From db486a56ad8271104f8034f20cca88558b42c7b7 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 4 Feb 2026 09:25:01 +0100 Subject: [PATCH] fix(streamlit): Fix ZIP upload, app management and add rename support - Fix ZIP upload: install unzip dependency, fix empty array check (jsonfilter returns "[ ]" not "[]"), redirect unzip stdout to prevent JSON corruption, use readAsArrayBuffer instead of deprecated readAsBinaryString, add .catch() error handler - Fix list_apps to scan subdirectories for ZIP-uploaded apps, skip Streamlit pages/ convention dir, prefer app.py as entry point - Fix set_active_app: replace broken streamlitctl call with direct UCI update - Fix remove_app: replace broken streamlitctl call with direct file removal and UCI cleanup - Fix add_app: replace broken streamlitctl call with direct UCI - Add rename_app and rename_instance RPCD methods with ACL entries - Activate now auto-creates an instance with next available port - Apps list shows UCI display name separate from filesystem ID - Sanitize uploaded filenames for UCI compatibility - Add rename buttons and modals for apps and instances - Add error notifications for failed deletes Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 13 +- .../luci-static/resources/streamlit/api.js | 22 +++ .../resources/view/streamlit/dashboard.js | 126 ++++++++++++-- .../root/usr/libexec/rpcd/luci.streamlit | 154 ++++++++++++++++-- .../share/rpcd/acl.d/luci-app-streamlit.json | 1 + 5 files changed, 289 insertions(+), 27 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3ef5baf9..9d9312f2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -255,7 +255,18 @@ "Bash(do DISPLAY=:1 xdotool getwindowname \"$wid\")", "Bash(dpkg:*)", "Bash(cosmic-screenshot:*)", - "Bash(do bash -n \"$f\")" + "Bash(do bash -n \"$f\")", + "Bash(scrot:*)", + "Bash(gnome-screenshot:*)", + "Bash(import:*)", + "Bash(grim:*)", + "Bash(DISPLAY=:1 import:*)", + "Bash(/usr/bin/cosmic-screenshot:*)", + "Bash(pip3 install:*)", + "Bash(gdbus call:*)", + "Bash(git mv:*)", + "Bash(DISPLAY=:1 scrot:*)", + "Bash(node -c:*)" ] } } diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js index d752038f..56dfa5e0 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/streamlit/api.js @@ -164,6 +164,20 @@ var callDisableInstance = rpc.declare({ expect: { result: {} } }); +var callRenameApp = rpc.declare({ + object: 'luci.streamlit', + method: 'rename_app', + params: ['id', 'name'], + expect: { result: {} } +}); + +var callRenameInstance = rpc.declare({ + object: 'luci.streamlit', + method: 'rename_instance', + params: ['id', 'name'], + expect: { result: {} } +}); + var callGetGiteaConfig = rpc.declare({ object: 'luci.streamlit', method: 'get_gitea_config', @@ -315,6 +329,14 @@ return baseclass.extend({ return callDisableInstance(id); }, + renameApp: function(id, name) { + return callRenameApp(id, name); + }, + + renameInstance: function(id, name) { + return callRenameInstance(id, name); + }, + getGiteaConfig: function() { return callGetGiteaConfig(); }, diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js index 2ccd5483..2410ef4c 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js @@ -178,13 +178,19 @@ return view.extend({ E('span', { 'style': 'margin-left:4px' }, inst.enabled ? _('Enabled') : _('Disabled')) ]), E('td', {}, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { self.renameInstance(inst.id, inst.name); } + }, _('Rename')), inst.enabled ? E('button', { 'class': 'cbi-button', + 'style': 'margin-left: 4px', 'click': function() { self.disableInstance(inst.id); } }, _('Disable')) : E('button', { 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left: 4px', 'click': function() { self.enableInstance(inst.id); } }, _('Enable')), E('button', { @@ -212,7 +218,9 @@ return view.extend({ var appOptions = [E('option', { 'value': '' }, _('-- Select App --'))]; this.apps.forEach(function(app) { - appOptions.push(E('option', { 'value': app.name }, app.name)); + var appId = app.id || app.name; + var label = app.name !== appId ? app.name + ' (' + appId + ')' : app.name; + appOptions.push(E('option', { 'value': appId }, label)); }); // Calculate next available port @@ -260,22 +268,30 @@ return view.extend({ } var rows = apps.map(function(app) { - var isActive = app.name === self.activeApp; + var appId = app.id || app.name; + var isActive = appId === self.activeApp; return E('tr', { 'class': isActive ? 'cbi-rowstyle-2' : '' }, [ E('td', {}, [ E('strong', {}, app.name), + app.id && app.id !== app.name ? + E('span', { 'style': 'color:#666; margin-left:4px' }, '(' + app.id + ')') : '', isActive ? E('span', { 'style': 'color:#0a0; margin-left:8px' }, _('(active)')) : '' ]), E('td', {}, app.path ? app.path.split('/').pop() : '-'), E('td', {}, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { self.renameApp(appId, app.name); } + }, _('Rename')), !isActive ? E('button', { 'class': 'cbi-button cbi-button-action', - 'click': function() { self.activateApp(app.name); } + 'style': 'margin-left: 4px', + 'click': function() { self.activateApp(appId); } }, _('Activate')) : '', E('button', { 'class': 'cbi-button cbi-button-remove', 'style': 'margin-left: 4px', - 'click': function() { self.deleteApp(app.name); } + 'click': function() { self.deleteApp(appId); } }, _('Delete')) ]) ]); @@ -537,10 +553,23 @@ return view.extend({ activateApp: function(name) { var self = this; + var hasInstance = this.instances.some(function(inst) { return inst.app === name; }); + api.setActiveApp(name).then(function(r) { if (r && r.success) { - ui.addNotification(null, E('p', {}, _('App activated: ') + name), 'info'); - return api.restart(); + if (!hasInstance) { + // Auto-create instance with next available port + var usedPorts = self.instances.map(function(i) { return i.port; }); + var port = 8501; + while (usedPorts.indexOf(port) !== -1) { port++; } + return api.addInstance(name, name, name, port).then(function() { + ui.addNotification(null, E('p', {}, _('App activated with new instance on port ') + port), 'info'); + return api.restart(); + }); + } else { + ui.addNotification(null, E('p', {}, _('App activated: ') + name), 'info'); + return api.restart(); + } } }).then(function() { self.refresh().then(function() { self.updateStatus(); }); @@ -561,6 +590,8 @@ return view.extend({ api.removeApp(name).then(function(r) { if (r && r.success) { ui.addNotification(null, E('p', {}, _('App deleted')), 'info'); + } else { + ui.addNotification(null, E('p', {}, (r && r.message) || _('Delete failed')), 'error'); } self.refresh().then(function() { self.updateStatus(); }); }); @@ -579,12 +610,24 @@ return view.extend({ } var file = fileInput.files[0]; - var name = file.name.replace(/\.(py|zip)$/, ''); + var name = file.name.replace(/\.(py|zip)$/, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, ''); var isZip = file.name.endsWith('.zip'); var reader = new FileReader(); reader.onload = function(e) { - var content = btoa(e.target.result); + var content; + if (isZip) { + var binary = e.target.result; + var bytes = new Uint8Array(binary); + var chunks = []; + for (var i = 0; i < bytes.length; i += 8192) { + chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); + } + content = btoa(chunks.join('')); + } else { + content = btoa(e.target.result); + } + var uploadFn = isZip ? api.uploadZip(name, content, null) : api.uploadApp(name, content); uploadFn.then(function(r) { @@ -593,18 +636,81 @@ return view.extend({ fileInput.value = ''; self.refresh().then(function() { self.updateStatus(); }); } else { - ui.addNotification(null, E('p', {}, r.message || _('Upload failed')), 'error'); + var msg = (r && r.message) ? r.message : _('Upload failed'); + ui.addNotification(null, E('p', {}, msg), 'error'); } + }).catch(function(err) { + ui.addNotification(null, E('p', {}, + _('Upload error: ') + (err.message || err)), 'error'); }); }; if (isZip) { - reader.readAsBinaryString(file); + reader.readAsArrayBuffer(file); } else { reader.readAsText(file); } }, + renameApp: function(id, currentName) { + var self = this; + if (!currentName) currentName = id; + + ui.showModal(_('Rename App'), [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Name')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'rename-input', 'class': 'cbi-input-text', 'value': currentName })) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left: 8px', + 'click': function() { + var newName = document.getElementById('rename-input').value.trim(); + if (!newName) return; + ui.hideModal(); + api.renameApp(id, newName).then(function(r) { + if (r && r.success) + ui.addNotification(null, E('p', {}, _('App renamed')), 'info'); + self.refresh().then(function() { self.updateStatus(); }); + }); + } + }, _('Save')) + ]) + ]); + }, + + renameInstance: function(id, currentName) { + var self = this; + + ui.showModal(_('Rename Instance'), [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Name')), + E('div', { 'class': 'cbi-value-field' }, + E('input', { 'type': 'text', 'id': 'rename-input', 'class': 'cbi-input-text', 'value': currentName || id })) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left: 8px', + 'click': function() { + var newName = document.getElementById('rename-input').value.trim(); + if (!newName) return; + ui.hideModal(); + api.renameInstance(id, newName).then(function(r) { + if (r && r.success) + ui.addNotification(null, E('p', {}, _('Instance renamed')), 'info'); + self.refresh().then(function() { self.updateStatus(); }); + }); + } + }, _('Save')) + ]) + ]); + }, + giteaClone: function() { var self = this; var repo = document.getElementById('gitea-repo').value.trim(); diff --git a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit index 6c81dfab..ef1696b0 100755 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -306,28 +306,68 @@ list_apps() { config_get active_app main active_app "hello" APPS_PATH="$data_path/apps" + local seen="" json_init_obj json_add_array "apps" if [ -d "$APPS_PATH" ]; then + # Scan top-level .py files for app in "$APPS_PATH"/*.py; do [ -f "$app" ] || continue local name=$(basename "$app" .py) + local display_name=$(uci -q get "${CONFIG}.${name}.name") + [ -z "$display_name" ] && display_name="$name" local size=$(ls -la "$app" 2>/dev/null | awk '{print $5}') local mtime=$(stat -c %Y "$app" 2>/dev/null || echo "0") local is_active=0 [ "$name" = "$active_app" ] && is_active=1 + seen="$seen $name " json_add_object "" - json_add_string "name" "$name" + json_add_string "id" "$name" + json_add_string "name" "$display_name" json_add_string "path" "$app" json_add_string "size" "$size" json_add_int "mtime" "$mtime" json_add_boolean "active" "$is_active" json_close_object done + + # Scan subdirectories (ZIP-uploaded apps) + for dir in "$APPS_PATH"/*/; do + [ -d "$dir" ] || continue + local dirname=$(basename "$dir") + # Skip Streamlit multi-page convention dir and hidden dirs + case "$dirname" in pages|.*) continue ;; esac + # Skip if already seen as a top-level .py + case "$seen" in *" $dirname "*) continue ;; esac + + # Prefer app.py as main entry point, fall back to first .py + local main_py="" + [ -f "$dir/app.py" ] && main_py="$dir/app.py" + [ -z "$main_py" ] && main_py=$(find "$dir" -maxdepth 1 -name "*.py" -type f | head -1) + [ -z "$main_py" ] && main_py=$(find "$dir" -maxdepth 2 -name "*.py" -type f | head -1) + [ -z "$main_py" ] && continue + + local display_name=$(uci -q get "${CONFIG}.${dirname}.name") + [ -z "$display_name" ] && display_name="$dirname" + local size=$(stat -c %s "$main_py" 2>/dev/null || echo "0") + local mtime=$(stat -c %Y "$main_py" 2>/dev/null || echo "0") + + local is_active=0 + [ "$dirname" = "$active_app" ] && is_active=1 + + json_add_object "" + json_add_string "id" "$dirname" + json_add_string "name" "$display_name" + json_add_string "path" "$main_py" + json_add_string "size" "$size" + json_add_int "mtime" "$mtime" + json_add_boolean "active" "$is_active" + json_close_object + done fi json_close_array @@ -348,12 +388,16 @@ add_app() { return fi - /usr/sbin/streamlitctl app add "$name" "$path" >/dev/null 2>&1 - if [ $? -eq 0 ]; then - json_success "App added: $name" - else - json_error "Failed to add app" - fi + # Sanitize name for UCI + name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') + + uci set "${CONFIG}.${name}=app" + uci set "${CONFIG}.${name}.name=$name" + uci set "${CONFIG}.${name}.path=$path" + uci set "${CONFIG}.${name}.enabled=1" + uci commit "$CONFIG" + + json_success "App added: $name" } # Remove app @@ -367,12 +411,24 @@ remove_app() { return fi - /usr/sbin/streamlitctl app remove "$name" >/dev/null 2>&1 - if [ $? -eq 0 ]; then - json_success "App removed: $name" - else - json_error "Failed to remove app" + local data_path + config_load "$CONFIG" + config_get data_path main data_path "/srv/streamlit" + + # Remove app files (top-level .py or subdirectory) + if [ -f "$data_path/apps/${name}.py" ]; then + rm -f "$data_path/apps/${name}.py" + rm -f "$data_path/apps/${name}.requirements.txt" fi + if [ -d "$data_path/apps/${name}" ]; then + rm -rf "$data_path/apps/${name}" + fi + + # Remove UCI config + uci -q delete "${CONFIG}.${name}" + uci commit "$CONFIG" + + json_success "App removed: $name" } # Set active app @@ -386,7 +442,9 @@ set_active_app() { return fi - /usr/sbin/streamlitctl app run "$name" >/dev/null 2>&1 + uci set "${CONFIG}.main.active_app=$name" + uci commit "$CONFIG" + if [ $? -eq 0 ]; then json_success "Active app set to: $name" else @@ -441,6 +499,9 @@ upload_app() { name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) content=$(echo "$input" | jsonfilter -e '@.content' 2>/dev/null) + # Sanitize name for UCI compatibility + name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') + if [ -z "$name" ] || [ -z "$content" ]; then json_error "Missing name or content" return @@ -570,6 +631,55 @@ remove_instance() { json_success "Instance removed: $id" } +# Rename app +rename_app() { + read -r input + local id name + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + if [ -z "$id" ] || [ -z "$name" ]; then + json_error "Missing id or name" + return + fi + + # Create UCI section if it doesn't exist yet + local existing + existing=$(uci -q get "${CONFIG}.${id}") + if [ -z "$existing" ]; then + uci set "${CONFIG}.${id}=app" + uci set "${CONFIG}.${id}.enabled=1" + fi + + uci set "${CONFIG}.${id}.name=$name" + uci commit "$CONFIG" + json_success "App renamed" +} + +# Rename instance +rename_instance() { + read -r input + local id name + id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + if [ -z "$id" ] || [ -z "$name" ]; then + json_error "Missing id or name" + return + fi + + local existing + existing=$(uci -q get "${CONFIG}.${id}") + if [ -z "$existing" ]; then + json_error "Instance $id not found" + return + fi + + uci set "${CONFIG}.${id}.name=$name" + uci commit "$CONFIG" + json_success "Instance renamed" +} + # Enable instance enable_instance() { read -r input @@ -654,6 +764,9 @@ upload_zip() { content=$(echo "$input" | jsonfilter -e '@.content' 2>/dev/null) selected_files=$(echo "$input" | jsonfilter -e '@.selected_files' 2>/dev/null) + # Sanitize name for UCI compatibility (alphanumeric and underscores only) + name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') + if [ -z "$name" ] || [ -z "$content" ]; then json_error "Missing name or content" return @@ -676,15 +789,16 @@ upload_zip() { mkdir -p "$app_dir" # Extract selected files or all if none specified - if [ -n "$selected_files" ] && [ "$selected_files" != "[]" ]; then + local file_count=$(echo "$selected_files" | jsonfilter -e '@[*]' 2>/dev/null | wc -l) + if [ -n "$selected_files" ] && [ "$file_count" -gt 0 ] 2>/dev/null; then # Parse selected files array and extract each echo "$selected_files" | jsonfilter -e '@[*]' 2>/dev/null | while read -r filepath; do [ -z "$filepath" ] && continue - unzip -o "$tmpzip" "$filepath" -d "$app_dir" 2>/dev/null + unzip -o "$tmpzip" "$filepath" -d "$app_dir" >/dev/null 2>&1 done else # Extract all - unzip -o "$tmpzip" -d "$app_dir" 2>/dev/null + unzip -o "$tmpzip" -d "$app_dir" >/dev/null 2>&1 fi rm -f "$tmpzip" @@ -918,6 +1032,8 @@ case "$1" in "remove_instance": {"id": "str"}, "enable_instance": {"id": "str"}, "disable_instance": {"id": "str"}, + "rename_app": {"id": "str", "name": "str"}, + "rename_instance": {"id": "str", "name": "str"}, "get_gitea_config": {}, "save_gitea_config": {"enabled": "str", "url": "str", "user": "str", "token": "str"}, "gitea_clone": {"name": "str", "repo": "str"}, @@ -1000,6 +1116,12 @@ case "$1" in disable_instance) disable_instance ;; + rename_app) + rename_app + ;; + rename_instance) + rename_instance + ;; get_gitea_config) get_gitea_config ;; diff --git a/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json b/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json index 6438152e..4fc9eb7d 100644 --- a/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json +++ b/package/secubox/luci-app-streamlit/root/usr/share/rpcd/acl.d/luci-app-streamlit.json @@ -20,6 +20,7 @@ "add_app", "remove_app", "set_active_app", "upload_app", "preview_zip", "upload_zip", "add_instance", "remove_instance", "enable_instance", "disable_instance", + "rename_app", "rename_instance", "save_gitea_config", "gitea_clone", "gitea_pull" ] },