feat(droplet): Implement async publish to prevent UI timeout

- RPCD handler returns immediately with job_id (~0.04s)
- Background script uses file output to avoid pipe inheritance issues
- LuCI JS polls job_status every 2s until completion
- Uses setsid for proper process detachment
- jsonfilter for reliable parameter parsing

Fixes "Failed to publish" error caused by ubus timeout during
40+ second publish operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-17 09:34:50 +01:00
parent ddf480e6ed
commit e1f2a0e885
3 changed files with 111 additions and 33 deletions

View File

@ -30,6 +30,13 @@ var callDropletRemove = rpc.declare({
expect: {} expect: {}
}); });
var callDropletJobStatus = rpc.declare({
object: 'luci.droplet',
method: 'job_status',
params: ['job_id'],
expect: {}
});
return view.extend({ return view.extend({
load: function() { load: function() {
return Promise.all([ return Promise.all([
@ -302,7 +309,11 @@ return view.extend({
} }
}) })
.then(function(result) { .then(function(result) {
if (result.success) { // Handle async job response
if (result.status === 'started' && result.job_id) {
showResult('success', '⏳ Publishing ' + result.name + '...');
return pollJobStatus(result.job_id);
} else if (result.success) {
showResult('success', '✅ Published! <a href="' + result.url + '" target="_blank">' + result.url + '</a>'); showResult('success', '✅ Published! <a href="' + result.url + '" target="_blank">' + result.url + '</a>');
setTimeout(function() { location.reload(); }, 2000); setTimeout(function() { location.reload(); }, 2000);
} else { } else {
@ -323,6 +334,47 @@ return view.extend({
resultMsg.innerHTML = msg; resultMsg.innerHTML = msg;
} }
function pollJobStatus(jobId) {
return new Promise(function(resolve, reject) {
var attempts = 0;
var maxAttempts = 60; // 60 * 2s = 2 minutes max
function check() {
callDropletJobStatus(jobId).then(function(status) {
if (status.status === 'complete') {
if (status.success) {
showResult('success', '✅ Published! <a href="' + status.url + '" target="_blank">' + status.url + '</a>');
setTimeout(function() { location.reload(); }, 2000);
} else {
showResult('error', '❌ ' + (status.error || 'Failed to publish'));
}
resolve(status);
} else if (status.status === 'running') {
attempts++;
if (attempts < maxAttempts) {
setTimeout(check, 2000);
} else {
showResult('error', '❌ Publish timed out');
reject(new Error('Timeout'));
}
} else {
showResult('error', '❌ Job not found');
reject(new Error('Job not found'));
}
}).catch(function(err) {
attempts++;
if (attempts < maxAttempts) {
setTimeout(check, 2000);
} else {
reject(err);
}
});
}
check();
});
}
// Delete buttons // Delete buttons
view.querySelectorAll('.btn-delete').forEach(function(btn) { view.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {

View File

@ -5,10 +5,14 @@
UPLOAD_DIR="/tmp/droplet-upload" UPLOAD_DIR="/tmp/droplet-upload"
DEFAULT_DOMAIN="gk2.secubox.in" DEFAULT_DOMAIN="gk2.secubox.in"
JOB_DIR="/tmp/droplet-jobs"
# Ensure job dir exists
mkdir -p "$JOB_DIR"
case "$1" in case "$1" in
list) list)
echo '{"publish":{},"upload":{"file":"string","name":"string","domain":"string"},"list":{},"remove":{"name":"string"},"rename":{"old":"string","new":"string"},"status":{}}' echo '{"publish":{},"upload":{"file":"string","name":"string","domain":"string"},"list":{},"remove":{"name":"string"},"rename":{"old":"string","new":"string"},"status":{},"job_status":{"job_id":"string"}}'
;; ;;
call) call)
case "$2" in case "$2" in
@ -21,6 +25,25 @@ case "$1" in
json_dump json_dump
;; ;;
job_status)
# Check status of an async publish job
read -r input
job_id=$(echo "$input" | jsonfilter -e '@.job_id' 2>/dev/null)
if [ -z "$job_id" ]; then
echo '{"error":"Job ID required"}'
exit 0
fi
job_file="$JOB_DIR/$job_id"
if [ ! -f "$job_file" ]; then
echo '{"status":"not_found"}'
exit 0
fi
cat "$job_file"
;;
list) list)
json_init json_init
json_add_array "droplets" json_add_array "droplets"
@ -59,53 +82,57 @@ case "$1" in
;; ;;
upload) upload)
# Read params # Read params using jsonfilter for reliability
read -r input read -r input
json_load "$input" file=$(echo "$input" | jsonfilter -e '@.file' 2>/dev/null)
json_get_var file file name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
json_get_var name name domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null)
json_get_var domain domain
[ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; } [ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; }
[ -z "$file" ] && { echo '{"error":"File required"}'; exit 0; } [ -z "$file" ] && { echo '{"error":"File required"}'; exit 0; }
[ -z "$domain" ] && domain="$DEFAULT_DOMAIN" [ -z "$domain" ] && domain="$DEFAULT_DOMAIN"
# File should be in upload dir (set by cgi-io) # File should be in upload dir (set by cgi-io)
local upload_file="$UPLOAD_DIR/$file" upload_file="$UPLOAD_DIR/$file"
if [ ! -f "$upload_file" ]; then if [ ! -f "$upload_file" ]; then
# Try direct path # Try direct path
upload_file="$file" upload_file="$file"
fi fi
[ ! -f "$upload_file" ] && { echo '{"error":"File not found"}'; exit 0; } [ ! -f "$upload_file" ] && { echo '{"error":"File not found: '"$file"'"}'; exit 0; }
# Publish # Generate job ID and run publish async
result=$(dropletctl publish "$upload_file" "$name" "$domain" 2>&1) job_id="$(date +%s)_$$"
exit_code=$? job_file="$JOB_DIR/$job_id"
# Extract vhost from result # Initialize job status
vhost=$(echo "$result" | grep -oE '[a-z0-9_-]+\.[a-z0-9.-]+' | tail -1) echo '{"status":"running","name":"'"$name"'","domain":"'"$domain"'"}' > "$job_file"
json_init # Write background script - use file output to avoid pipe inheritance issues
if [ $exit_code -eq 0 ]; then bg_script="/tmp/droplet-bg-$job_id.sh"
json_add_boolean "success" 1 result_file="/tmp/droplet-result-$job_id.txt"
json_add_string "vhost" "$vhost" printf '#!/bin/sh\nupload_file="$1"\nname="$2"\ndomain="$3"\njob_file="$4"\nresult_file="$5"\n' > "$bg_script"
json_add_string "url" "https://$vhost/" # Write output to file instead of capturing in variable (avoids pipe inheritance)
json_add_string "message" "Published successfully" printf 'dropletctl publish "$upload_file" "$name" "$domain" > "$result_file" 2>&1\nexit_code=$?\n' >> "$bg_script"
else # Check for success marker and extract vhost
json_add_boolean "success" 0 printf 'if grep -q "\\[OK\\] Published:" "$result_file"; then\n' >> "$bg_script"
json_add_string "error" "$result" printf ' vhost=$(tail -1 "$result_file")\n' >> "$bg_script"
fi printf ' printf '\''{"status":"complete","success":true,"vhost":"%%s","url":"https://%%s/","message":"Published successfully"}'\'' "$vhost" "$vhost" > "$job_file"\n' >> "$bg_script"
json_dump printf 'else\n error_msg=$(tail -10 "$result_file" | tr '\''\\n'\'' '\'' '\'' | sed '\''s/"/\\\\"/g'\'')\n' >> "$bg_script"
printf ' printf '\''{"status":"complete","success":false,"error":"%%s"}'\'' "$error_msg" > "$job_file"\nfi\n' >> "$bg_script"
printf 'rm -f "$upload_file" "$result_file" "$0"\nsleep 300\nrm -f "$job_file"\n' >> "$bg_script"
chmod +x "$bg_script"
# Cleanup # Run background script detached
rm -f "$upload_file" setsid "$bg_script" "$upload_file" "$name" "$domain" "$job_file" "$result_file" </dev/null >/dev/null 2>&1 &
# Return immediately with job ID
echo '{"status":"started","job_id":"'"$job_id"'","name":"'"$name"'","domain":"'"$domain"'"}'
;; ;;
remove) remove)
read -r input read -r input
json_load "$input" name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null)
json_get_var name name
[ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; } [ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; }
@ -119,9 +146,8 @@ case "$1" in
rename) rename)
read -r input read -r input
json_load "$input" old=$(echo "$input" | jsonfilter -e '@.old' 2>/dev/null)
json_get_var old old new=$(echo "$input" | jsonfilter -e '@.new' 2>/dev/null)
json_get_var new new
[ -z "$old" ] || [ -z "$new" ] && { echo '{"error":"Old and new names required"}'; exit 0; } [ -z "$old" ] || [ -z "$new" ] && { echo '{"error":"Old and new names required"}'; exit 0; }

View File

@ -3,7 +3,7 @@
"description": "Droplet Publisher", "description": "Droplet Publisher",
"read": { "read": {
"ubus": { "ubus": {
"luci.droplet": ["status", "list"] "luci.droplet": ["status", "list", "job_status"]
} }
}, },
"write": { "write": {