secubox-openwrt/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit
CyberMind-FR dc6a8f9c62 fix(streamlit): Auto-install requirements from ZIP uploads and support non-standard filenames
The install_requirements() function only matched requirements.txt exactly,
missing files like requirements_bazi.txt shipped in user ZIP uploads. Now
falls back to any requirements*.txt file. RPCD upload handlers (upload_zip,
upload_finalize) also trigger pip install inside the container at deploy time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:31:02 +01:00

1303 lines
32 KiB
Bash
Executable File

#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
# LuCI RPC backend for Streamlit Platform
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
CONFIG="streamlit"
LXC_NAME="streamlit"
LXC_PATH="/srv/lxc"
APPS_PATH="/srv/streamlit/apps"
# JSON helpers
json_init_obj() { json_init; json_add_object "result"; }
json_close_obj() { json_close_object; json_dump; }
json_error() {
json_init_obj
json_add_boolean "success" 0
json_add_string "message" "$1"
json_close_obj
}
json_success() {
json_init_obj
json_add_boolean "success" 1
[ -n "$1" ] && json_add_string "message" "$1"
json_close_obj
}
# Check if container is running
lxc_running() {
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
}
# Check if container exists
lxc_exists() {
[ -f "$LXC_PATH/$LXC_NAME/config" ] && [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]
}
# Get service status
get_status() {
local enabled running installed uptime
local http_port data_path memory_limit active_app
config_load "$CONFIG"
config_get enabled main enabled "0"
config_get http_port main http_port "8501"
config_get data_path main data_path "/srv/streamlit"
config_get memory_limit main memory_limit "512M"
config_get active_app main active_app "hello"
running="false"
installed="false"
uptime=""
if lxc_exists; then
installed="true"
fi
if lxc_running; then
running="true"
uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1 | awk '{print $3}')
fi
# Count apps
local app_count=0
APPS_PATH="$data_path/apps"
if [ -d "$APPS_PATH" ]; then
app_count=$(ls -1 "$APPS_PATH"/*.py 2>/dev/null | wc -l)
fi
# Get LAN IP for URL
local lan_ip
lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
json_init_obj
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_boolean "running" "$( [ "$running" = "true" ] && echo 1 || echo 0 )"
json_add_boolean "installed" "$( [ "$installed" = "true" ] && echo 1 || echo 0 )"
json_add_string "uptime" "$uptime"
json_add_int "http_port" "$http_port"
json_add_string "data_path" "$data_path"
json_add_string "memory_limit" "$memory_limit"
json_add_string "active_app" "$active_app"
json_add_int "app_count" "$app_count"
json_add_string "web_url" "http://${lan_ip}:${http_port}"
json_add_string "container_name" "$LXC_NAME"
json_close_obj
}
# Get configuration
get_config() {
local http_port http_host data_path memory_limit enabled active_app
local headless gather_stats theme_base theme_primary
config_load "$CONFIG"
# Main settings
config_get http_port main http_port "8501"
config_get http_host main http_host "0.0.0.0"
config_get data_path main data_path "/srv/streamlit"
config_get memory_limit main memory_limit "512M"
config_get enabled main enabled "0"
config_get active_app main active_app "hello"
# Server settings
config_get headless server headless "true"
config_get gather_stats server browser_gather_usage_stats "false"
config_get theme_base server theme_base "dark"
config_get theme_primary server theme_primary_color "#0ff"
json_init_obj
json_add_object "main"
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_int "http_port" "$http_port"
json_add_string "http_host" "$http_host"
json_add_string "data_path" "$data_path"
json_add_string "memory_limit" "$memory_limit"
json_add_string "active_app" "$active_app"
json_close_object
json_add_object "server"
json_add_boolean "headless" "$( [ "$headless" = "true" ] && echo 1 || echo 0 )"
json_add_boolean "browser_gather_usage_stats" "$( [ "$gather_stats" = "true" ] && echo 1 || echo 0 )"
json_add_string "theme_base" "$theme_base"
json_add_string "theme_primary_color" "$theme_primary"
json_close_object
json_close_obj
}
# Save configuration
save_config() {
read -r input
local http_port http_host data_path memory_limit enabled active_app
local headless gather_stats theme_base theme_primary
http_port=$(echo "$input" | jsonfilter -e '@.http_port' 2>/dev/null)
http_host=$(echo "$input" | jsonfilter -e '@.http_host' 2>/dev/null)
data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null)
memory_limit=$(echo "$input" | jsonfilter -e '@.memory_limit' 2>/dev/null)
enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null)
active_app=$(echo "$input" | jsonfilter -e '@.active_app' 2>/dev/null)
headless=$(echo "$input" | jsonfilter -e '@.headless' 2>/dev/null)
gather_stats=$(echo "$input" | jsonfilter -e '@.browser_gather_usage_stats' 2>/dev/null)
theme_base=$(echo "$input" | jsonfilter -e '@.theme_base' 2>/dev/null)
theme_primary=$(echo "$input" | jsonfilter -e '@.theme_primary_color' 2>/dev/null)
[ -n "$http_port" ] && uci set "${CONFIG}.main.http_port=$http_port"
[ -n "$http_host" ] && uci set "${CONFIG}.main.http_host=$http_host"
[ -n "$data_path" ] && uci set "${CONFIG}.main.data_path=$data_path"
[ -n "$memory_limit" ] && uci set "${CONFIG}.main.memory_limit=$memory_limit"
[ -n "$enabled" ] && uci set "${CONFIG}.main.enabled=$enabled"
[ -n "$active_app" ] && uci set "${CONFIG}.main.active_app=$active_app"
[ -n "$headless" ] && uci set "${CONFIG}.server.headless=$headless"
[ -n "$gather_stats" ] && uci set "${CONFIG}.server.browser_gather_usage_stats=$gather_stats"
[ -n "$theme_base" ] && uci set "${CONFIG}.server.theme_base=$theme_base"
[ -n "$theme_primary" ] && uci set "${CONFIG}.server.theme_primary_color=$theme_primary"
uci commit "$CONFIG"
json_success "Configuration saved"
}
# Start service
start_service() {
if lxc_running; then
json_error "Service is already running"
return
fi
if ! lxc_exists; then
json_error "Container not installed. Run install first."
return
fi
/etc/init.d/streamlit start >/dev/null 2>&1 &
sleep 2
if lxc_running; then
json_success "Service started"
else
json_error "Failed to start service"
fi
}
# Stop service
stop_service() {
if ! lxc_running; then
json_error "Service is not running"
return
fi
/etc/init.d/streamlit stop >/dev/null 2>&1
sleep 2
if ! lxc_running; then
json_success "Service stopped"
else
json_error "Failed to stop service"
fi
}
# Restart service
restart_service() {
/etc/init.d/streamlit restart >/dev/null 2>&1 &
sleep 3
if lxc_running; then
json_success "Service restarted"
else
json_error "Service restart failed"
fi
}
# Install Streamlit
install() {
if lxc_exists; then
json_error "Already installed. Use update to refresh."
return
fi
# Run install in background
/usr/sbin/streamlitctl install >/var/log/streamlit-install.log 2>&1 &
json_init_obj
json_add_boolean "started" 1
json_add_string "message" "Installation started in background"
json_add_string "log_file" "/var/log/streamlit-install.log"
json_close_obj
}
# Uninstall Streamlit
uninstall() {
/usr/sbin/streamlitctl uninstall >/dev/null 2>&1
if ! lxc_exists; then
json_success "Uninstalled successfully"
else
json_error "Uninstall failed"
fi
}
# Update Streamlit
update() {
if ! lxc_exists; then
json_error "Not installed. Run install first."
return
fi
# Run update in background
/usr/sbin/streamlitctl update >/var/log/streamlit-update.log 2>&1 &
json_init_obj
json_add_boolean "started" 1
json_add_string "message" "Update started in background"
json_add_string "log_file" "/var/log/streamlit-update.log"
json_close_obj
}
# Get logs
get_logs() {
read -r input
local lines
lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null)
[ -z "$lines" ] && lines=100
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
json_init_obj
json_add_array "logs"
# Get container logs from data path
if [ -d "$data_path/logs" ]; then
local logfile
for logfile in "$data_path/logs"/*.log; do
[ -f "$logfile" ] || continue
tail -n "$lines" "$logfile" 2>/dev/null | while IFS= read -r line; do
json_add_string "" "$line"
done
done
fi
# Also check install/update logs
for logfile in /var/log/streamlit-install.log /var/log/streamlit-update.log; do
[ -f "$logfile" ] || continue
tail -n 50 "$logfile" 2>/dev/null | while IFS= read -r line; do
json_add_string "" "$line"
done
done
json_close_array
json_close_obj
}
# List apps
list_apps() {
local data_path active_app
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
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 "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
json_add_string "active_app" "$active_app"
json_add_string "apps_path" "$APPS_PATH"
json_close_obj
}
# Add app
add_app() {
read -r input
local name path
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null)
if [ -z "$name" ] || [ -z "$path" ]; then
json_error "Missing name or path"
return
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
remove_app() {
read -r input
local name
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
if [ -z "$name" ]; then
json_error "Missing app name"
return
fi
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
set_active_app() {
read -r input
local name
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
if [ -z "$name" ]; then
json_error "Missing app name"
return
fi
uci set "${CONFIG}.main.active_app=$name"
uci commit "$CONFIG"
if [ $? -eq 0 ]; then
json_success "Active app set to: $name"
else
json_error "Failed to set active app"
fi
}
# Get app details
get_app() {
read -r input
local name
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
if [ -z "$name" ]; then
json_error "Missing app name"
return
fi
local data_path active_app
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
config_get active_app main active_app "hello"
local app_file="$data_path/apps/${name}.py"
if [ ! -f "$app_file" ]; then
json_error "App not found"
return
fi
local size=$(ls -la "$app_file" 2>/dev/null | awk '{print $5}')
local mtime=$(stat -c %Y "$app_file" 2>/dev/null || echo "0")
local lines=$(wc -l < "$app_file" 2>/dev/null || echo "0")
local is_active=0
[ "$name" = "$active_app" ] && is_active=1
json_init_obj
json_add_string "name" "$name"
json_add_string "path" "$app_file"
json_add_string "size" "$size"
json_add_int "mtime" "$mtime"
json_add_int "lines" "$lines"
json_add_boolean "active" "$is_active"
json_close_obj
}
# Upload app (receive base64 content)
# NOTE: uhttpd-mod-ubus has a 64KB JSON body limit.
# Small files (<40KB) go through RPC directly.
# Larger files use chunked upload: upload_chunk + upload_finalize.
upload_app() {
local tmpinput="/tmp/rpcd_upload_$$.json"
cat > "$tmpinput"
local name
name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null)
name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//')
if [ -z "$name" ]; then
rm -f "$tmpinput"
json_error "Missing name"
return
fi
local b64file="/tmp/rpcd_b64_$$.txt"
jsonfilter -i "$tmpinput" -e '@.content' > "$b64file" 2>/dev/null
rm -f "$tmpinput"
if [ ! -s "$b64file" ]; then
rm -f "$b64file"
json_error "Missing content"
return
fi
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
local app_file="$data_path/apps/${name}.py"
mkdir -p "$data_path/apps"
base64 -d < "$b64file" > "$app_file" 2>/dev/null
local rc=$?
rm -f "$b64file"
if [ $rc -eq 0 ] && [ -s "$app_file" ]; then
uci set "${CONFIG}.${name}=app"
uci set "${CONFIG}.${name}.name=$name"
uci set "${CONFIG}.${name}.path=${name}.py"
uci set "${CONFIG}.${name}.enabled=1"
uci commit "$CONFIG"
json_success "App uploaded: $name"
else
rm -f "$app_file"
json_error "Failed to decode app content"
fi
}
# Chunked upload: receive a base64 chunk and append to temp file
upload_chunk() {
local tmpinput="/tmp/rpcd_chunk_$$.json"
cat > "$tmpinput"
local name chunk_data chunk_index
name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null)
chunk_data=$(jsonfilter -i "$tmpinput" -e '@.data' 2>/dev/null)
chunk_index=$(jsonfilter -i "$tmpinput" -e '@.index' 2>/dev/null)
rm -f "$tmpinput"
name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//')
if [ -z "$name" ] || [ -z "$chunk_data" ]; then
json_error "Missing name or data"
return
fi
local staging="/tmp/streamlit_upload_${name}.b64"
# First chunk: create new file; subsequent: append
if [ "$chunk_index" = "0" ]; then
printf '%s' "$chunk_data" > "$staging"
else
printf '%s' "$chunk_data" >> "$staging"
fi
json_success "Chunk $chunk_index received"
}
# Finalize chunked upload: decode accumulated base64 and save
upload_finalize() {
local tmpinput="/tmp/rpcd_finalize_$$.json"
cat > "$tmpinput"
local name is_zip
name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null)
is_zip=$(jsonfilter -i "$tmpinput" -e '@.is_zip' 2>/dev/null)
rm -f "$tmpinput"
name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//')
if [ -z "$name" ]; then
json_error "Missing name"
return
fi
local staging="/tmp/streamlit_upload_${name}.b64"
if [ ! -s "$staging" ]; then
json_error "No upload data found for $name"
return
fi
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
mkdir -p "$data_path/apps"
if [ "$is_zip" = "1" ] || [ "$is_zip" = "true" ]; then
# Decode as ZIP and extract
local tmpzip="/tmp/upload_${name}_$$.zip"
base64 -d < "$staging" > "$tmpzip" 2>/dev/null
rm -f "$staging"
if [ ! -s "$tmpzip" ]; then
rm -f "$tmpzip"
json_error "Failed to decode ZIP"
return
fi
local app_dir="$data_path/apps/$name"
mkdir -p "$app_dir"
unzip -o "$tmpzip" -d "$app_dir" >/dev/null 2>&1
rm -f "$tmpzip"
local main_py
main_py=$(find "$app_dir" -maxdepth 2 -name "*.py" -type f | head -1)
if [ -n "$main_py" ]; then
# Install requirements if found (requirements.txt or requirements*.txt)
if lxc_running; then
local req_file=""
if [ -f "$app_dir/requirements.txt" ]; then
req_file="requirements.txt"
else
req_file=$(ls -1 "$app_dir"/requirements*.txt 2>/dev/null | head -1 | xargs basename 2>/dev/null)
fi
if [ -n "$req_file" ]; then
lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/${name}/${req_file}" >/dev/null 2>&1 &
fi
fi
uci set "${CONFIG}.${name}=app"
uci set "${CONFIG}.${name}.name=$name"
uci set "${CONFIG}.${name}.path=$main_py"
uci set "${CONFIG}.${name}.enabled=1"
uci commit "$CONFIG"
json_success "ZIP app deployed: $name"
else
json_error "No Python files found in archive"
fi
else
# Decode as .py file
local app_file="$data_path/apps/${name}.py"
base64 -d < "$staging" > "$app_file" 2>/dev/null
local rc=$?
rm -f "$staging"
if [ $rc -eq 0 ] && [ -s "$app_file" ]; then
uci set "${CONFIG}.${name}=app"
uci set "${CONFIG}.${name}.name=$name"
uci set "${CONFIG}.${name}.path=${name}.py"
uci set "${CONFIG}.${name}.enabled=1"
uci commit "$CONFIG"
json_success "App uploaded: $name"
else
rm -f "$app_file"
json_error "Failed to decode app content"
fi
fi
}
# List instances
list_instances() {
json_init_obj
json_add_array "instances"
config_load "$CONFIG"
_add_instance_json() {
local section="$1"
local name app port enabled autostart inst_name
config_get inst_name "$section" name ""
config_get app "$section" app ""
config_get port "$section" port ""
config_get enabled "$section" enabled "0"
config_get autostart "$section" autostart "0"
[ -z "$app" ] && return
json_add_object ""
json_add_string "id" "$section"
json_add_string "name" "$inst_name"
json_add_string "app" "$app"
json_add_int "port" "$port"
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_boolean "autostart" "$( [ "$autostart" = "1" ] && echo 1 || echo 0 )"
json_close_object
}
config_foreach _add_instance_json instance
json_close_array
json_close_obj
}
# Add instance
add_instance() {
read -r input
local id name app port
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
app=$(echo "$input" | jsonfilter -e '@.app' 2>/dev/null)
port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null)
if [ -z "$id" ] || [ -z "$app" ] || [ -z "$port" ]; then
json_error "Missing id, app, or port"
return
fi
[ -z "$name" ] && name="$id"
# Validate port number
if ! echo "$port" | grep -qE '^[0-9]+$'; then
json_error "Invalid port number"
return
fi
# Check if instance already exists
local existing
existing=$(uci -q get "${CONFIG}.${id}")
if [ -n "$existing" ]; then
json_error "Instance $id already exists"
return
fi
uci set "${CONFIG}.${id}=instance"
uci set "${CONFIG}.${id}.name=$name"
uci set "${CONFIG}.${id}.app=$app"
uci set "${CONFIG}.${id}.port=$port"
uci set "${CONFIG}.${id}.enabled=1"
uci set "${CONFIG}.${id}.autostart=1"
uci commit "$CONFIG"
json_success "Instance added: $id"
}
# Remove instance
remove_instance() {
read -r input
local id
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
if [ -z "$id" ]; then
json_error "Missing instance id"
return
fi
# Check if instance exists
local existing
existing=$(uci -q get "${CONFIG}.${id}")
if [ -z "$existing" ]; then
json_error "Instance $id not found"
return
fi
uci delete "${CONFIG}.${id}"
uci commit "$CONFIG"
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
local id
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
if [ -z "$id" ]; then
json_error "Missing instance id"
return
fi
uci set "${CONFIG}.${id}.enabled=1"
uci commit "$CONFIG"
json_success "Instance enabled: $id"
}
# Disable instance
disable_instance() {
read -r input
local id
id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
if [ -z "$id" ]; then
json_error "Missing instance id"
return
fi
uci set "${CONFIG}.${id}.enabled=0"
uci commit "$CONFIG"
json_success "Instance disabled: $id"
}
# Preview ZIP contents
preview_zip() {
# Write stdin to temp file to avoid shell variable size limits
local tmpinput="/tmp/rpcd_preview_$$.json"
cat > "$tmpinput"
local tmpzip="/tmp/preview_$$.zip"
jsonfilter -i "$tmpinput" -e '@.content' 2>/dev/null | base64 -d > "$tmpzip" 2>/dev/null
rm -f "$tmpinput"
if [ ! -s "$tmpzip" ]; then
rm -f "$tmpzip"
json_error "Failed to decode ZIP"
return
fi
json_init_obj
json_add_array "files"
# Use unzip to list contents
unzip -l "$tmpzip" 2>/dev/null | tail -n +4 | head -n -2 | while read -r size date time name; do
[ -z "$name" ] && continue
local is_dir=0
echo "$name" | grep -q '/$' && is_dir=1
json_add_object ""
json_add_string "path" "$name"
json_add_int "size" "$size"
json_add_boolean "is_dir" "$is_dir"
json_close_object
done
json_close_array
json_close_obj
rm -f "$tmpzip"
}
# Upload ZIP with selected files
upload_zip() {
# Write stdin to temp file to avoid shell variable size limits
local tmpinput="/tmp/rpcd_zipinput_$$.json"
cat > "$tmpinput"
local name selected_files
name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null)
selected_files=$(jsonfilter -i "$tmpinput" -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" ]; then
rm -f "$tmpinput"
json_error "Missing name"
return
fi
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/streamlit"
local app_dir="$data_path/apps/$name"
local tmpzip="/tmp/upload_$$.zip"
# Extract base64 content and decode directly to zip file
jsonfilter -i "$tmpinput" -e '@.content' 2>/dev/null | base64 -d > "$tmpzip" 2>/dev/null
rm -f "$tmpinput"
if [ ! -s "$tmpzip" ]; then
rm -f "$tmpzip"
json_error "Failed to decode ZIP"
return
fi
mkdir -p "$app_dir"
# Extract selected files or all if none specified
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" >/dev/null 2>&1
done
else
# Extract all
unzip -o "$tmpzip" -d "$app_dir" >/dev/null 2>&1
fi
rm -f "$tmpzip"
# Find main .py file for registration
local main_py
main_py=$(find "$app_dir" -maxdepth 2 -name "*.py" -type f | head -1)
if [ -n "$main_py" ]; then
# Install requirements if found (requirements.txt or requirements*.txt)
if lxc_running; then
local req_file=""
if [ -f "$app_dir/requirements.txt" ]; then
req_file="requirements.txt"
else
req_file=$(ls -1 "$app_dir"/requirements*.txt 2>/dev/null | head -1 | xargs basename 2>/dev/null)
fi
if [ -n "$req_file" ]; then
lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/${name}/${req_file}" >/dev/null 2>&1 &
fi
fi
# Register in UCI
uci set "${CONFIG}.${name}=app"
uci set "${CONFIG}.${name}.name=$name"
uci set "${CONFIG}.${name}.path=$main_py"
uci set "${CONFIG}.${name}.enabled=1"
uci commit "$CONFIG"
json_init_obj
json_add_boolean "success" 1
json_add_string "message" "App deployed: $name"
json_add_string "path" "$app_dir"
json_add_string "main_file" "$main_py"
json_close_obj
else
json_error "No Python files found in extracted archive"
fi
}
# Get Gitea config
get_gitea_config() {
config_load "$CONFIG"
local enabled url user token
config_get enabled gitea enabled "0"
config_get url gitea url ""
config_get user gitea user ""
config_get token gitea token ""
json_init_obj
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_string "url" "$url"
json_add_string "user" "$user"
json_add_boolean "has_token" "$( [ -n "$token" ] && echo 1 || echo 0 )"
json_close_obj
}
# Save Gitea config
save_gitea_config() {
read -r input
local enabled url user token
enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null)
url=$(echo "$input" | jsonfilter -e '@.url' 2>/dev/null)
user=$(echo "$input" | jsonfilter -e '@.user' 2>/dev/null)
token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null)
# Ensure gitea section exists
uci -q get "${CONFIG}.gitea" >/dev/null || uci set "${CONFIG}.gitea=gitea"
[ -n "$enabled" ] && uci set "${CONFIG}.gitea.enabled=$enabled"
[ -n "$url" ] && uci set "${CONFIG}.gitea.url=$url"
[ -n "$user" ] && uci set "${CONFIG}.gitea.user=$user"
[ -n "$token" ] && uci set "${CONFIG}.gitea.token=$token"
uci commit "$CONFIG"
json_success "Gitea configuration saved"
}
# Clone app from Gitea
gitea_clone() {
read -r input
local name repo
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
repo=$(echo "$input" | jsonfilter -e '@.repo' 2>/dev/null)
if [ -z "$name" ] || [ -z "$repo" ]; then
json_error "Missing name or repo"
return
fi
# Run clone in background
/usr/sbin/streamlitctl gitea clone "$name" "$repo" >/var/log/streamlit-gitea.log 2>&1 &
json_init_obj
json_add_boolean "success" 1
json_add_string "message" "Cloning $repo to $name in background"
json_close_obj
}
# Pull app from Gitea
gitea_pull() {
read -r input
local name
name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
if [ -z "$name" ]; then
json_error "Missing app name"
return
fi
# Run pull
/usr/sbin/streamlitctl gitea pull "$name" >/var/log/streamlit-gitea.log 2>&1
if [ $? -eq 0 ]; then
json_success "App updated from Gitea: $name"
else
json_error "Failed to pull app from Gitea"
fi
}
# List Gitea repositories
gitea_list_repos() {
config_load "$CONFIG"
local enabled url user token
config_get enabled gitea enabled "0"
config_get url gitea url ""
config_get user gitea user ""
config_get token gitea token ""
if [ "$enabled" != "1" ] || [ -z "$url" ] || [ -z "$token" ]; then
json_error "Gitea not configured"
return
fi
# Call Gitea API to list user repos
local api_url="${url}/api/v1/user/repos"
local response
response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null)
if [ -z "$response" ]; then
json_error "Failed to connect to Gitea"
return
fi
json_init_obj
json_add_array "repos"
# Parse JSON response (simple extraction)
echo "$response" | jsonfilter -e '@[*].full_name' 2>/dev/null | while read -r repo; do
[ -z "$repo" ] && continue
json_add_string "" "$repo"
done
json_close_array
json_close_obj
}
# Check install progress
get_install_progress() {
local log_file="/var/log/streamlit-install.log"
local status="unknown"
local progress=0
local message=""
if [ -f "$log_file" ]; then
# Check for completion markers
if grep -q "Installation complete" "$log_file" 2>/dev/null; then
status="completed"
progress=100
message="Installation completed successfully"
elif grep -q "ERROR" "$log_file" 2>/dev/null; then
status="error"
message=$(grep "ERROR" "$log_file" | tail -1)
else
status="running"
# Estimate progress based on log content
if grep -q "Rootfs created" "$log_file" 2>/dev/null; then
progress=80
message="Setting up container..."
elif grep -q "Extracting rootfs" "$log_file" 2>/dev/null; then
progress=60
message="Extracting container rootfs..."
elif grep -q "Downloading Alpine" "$log_file" 2>/dev/null; then
progress=40
message="Downloading Alpine rootfs..."
elif grep -q "Installing Streamlit" "$log_file" 2>/dev/null; then
progress=20
message="Starting installation..."
else
progress=10
message="Initializing..."
fi
fi
else
status="not_started"
message="Installation has not been started"
fi
# Check if process is still running
if pgrep -f "streamlitctl install" >/dev/null 2>&1; then
status="running"
fi
json_init_obj
json_add_string "status" "$status"
json_add_int "progress" "$progress"
json_add_string "message" "$message"
json_close_obj
}
# Main RPC handler
case "$1" in
list)
cat <<-EOF
{
"get_status": {},
"get_config": {},
"save_config": {"http_port": 8501, "http_host": "str", "data_path": "str", "memory_limit": "str", "enabled": "str", "active_app": "str", "headless": "str", "browser_gather_usage_stats": "str", "theme_base": "str", "theme_primary_color": "str"},
"start": {},
"stop": {},
"restart": {},
"install": {},
"uninstall": {},
"update": {},
"get_logs": {"lines": 100},
"list_apps": {},
"get_app": {"name": "str"},
"add_app": {"name": "str", "path": "str"},
"remove_app": {"name": "str"},
"set_active_app": {"name": "str"},
"upload_app": {"name": "str", "content": "str"},
"upload_chunk": {"name": "str", "data": "str", "index": 0},
"upload_finalize": {"name": "str", "is_zip": "str"},
"preview_zip": {"content": "str"},
"upload_zip": {"name": "str", "content": "str", "selected_files": []},
"get_install_progress": {},
"list_instances": {},
"add_instance": {"id": "str", "name": "str", "app": "str", "port": 8501},
"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"},
"gitea_pull": {"name": "str"},
"gitea_list_repos": {}
}
EOF
;;
call)
case "$2" in
get_status)
get_status
;;
get_config)
get_config
;;
save_config)
save_config
;;
start)
start_service
;;
stop)
stop_service
;;
restart)
restart_service
;;
install)
install
;;
uninstall)
uninstall
;;
update)
update
;;
get_logs)
get_logs
;;
list_apps)
list_apps
;;
get_app)
get_app
;;
add_app)
add_app
;;
remove_app)
remove_app
;;
set_active_app)
set_active_app
;;
upload_app)
upload_app
;;
upload_chunk)
upload_chunk
;;
upload_finalize)
upload_finalize
;;
preview_zip)
preview_zip
;;
upload_zip)
upload_zip
;;
get_install_progress)
get_install_progress
;;
list_instances)
list_instances
;;
add_instance)
add_instance
;;
remove_instance)
remove_instance
;;
enable_instance)
enable_instance
;;
disable_instance)
disable_instance
;;
rename_app)
rename_app
;;
rename_instance)
rename_instance
;;
get_gitea_config)
get_gitea_config
;;
save_gitea_config)
save_gitea_config
;;
gitea_clone)
gitea_clone
;;
gitea_pull)
gitea_pull
;;
gitea_list_repos)
gitea_list_repos
;;
*)
json_error "Unknown method: $2"
;;
esac
;;
esac