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:
parent
f2b40efbfa
commit
db486a56ad
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
;;
|
||||
|
||||
@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user