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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-04 09:25:01 +01:00
parent f2b40efbfa
commit db486a56ad
5 changed files with 289 additions and 27 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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();
},

View File

@ -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();

View File

@ -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
;;

View File

@ -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"
]
},