mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-28 03:01:54 +08:00
⛅ Sync 2026-03-17 20:27:41
This commit is contained in:
parent
738f2b9cd8
commit
cb0fc3c571
@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-openclaw
|
||||
PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0"))
|
||||
PKG_RELEASE:=2
|
||||
PKG_RELEASE:=3
|
||||
|
||||
PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com>
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
@ -41,6 +41,9 @@ function index()
|
||||
|
||||
-- 配置备份 API (v2026.3.8+: openclaw backup create/verify)
|
||||
entry({"admin", "services", "openclaw", "backup"}, call("action_backup"), nil).leaf = true
|
||||
|
||||
-- 系统配置检测 API (安装前检测)
|
||||
entry({"admin", "services", "openclaw", "check_system"}, call("action_check_system"), nil).leaf = true
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
@ -372,19 +375,21 @@ function action_uninstall()
|
||||
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
|
||||
-- 设置 UCI enabled=0
|
||||
sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null")
|
||||
-- 删除 Node.js + OpenClaw 运行环境
|
||||
-- 删除 Node.js + OpenClaw 运行环境 (包含所有插件: qqbot, 飞书等)
|
||||
sys.exec("rm -rf /opt/openclaw")
|
||||
-- 清理旧数据迁移后可能残留的目录
|
||||
sys.exec("rm -rf /root/.openclaw 2>/dev/null")
|
||||
-- 清理临时文件
|
||||
sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /var/run/openclaw*.pid")
|
||||
sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /tmp/openclaw-plugin-upgrade.* /var/run/openclaw*.pid")
|
||||
-- 清理 LuCI 缓存
|
||||
sys.exec("rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null")
|
||||
-- 删除 openclaw 系统用户
|
||||
sys.exec("sed -i '/^openclaw:/d' /etc/passwd /etc/shadow /etc/group 2>/dev/null")
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
message = "运行环境已卸载。已清理: Node.js 运行环境 (/opt/openclaw)、旧数据目录 (/root/.openclaw)、临时文件。"
|
||||
message = "运行环境已卸载。已清理: Node.js 运行环境 (/opt/openclaw)、所有插件 (qqbot/飞书等)、旧数据目录 (/root/.openclaw)、临时文件、LuCI 缓存。"
|
||||
})
|
||||
end
|
||||
|
||||
@ -801,3 +806,74 @@ function action_backup()
|
||||
http.write_json({ status = "error", message = "未知备份操作: " .. action })
|
||||
end
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 系统配置检测 API (安装前检测)
|
||||
-- 检测内存和磁盘空间是否满足最低要求
|
||||
-- 要求: 内存 > 1GB, 磁盘可用空间 > 1.5GB
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_check_system()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
-- 最低要求配置
|
||||
local MIN_MEMORY_MB = 1024 -- 1GB
|
||||
local MIN_DISK_MB = 1536 -- 1.5GB
|
||||
|
||||
local result = {
|
||||
memory_mb = 0,
|
||||
memory_ok = false,
|
||||
disk_mb = 0,
|
||||
disk_ok = false,
|
||||
disk_path = "",
|
||||
pass = false,
|
||||
message = ""
|
||||
}
|
||||
|
||||
-- 检测总内存 (从 /proc/meminfo 读取 MemTotal)
|
||||
local meminfo = io.open("/proc/meminfo", "r")
|
||||
if meminfo then
|
||||
for line in meminfo:lines() do
|
||||
local mem_total = line:match("MemTotal:%s+(%d+)%s+kB")
|
||||
if mem_total then
|
||||
result.memory_mb = math.floor(tonumber(mem_total) / 1024)
|
||||
break
|
||||
end
|
||||
end
|
||||
meminfo:close()
|
||||
end
|
||||
result.memory_ok = result.memory_mb >= MIN_MEMORY_MB
|
||||
|
||||
-- 检测磁盘可用空间
|
||||
-- 优先检测 /opt 所在分区,如果 /opt 不存在则检测 /overlay 或 /
|
||||
local disk_paths = {"/opt", "/overlay", "/"}
|
||||
for _, path in ipairs(disk_paths) do
|
||||
local df_output = sys.exec("df -m " .. path .. " 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
|
||||
if df_output and df_output ~= "" and tonumber(df_output) then
|
||||
result.disk_mb = tonumber(df_output)
|
||||
result.disk_path = path
|
||||
break
|
||||
end
|
||||
end
|
||||
result.disk_ok = result.disk_mb >= MIN_DISK_MB
|
||||
|
||||
-- 综合判断
|
||||
result.pass = result.memory_ok and result.disk_ok
|
||||
|
||||
-- 生成提示信息
|
||||
if result.pass then
|
||||
result.message = "系统配置检测通过"
|
||||
else
|
||||
local issues = {}
|
||||
if not result.memory_ok then
|
||||
table.insert(issues, string.format("内存不足: 当前 %d MB,需要至少 %d MB", result.memory_mb, MIN_MEMORY_MB))
|
||||
end
|
||||
if not result.disk_ok then
|
||||
table.insert(issues, string.format("磁盘空间不足: 当前 %d MB 可用,需要至少 %d MB", result.disk_mb, MIN_DISK_MB))
|
||||
end
|
||||
result.message = table.concat(issues, ";")
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json(result)
|
||||
end
|
||||
|
||||
@ -27,6 +27,7 @@ act.cfgvalue = function(self, section)
|
||||
local uninstall_url = luci.dispatcher.build_url("admin", "services", "openclaw", "uninstall")
|
||||
local plugin_upgrade_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade")
|
||||
local plugin_upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade_log")
|
||||
local check_system_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_system")
|
||||
local html = {}
|
||||
|
||||
-- 按钮区域
|
||||
@ -91,30 +92,64 @@ act.cfgvalue = function(self, section)
|
||||
html[#html+1] = 'document.getElementById("oc-setup-dialog").style.display="none";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'function ocConfirmSetup(){'
|
||||
html[#html+1] = 'ocCloseSetupDialog();'
|
||||
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
|
||||
html[#html+1] = 'var choice="stable";'
|
||||
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].checked){choice=radios[i].value;break;}}'
|
||||
html[#html+1] = 'var verParam=(choice==="stable")?"stable":"latest";'
|
||||
html[#html+1] = 'ocSetup(verParam);'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 安装运行环境 (带实时日志)
|
||||
html[#html+1] = 'function ocSetup(version){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 检测系统配置...";'
|
||||
html[#html+1] = '(new XHR()).get("' .. check_system_url .. '",null,function(x){'
|
||||
html[#html+1] = 'try{'
|
||||
html[#html+1] = 'var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var titleEl=document.getElementById("setup-log-title");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
|
||||
html[#html+1] = 'var actionEl=document.getElementById("action-result");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 安装中...";'
|
||||
html[#html+1] = 'actionEl.textContent="";'
|
||||
html[#html+1] = 'panel.style.display="block";'
|
||||
html[#html+1] = 'logEl.textContent="正在启动安装 ("+((version==="stable")?"稳定版":"最新版")+")...\\n";'
|
||||
html[#html+1] = 'titleEl.textContent="📋 安装日志";'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
|
||||
html[#html+1] = 'resultEl.style.display="none";'
|
||||
html[#html+1] = 'titleEl.textContent="📋 安装日志";'
|
||||
html[#html+1] = 'logEl.textContent="";'
|
||||
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="🔍 系统配置检测\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="内存: "+r.memory_mb+" MB (需要 ≥ 1024 MB) — "+(r.memory_ok?"✅ 通过":"❌ 不达标")+"\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="磁盘: "+r.disk_mb+" MB 可用 (需要 ≥ 1536 MB) — "+(r.disk_ok?"✅ 通过":"❌ 不达标")+"\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="\\n";'
|
||||
html[#html+1] = 'if(!r.pass){'
|
||||
html[#html+1] = 'ocCloseSetupDialog();'
|
||||
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 系统配置不满足要求</span>";'
|
||||
html[#html+1] = 'logEl.textContent+="❌ 系统配置不满足要求,安装已终止\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="💡 请升级硬件配置或清理磁盘空间后重试\\n";'
|
||||
html[#html+1] = 'resultEl.style.display="block";'
|
||||
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 系统配置不满足要求</strong><br/>"+'
|
||||
html[#html+1] = '"<div style=\\"margin-top:8px;font-size:12px;color:#666;\\">💡 请升级硬件配置或清理磁盘空间后重试。</div></div>";'
|
||||
html[#html+1] = 'return;'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
|
||||
html[#html+1] = 'logEl.textContent+="✅ 系统配置检测通过,开始安装...\\n\\n";'
|
||||
html[#html+1] = 'ocCloseSetupDialog();'
|
||||
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
|
||||
html[#html+1] = 'var choice="stable";'
|
||||
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].checked){choice=radios[i].value;break;}}'
|
||||
html[#html+1] = 'var verParam=(choice==="stable")?"stable":"latest";'
|
||||
html[#html+1] = 'ocSetup(verParam,r.memory_mb,r.disk_mb);'
|
||||
html[#html+1] = '}catch(e){'
|
||||
html[#html+1] = 'ocCloseSetupDialog();'
|
||||
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
|
||||
html[#html+1] = 'alert("系统检测失败,请重试");'
|
||||
html[#html+1] = '}});'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 安装运行环境 (带实时日志)
|
||||
html[#html+1] = 'function ocSetup(version,mem_mb,disk_mb){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 安装中...";'
|
||||
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="📦 安装运行环境 ("+((version==="stable")?"稳定版":"最新版")+")\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
|
||||
html[#html+1] = 'logEl.textContent+="正在启动安装...\\n";'
|
||||
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version),null,function(x){'
|
||||
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
|
||||
html[#html+1] = 'ocPollSetupLog();'
|
||||
@ -122,24 +157,30 @@ act.cfgvalue = function(self, section)
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 轮询安装日志
|
||||
html[#html+1] = 'var _lastLogLen=0;'
|
||||
html[#html+1] = 'function ocPollSetupLog(){'
|
||||
html[#html+1] = 'if(_setupTimer)clearInterval(_setupTimer);'
|
||||
html[#html+1] = '_lastLogLen=0;'
|
||||
html[#html+1] = '_setupTimer=setInterval(function(){'
|
||||
html[#html+1] = '(new XHR()).get("' .. log_url .. '",null,function(x){'
|
||||
html[#html+1] = 'try{'
|
||||
html[#html+1] = 'var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'if(r.log)logEl.textContent=r.log;'
|
||||
html[#html+1] = 'if(r.log&&r.log.length>_lastLogLen){'
|
||||
html[#html+1] = 'var newLog=r.log.substring(_lastLogLen);'
|
||||
html[#html+1] = 'logEl.textContent+=newLog;'
|
||||
html[#html+1] = '_lastLogLen=r.log.length;'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;'
|
||||
html[#html+1] = 'if(r.state==="running"){'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
|
||||
html[#html+1] = '}else if(r.state==="success"){'
|
||||
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
|
||||
html[#html+1] = 'ocSetupDone(true,r.log);'
|
||||
html[#html+1] = 'ocSetupDone(true,logEl.textContent);'
|
||||
html[#html+1] = '}else if(r.state==="failed"){'
|
||||
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
|
||||
html[#html+1] = 'ocSetupDone(false,r.log);'
|
||||
html[#html+1] = 'ocSetupDone(false,logEl.textContent);'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){}'
|
||||
html[#html+1] = '});'
|
||||
|
||||
@ -258,24 +258,33 @@ all) gw_bind="custom" ;; # custom = 0.0.0.0
|
||||
esac
|
||||
|
||||
# 确保网关端口未被残留进程占用 (防止 restart 时 crash loop)
|
||||
# v2026.3.14 优化: 快速轮询 + 批量 kill
|
||||
_ensure_port_free() {
|
||||
local p="$1" max_wait="${2:-5}" i=0
|
||||
local p="$1"
|
||||
local i=0 max_wait=10 # 10 * 0.2 = 2 秒
|
||||
|
||||
# 先检查端口是否已被占用
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
|
||||
# 端口被占用,尝试清理
|
||||
for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do
|
||||
kill "$occ_pid" 2>/dev/null
|
||||
done
|
||||
|
||||
while [ $i -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
# 尝试杀掉占用端口的 gateway 进程
|
||||
if [ $i -eq 0 ]; then
|
||||
local occ_pid
|
||||
for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do
|
||||
kill "$occ_pid" 2>/dev/null
|
||||
done
|
||||
fi
|
||||
sleep 1
|
||||
usleep 200000 2>/dev/null || sleep 0.2
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# 最后手段: SIGKILL
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
@ -283,7 +292,7 @@ _ensure_port_free() {
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && usleep 300000 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
_ensure_port_free "$port"
|
||||
@ -350,36 +359,43 @@ stop_service() {
|
||||
local port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
|
||||
# 杀掉所有 openclaw / openclaw-gateway 残留进程
|
||||
# (排除 web-pty.js 和 oc-config.sh,它们由 pty 实例管理)
|
||||
local pid
|
||||
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) \
|
||||
$(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null
|
||||
# v2026.3.14 优化: 快速终止进程树,减少等待时间
|
||||
# 1) 先获取所有相关 PID
|
||||
local gw_pids=""
|
||||
gw_pids=$(pgrep -f "openclaw-gateway" 2>/dev/null)
|
||||
gw_pids="$gw_pids $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null)"
|
||||
|
||||
# 2) 同时发送 SIGTERM 给所有进程
|
||||
for pid in $gw_pids; do
|
||||
[ -n "$pid" ] && kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# 等待端口真正释放 (最多 8 秒)
|
||||
# 3) 快速轮询等待端口释放 (200ms 间隔,最多 3 秒)
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 8 ]; do
|
||||
local max_wait=15 # 15 * 0.2 = 3 秒
|
||||
while [ $wait_count -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
usleep 200000 2>/dev/null || sleep 0.2
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# 如果端口仍被占用,强制杀掉占用者
|
||||
if [ $wait_count -ge 8 ]; then
|
||||
# 4) 如果端口仍被占用,强制 SIGKILL
|
||||
if [ $wait_count -ge $max_wait ]; then
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null
|
||||
sleep 1
|
||||
if [ -n "$port_pid" ]; then
|
||||
kill -9 "$port_pid" 2>/dev/null
|
||||
# 等待内核回收 (缩短到 0.5 秒)
|
||||
usleep 500000 2>/dev/null || sleep 0.5
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@ -390,8 +406,8 @@ procd_add_reload_trigger "openclaw"
|
||||
|
||||
reload_service() {
|
||||
stop
|
||||
# stop_service 已确保端口释放,但额外等待 1 秒让内核回收
|
||||
sleep 1
|
||||
# v2026.3.14: stop_service 已优化等待逻辑,缩短额外等待时间
|
||||
usleep 300000 2>/dev/null || sleep 0.3
|
||||
start
|
||||
}
|
||||
|
||||
@ -407,47 +423,45 @@ restart_gateway() {
|
||||
local port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
|
||||
# ── 第一步: kill 监听端口的 gateway 子进程 (openclaw-gateway) ──
|
||||
# openclaw 启动后会 fork 出 openclaw-gateway 子进程实际监听端口
|
||||
# 必须先杀子进程释放端口,否则 procd respawn 的新实例会因端口冲突而崩溃
|
||||
local port_pid=""
|
||||
# v2026.3.14 优化: 快速终止并重启
|
||||
# 1) 同时获取端口 PID 和 procd 管理的 PID
|
||||
local port_pid="" gw_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null
|
||||
|
||||
# ── 第二步: kill procd 管理的 gateway 主进程 (openclaw) ──
|
||||
# procd 追踪的是主进程 PID,kill 它才能触发 respawn
|
||||
local gw_pid=""
|
||||
gw_pid=$(ubus call service list '{"name":"openclaw"}' 2>/dev/null | \
|
||||
jsonfilter -e '$.openclaw.instances.gateway.pid' 2>/dev/null) || true
|
||||
|
||||
if [ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null; then
|
||||
kill "$gw_pid" 2>/dev/null
|
||||
fi
|
||||
# 2) 同时发送 SIGTERM 给所有相关进程
|
||||
[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null
|
||||
[ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null && kill "$gw_pid" 2>/dev/null
|
||||
|
||||
# ── 第三步: 兜底 — kill 所有 openclaw gateway 相关残留进程 ──
|
||||
# 避免任何残留进程占据端口
|
||||
sleep 1
|
||||
# 3) 兜底: kill 所有残留进程
|
||||
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# ── 第四步: 等待端口真正释放 (最多 5 秒) ──
|
||||
# 4) 快速轮询等待端口释放 (200ms 间隔,最多 2 秒)
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 5 ]; do
|
||||
while [ $wait_count -lt 10 ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
usleep 200000 2>/dev/null || sleep 0.2
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# ── 第五步: 如果 procd 中没有 gateway 服务注册 (首次/崩溃),调用 start ──
|
||||
# 5) 如果端口仍被占用,强制 SIGKILL
|
||||
if [ $wait_count -ge 10 ]; then
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null
|
||||
usleep 300000 2>/dev/null || sleep 0.3
|
||||
fi
|
||||
|
||||
# 6) 如果 procd 中没有 gateway 服务注册,调用 start
|
||||
if [ -z "$gw_pid" ]; then
|
||||
/etc/init.d/openclaw start >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
@ -452,13 +452,8 @@ do_setup() {
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ 安装完成! ║"
|
||||
echo "║ ║"
|
||||
echo "║ 下一步: ║"
|
||||
echo "║ uci set openclaw.main.enabled=1 ║"
|
||||
echo "║ uci commit openclaw ║"
|
||||
echo "║ /etc/init.d/openclaw enable ║"
|
||||
echo "║ /etc/init.d/openclaw start ║"
|
||||
echo "║ ║"
|
||||
echo "║ 或在 LuCI → 服务 → OpenClaw 中启用 ║"
|
||||
echo "║ 如通过 LuCI 安装,服务已自动启用并启动。 ║"
|
||||
echo "║ 如通过命令行安装,请在 LuCI → 服务 → OpenClaw 中启用。 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
}
|
||||
|
||||
@ -724,13 +719,7 @@ do_setup_offline() {
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ 离线安装完成! ║"
|
||||
echo "║ ║"
|
||||
echo "║ 下一步: ║"
|
||||
echo "║ uci set openclaw.main.enabled=1 ║"
|
||||
echo "║ uci commit openclaw ║"
|
||||
echo "║ /etc/init.d/openclaw enable ║"
|
||||
echo "║ /etc/init.d/openclaw start ║"
|
||||
echo "║ ║"
|
||||
echo "║ 或在 LuCI → 服务 → OpenClaw 中启用 ║"
|
||||
echo "║ 请在 LuCI → 服务 → OpenClaw 中启用服务。 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
}
|
||||
|
||||
|
||||
@ -2059,7 +2059,69 @@ reset_to_defaults() {
|
||||
if [ "$confirm" = "yes" ]; then
|
||||
echo ""
|
||||
echo -e " ${CYAN}正在清除渠道配置...${NC}"
|
||||
# 清除 openclaw.json 中的 channels 配置
|
||||
oc_cmd config unset channels >/dev/null 2>&1 || true
|
||||
|
||||
# v2026.3.14: 同时清除 plugins 中与渠道相关的配置
|
||||
# 防止重置后插件配置残留导致状态不一致
|
||||
if [ -f "$CONFIG_FILE" ] && [ -x "$NODE_BIN" ]; then
|
||||
"$NODE_BIN" -e "
|
||||
const fs=require('fs');
|
||||
try{
|
||||
const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8'));
|
||||
let modified=false;
|
||||
|
||||
// 清除 plugins.entries 中与消息渠道相关的插件
|
||||
if(d.plugins && d.plugins.entries){
|
||||
const channelPlugins=['openclaw-qqbot','@tencent-connect/openclaw-qqbot','openclaw-lark','@larksuite/openclaw-lark'];
|
||||
channelPlugins.forEach(p=>{
|
||||
if(d.plugins.entries[p]){
|
||||
delete d.plugins.entries[p];
|
||||
modified=true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清除 plugins.allow 中的渠道插件
|
||||
if(Array.isArray(d.plugins && d.plugins.allow)){
|
||||
const beforeLen=d.plugins.allow.length;
|
||||
d.plugins.allow=d.plugins.allow.filter(p=>
|
||||
!p.includes('qqbot') &&
|
||||
!p.includes('lark') &&
|
||||
!p.includes('telegram') &&
|
||||
!p.includes('discord') &&
|
||||
!p.includes('slack') &&
|
||||
!p.includes('whatsapp')
|
||||
);
|
||||
if(d.plugins.allow.length!==beforeLen)modified=true;
|
||||
}
|
||||
|
||||
if(modified){
|
||||
fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2));
|
||||
console.log('CLEANED');
|
||||
}
|
||||
}catch(e){}
|
||||
" 2>/dev/null
|
||||
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 清除飞书扩展目录中的敏感数据 (保留插件本体)
|
||||
local feishu_ext_dir="${OC_STATE_DIR}/extensions/openclaw-lark"
|
||||
if [ -d "$feishu_ext_dir" ]; then
|
||||
# 只清除配置文件,保留插件代码
|
||||
rm -f "${feishu_ext_dir}/.credentials"* 2>/dev/null
|
||||
rm -f "${feishu_ext_dir}/config.json" 2>/dev/null
|
||||
rm -rf "${feishu_ext_dir}/.cache" 2>/dev/null
|
||||
echo -e " ${CYAN}已清理飞书插件缓存数据${NC}"
|
||||
fi
|
||||
|
||||
# 清除 QQ 机器人扩展目录中的敏感数据
|
||||
local qqbot_ext_dir="${OC_STATE_DIR}/extensions/openclaw-qqbot"
|
||||
if [ -d "$qqbot_ext_dir" ]; then
|
||||
rm -f "${qqbot_ext_dir}/credentials"* 2>/dev/null
|
||||
rm -f "${qqbot_ext_dir}/config.json" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✅ 渠道配置已清除${NC}"
|
||||
echo -e " ${YELLOW}请通过菜单 [4] 重新配置消息渠道${NC}"
|
||||
ask_restart
|
||||
|
||||
29
luci-app-openclawmgr/Makefile
Normal file
29
luci-app-openclawmgr/Makefile
Normal file
@ -0,0 +1,29 @@
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_VERSION:=0.1.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
LUCI_TITLE:=LuCI support for OpenClaw Launcher
|
||||
LUCI_PKGARCH:=all
|
||||
LUCI_DEPENDS:=+luci-base +luci-compat +luci-lib-taskd +curl +ca-bundle +tar +xz-utils +jsonfilter
|
||||
|
||||
define Package/luci-app-openclawmgr/conffiles
|
||||
/etc/config/openclawmgr
|
||||
endef
|
||||
|
||||
define Package/luci-app-openclawmgr/prerm
|
||||
#!/bin/sh
|
||||
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
endef
|
||||
|
||||
define Package/luci-app-openclawmgr/postrm
|
||||
#!/bin/sh
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
exit 0
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@ -0,0 +1,583 @@
|
||||
.oclm-app {
|
||||
--oclm-bg: #f5f7fb;
|
||||
--oclm-card: #ffffff;
|
||||
--oclm-border: #e7ebf3;
|
||||
--oclm-text: #1f2937;
|
||||
--oclm-subtle: #6b7280;
|
||||
--oclm-line: #d9e1ee;
|
||||
--oclm-primary: #2f67f6;
|
||||
--oclm-primary-strong: #1f4fd4;
|
||||
--oclm-danger: #ef4444;
|
||||
--oclm-success: #12b981;
|
||||
--oclm-warning: #f59e0b;
|
||||
--oclm-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
color: var(--oclm-text);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.oclm-app[data-darkmode="true"] {
|
||||
--oclm-bg: #101722;
|
||||
--oclm-card: #182130;
|
||||
--oclm-border: #2a3446;
|
||||
--oclm-text: #edf2f7;
|
||||
--oclm-subtle: #aeb8c8;
|
||||
--oclm-line: #314056;
|
||||
--oclm-primary: #5b86ff;
|
||||
--oclm-primary-strong: #7ea0ff;
|
||||
--oclm-danger: #ff7070;
|
||||
--oclm-success: #34d399;
|
||||
--oclm-warning: #fbbf24;
|
||||
--oclm-shadow: 0 10px 28px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.oclm-root {
|
||||
min-height: 100%;
|
||||
background: linear-gradient(180deg, rgba(47, 103, 246, 0.06), transparent 180px), var(--oclm-bg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.oclm-shell {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.oclm-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.oclm-header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.oclm-header p {
|
||||
margin: 0;
|
||||
color: var(--oclm-subtle);
|
||||
}
|
||||
|
||||
.oclm-card {
|
||||
background: var(--oclm-card);
|
||||
border: 1px solid var(--oclm-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--oclm-shadow);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.oclm-card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.oclm-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.oclm-tab {
|
||||
appearance: none;
|
||||
border: 1px solid var(--oclm-border);
|
||||
background: transparent;
|
||||
color: var(--oclm-subtle);
|
||||
border-radius: 999px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: .16s ease;
|
||||
}
|
||||
|
||||
.oclm-tab.is-active {
|
||||
background: rgba(47, 103, 246, 0.10);
|
||||
color: var(--oclm-primary);
|
||||
border-color: rgba(47, 103, 246, 0.22);
|
||||
}
|
||||
|
||||
.oclm-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.oclm-status-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oclm-status-label,
|
||||
.oclm-meta-label {
|
||||
color: var(--oclm-subtle);
|
||||
font-size: 12px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.oclm-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.oclm-version-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oclm-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 103, 246, 0.08);
|
||||
color: var(--oclm-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.oclm-tag-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.oclm-tag strong {
|
||||
color: var(--oclm-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.oclm-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--oclm-warning);
|
||||
}
|
||||
|
||||
.oclm-status-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(47, 103, 246, 0.18);
|
||||
border-top-color: var(--oclm-primary);
|
||||
animation: oclm-status-spin var(--oclm-spin-duration, 15s) linear infinite;
|
||||
flex: 0 0 14px;
|
||||
}
|
||||
|
||||
.oclm-dot.is-success { background: var(--oclm-success); }
|
||||
.oclm-dot.is-danger { background: var(--oclm-danger); }
|
||||
|
||||
@keyframes oclm-status-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.oclm-status-actions,
|
||||
.oclm-danger-actions,
|
||||
.oclm-origin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oclm-status-note {
|
||||
margin-top: 10px;
|
||||
color: var(--oclm-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oclm-install-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oclm-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
color: var(--oclm-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oclm-button {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--oclm-border);
|
||||
background: #fff;
|
||||
color: var(--oclm-text);
|
||||
border-radius: 10px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: .16s ease;
|
||||
}
|
||||
|
||||
.oclm-button-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
|
||||
.oclm-title-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
}
|
||||
|
||||
.oclm-app[data-darkmode="true"] .oclm-button {
|
||||
background: #162132;
|
||||
}
|
||||
|
||||
.oclm-button:hover {
|
||||
border-color: var(--oclm-primary);
|
||||
color: var(--oclm-primary);
|
||||
}
|
||||
|
||||
.oclm-button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.oclm-button-primary {
|
||||
background: linear-gradient(180deg, var(--oclm-primary), var(--oclm-primary-strong));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.oclm-button-primary:hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.oclm-button-danger {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #ff5d6d, #ef4444);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.oclm-form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oclm-field {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.oclm-label {
|
||||
padding-top: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oclm-control,
|
||||
.oclm-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--oclm-line);
|
||||
border-radius: 12px;
|
||||
background: var(--oclm-card);
|
||||
color: var(--oclm-text);
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.oclm-control::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.oclm-select {
|
||||
padding-right: 36px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8ea0bf 50%),
|
||||
linear-gradient(135deg, #8ea0bf 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 2px),
|
||||
calc(100% - 12px) calc(50% - 2px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.oclm-control:focus,
|
||||
.oclm-select:focus {
|
||||
border-color: var(--oclm-primary);
|
||||
box-shadow: 0 0 0 3px rgba(47, 103, 246, 0.14);
|
||||
}
|
||||
|
||||
.oclm-password-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oclm-eye-button {
|
||||
min-width: 52px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.oclm-eye-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.oclm-hint {
|
||||
margin-top: 6px;
|
||||
color: var(--oclm-subtle);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.oclm-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oclm-toggle input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oclm-toggle-track {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: #d5dbe8;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||
position: relative;
|
||||
transition: background-color .16s ease;
|
||||
}
|
||||
|
||||
.oclm-app[data-darkmode="true"] .oclm-toggle-track {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.oclm-toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.22);
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
|
||||
.oclm-toggle input:checked + .oclm-toggle-track {
|
||||
background: var(--oclm-primary);
|
||||
}
|
||||
|
||||
.oclm-toggle input:checked + .oclm-toggle-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.oclm-inline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oclm-address-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oclm-address-text {
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oclm-icon-button {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--oclm-border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--oclm-subtle);
|
||||
cursor: pointer;
|
||||
flex: 0 0 26px;
|
||||
}
|
||||
|
||||
.oclm-icon-button:hover {
|
||||
color: var(--oclm-primary);
|
||||
border-color: rgba(47, 103, 246, 0.24);
|
||||
}
|
||||
|
||||
.oclm-icon-button.is-copied {
|
||||
color: var(--oclm-success);
|
||||
border-color: rgba(18, 185, 129, 0.35);
|
||||
background: rgba(18, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.oclm-copy-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.oclm-origin-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oclm-origin-item,
|
||||
.oclm-origin-new {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oclm-origin-item button,
|
||||
.oclm-origin-new button {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.oclm-section-submit {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oclm-applied-hint {
|
||||
color: var(--oclm-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oclm-banner {
|
||||
color: var(--oclm-danger);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.oclm-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.oclm-log-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
.oclm-log-dialog {
|
||||
width: min(860px, 100%);
|
||||
max-height: 82vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
background: var(--oclm-card);
|
||||
border: 1px solid var(--oclm-border);
|
||||
box-shadow: var(--oclm-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oclm-log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--oclm-border);
|
||||
}
|
||||
|
||||
.oclm-log-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.oclm-log-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oclm-log-body {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.oclm-log-body pre {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
color: #e2e8f0;
|
||||
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.oclm-root {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.oclm-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.oclm-status-grid,
|
||||
.oclm-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oclm-log-modal {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.oclm-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,798 @@
|
||||
(function() {
|
||||
var config = window.openclawmgrConfig || {};
|
||||
var container = document.getElementById("openclawmgr-app");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
function parseRgb(value) {
|
||||
var m = value && value.match(/\d+/g);
|
||||
if (m && m.length >= 3) {
|
||||
return { r: parseInt(m[0], 10), g: parseInt(m[1], 10), b: parseInt(m[2], 10) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectDarkMode() {
|
||||
try {
|
||||
var bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
var rgb = parseRgb(bg);
|
||||
if (rgb) {
|
||||
var luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
|
||||
return luminance < 128;
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = container.attachShadow ? (container.shadowRoot || container.attachShadow({ mode: "open" })) : container;
|
||||
var state = {
|
||||
status: null,
|
||||
form: null,
|
||||
options: null,
|
||||
newOrigin: "",
|
||||
activeTab: "basic",
|
||||
savingSection: "",
|
||||
lastAppliedAt: "",
|
||||
showLogModal: false,
|
||||
logText: "",
|
||||
logTimer: null,
|
||||
statusTimer: null,
|
||||
installWatchTimer: null
|
||||
};
|
||||
var styleText = "";
|
||||
|
||||
function request(url, options) {
|
||||
options = options || {};
|
||||
options.credentials = "same-origin";
|
||||
options.headers = options.headers || {};
|
||||
if (config.token) {
|
||||
options.headers["X-LuCI-Token"] = config.token;
|
||||
}
|
||||
return fetch(url, options).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
function postJson(url, payload) {
|
||||
payload = payload || {};
|
||||
if (config.token && payload.token == null) {
|
||||
payload.token = config.token;
|
||||
}
|
||||
return request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
function postForm(url, payload) {
|
||||
var body = new URLSearchParams();
|
||||
payload = payload || {};
|
||||
if (config.token && payload.token == null) {
|
||||
payload.token = config.token;
|
||||
}
|
||||
Object.keys(payload || {}).forEach(function(key) {
|
||||
body.append(key, payload[key]);
|
||||
});
|
||||
return request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
|
||||
},
|
||||
body: body.toString()
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function modelForAgent(agent) {
|
||||
return {
|
||||
openai: "openai/gpt-5.2",
|
||||
anthropic: "anthropic/claude-sonnet-4-6",
|
||||
"minimax-cn": "minimax-cn/MiniMax-M2.5",
|
||||
moonshot: "moonshot/kimi-k2.5"
|
||||
}[agent] || "anthropic/claude-sonnet-4-6";
|
||||
}
|
||||
|
||||
function modelMatchesAgent(agent, model) {
|
||||
model = String(model || "");
|
||||
return {
|
||||
openai: /^openai\//,
|
||||
anthropic: /^anthropic\//,
|
||||
"minimax-cn": /^minimax-cn\//,
|
||||
moonshot: /^moonshot\//
|
||||
}[agent] ? ({
|
||||
openai: /^openai\//,
|
||||
anthropic: /^anthropic\//,
|
||||
"minimax-cn": /^minimax-cn\//,
|
||||
moonshot: /^moonshot\//
|
||||
}[agent]).test(model) : false;
|
||||
}
|
||||
|
||||
function resolveModelValue(form) {
|
||||
var agent = form && form.default_agent ? form.default_agent : "anthropic";
|
||||
var value = form && form.default_model ? String(form.default_model) : "";
|
||||
if (!value || !modelMatchesAgent(agent, value)) {
|
||||
return modelForAgent(agent);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
if (status.installing) return "安装中";
|
||||
if (!status || !status.installed) return "未安装";
|
||||
if (status.running) return "运行中";
|
||||
return "已停止";
|
||||
}
|
||||
|
||||
function statusDotClass(status) {
|
||||
if (status && status.installing) return "";
|
||||
if (status && status.running) return "is-success";
|
||||
return "is-danger";
|
||||
}
|
||||
|
||||
function statusSpinSeconds(status) {
|
||||
return (backgroundStatusDelay(status) / 1000).toFixed(1);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function maskedTokenUrl(url) {
|
||||
var value = String(url || "");
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.replace(/(#token=)([^&#]+)/, function(_, prefix, token) {
|
||||
if (!token) {
|
||||
return prefix;
|
||||
}
|
||||
var head = token.slice(0, 6);
|
||||
var tail = token.length > 10 ? token.slice(-4) : "";
|
||||
return prefix + head + "****" + tail;
|
||||
});
|
||||
}
|
||||
|
||||
function copyIcon(className) {
|
||||
return '' +
|
||||
'<svg class="' + escapeAttr(className || "") + '" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||
'<rect x="9" y="9" width="10" height="10" rx="2" stroke="currentColor" stroke-width="1.8"></rect>' +
|
||||
'<path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
function copiedIcon(className) {
|
||||
return '' +
|
||||
'<svg class="' + escapeAttr(className || "") + '" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||
'<path d="M5 12.5l4 4L19 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
function fallbackCopyText(value) {
|
||||
var el = document.createElement("textarea");
|
||||
el.value = String(value || "");
|
||||
el.setAttribute("readonly", "readonly");
|
||||
el.style.position = "fixed";
|
||||
el.style.top = "-9999px";
|
||||
el.style.left = "-9999px";
|
||||
document.body.appendChild(el);
|
||||
el.focus();
|
||||
el.select();
|
||||
var ok = false;
|
||||
try {
|
||||
ok = document.execCommand("copy");
|
||||
} catch (e) {}
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function copyText(value) {
|
||||
value = String(value || "");
|
||||
if (!value) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(value).then(function() {
|
||||
return true;
|
||||
}).catch(function() {
|
||||
return fallbackCopyText(value);
|
||||
});
|
||||
}
|
||||
return Promise.resolve(fallbackCopyText(value));
|
||||
}
|
||||
|
||||
function flashCopied(el) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (el._oclmCopyTimer) {
|
||||
window.clearTimeout(el._oclmCopyTimer);
|
||||
el._oclmCopyTimer = null;
|
||||
}
|
||||
el.classList.add("is-copied");
|
||||
el.innerHTML = copiedIcon("oclm-copy-icon");
|
||||
el._oclmCopyTimer = window.setTimeout(function() {
|
||||
el.classList.remove("is-copied");
|
||||
el.innerHTML = copyIcon("oclm-copy-icon");
|
||||
el._oclmCopyTimer = null;
|
||||
}, 1100);
|
||||
}
|
||||
|
||||
function openclawIcon(className) {
|
||||
return '' +
|
||||
'<svg class="' + escapeAttr(className || "") + '" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||
'<defs>' +
|
||||
'<linearGradient id="oclm-lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">' +
|
||||
'<stop offset="0%" stop-color="#ff4d4d"/>' +
|
||||
'<stop offset="100%" stop-color="#991b1b"/>' +
|
||||
'</linearGradient>' +
|
||||
'</defs>' +
|
||||
'<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#oclm-lobster-gradient)"/>' +
|
||||
'<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#oclm-lobster-gradient)"/>' +
|
||||
'<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#oclm-lobster-gradient)"/>' +
|
||||
'<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>' +
|
||||
'<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>' +
|
||||
'<circle cx="45" cy="35" r="6" fill="#050810"/>' +
|
||||
'<circle cx="75" cy="35" r="6" fill="#050810"/>' +
|
||||
'<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>' +
|
||||
'<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
function openLogModal() {
|
||||
state.showLogModal = true;
|
||||
if (state.logTimer) {
|
||||
window.clearTimeout(state.logTimer);
|
||||
state.logTimer = null;
|
||||
}
|
||||
pollLogs();
|
||||
render();
|
||||
}
|
||||
|
||||
function closeLogModal() {
|
||||
state.showLogModal = false;
|
||||
if (state.logTimer) {
|
||||
window.clearTimeout(state.logTimer);
|
||||
state.logTimer = null;
|
||||
}
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
function pollLogs() {
|
||||
if (!state.showLogModal) {
|
||||
return;
|
||||
}
|
||||
if (state.logTimer) {
|
||||
window.clearTimeout(state.logTimer);
|
||||
state.logTimer = null;
|
||||
}
|
||||
request(config.logUrl + "?n=200").then(function(data) {
|
||||
state.logText = (data && data.log) || "";
|
||||
refreshStatus(function() {
|
||||
if (state.showLogModal) {
|
||||
state.logTimer = window.setTimeout(pollLogs, 3000);
|
||||
}
|
||||
});
|
||||
}).catch(function() {
|
||||
if (state.showLogModal) {
|
||||
state.logTimer = window.setTimeout(pollLogs, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (state.statusTimer) {
|
||||
window.clearTimeout(state.statusTimer);
|
||||
state.statusTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopInstallWatch() {
|
||||
if (state.installWatchTimer) {
|
||||
window.clearTimeout(state.installWatchTimer);
|
||||
state.installWatchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function backgroundStatusDelay(status) {
|
||||
if (status && status.installing) {
|
||||
return 3000;
|
||||
}
|
||||
if (!status || !status.installed) {
|
||||
return 30000;
|
||||
}
|
||||
if (status.running) {
|
||||
return 15000;
|
||||
}
|
||||
return 20000;
|
||||
}
|
||||
|
||||
function ensureInstallWatch() {
|
||||
stopInstallWatch();
|
||||
if (state.status && state.status.installing && state.showLogModal) {
|
||||
return;
|
||||
}
|
||||
state.installWatchTimer = window.setTimeout(function() {
|
||||
refreshStatus(function() {
|
||||
ensureInstallWatch();
|
||||
});
|
||||
}, backgroundStatusDelay(state.status));
|
||||
}
|
||||
|
||||
function scheduleStatusRefresh(rounds, delay) {
|
||||
rounds = typeof rounds === "number" ? rounds : 6;
|
||||
delay = typeof delay === "number" ? delay : 1000;
|
||||
stopStatusPolling();
|
||||
|
||||
function tick(remaining) {
|
||||
refreshStatus(function() {
|
||||
if (remaining > 1) {
|
||||
state.statusTimer = window.setTimeout(function() {
|
||||
tick(remaining - 1);
|
||||
}, delay);
|
||||
} else {
|
||||
state.statusTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tick(rounds);
|
||||
}
|
||||
|
||||
function scrollLogToBottom() {
|
||||
if (!state.showLogModal) {
|
||||
return;
|
||||
}
|
||||
var body = root.querySelector(".oclm-log-body");
|
||||
if (body) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
var status = state.status || {};
|
||||
var form = state.form || {};
|
||||
var options = state.options || { base_dir_choices: [] };
|
||||
var allowedOrigins = Array.isArray(form.allowed_origins) ? form.allowed_origins : [];
|
||||
var baseDirOptions = (options.base_dir_choices || []).map(function(path) {
|
||||
return '<option value="' + escapeHtml(path) + '"></option>';
|
||||
}).join("");
|
||||
var origins = allowedOrigins.map(function(item, index) {
|
||||
return '' +
|
||||
'<div class="oclm-origin-item">' +
|
||||
'<input class="oclm-control" type="text" value="' + escapeHtml(item) + '" data-origin-index="' + index + '" />' +
|
||||
'<button class="oclm-button oclm-button-danger" type="button" data-remove-origin="' + index + '">删除</button>' +
|
||||
'</div>';
|
||||
}).join("");
|
||||
|
||||
var installLabel = status.installing ? "安装中" : "立即安装";
|
||||
var installAcceleratedChecked = form.install_accelerated == null ? true : form.install_accelerated === true;
|
||||
var showInstallAction = !status.installed || status.installing;
|
||||
var showServiceActions = status.installed && !status.installing;
|
||||
var activeTab = state.activeTab || "basic";
|
||||
var savingBasic = state.savingSection === "basic";
|
||||
var savingAccess = state.savingSection === "access";
|
||||
|
||||
root.innerHTML =
|
||||
'<style>' + styleText + '</style>' +
|
||||
'<div class="oclm-app"' + (detectDarkMode() ? ' data-darkmode="true"' : '') + '>' +
|
||||
'<div class="oclm-root">' +
|
||||
'<div class="oclm-shell">' +
|
||||
'<div class="oclm-header">' +
|
||||
'<h1>' + openclawIcon("oclm-title-icon") + 'OpenClaw 启动器</h1>' +
|
||||
'<p>在 OpenWrt 上原生安装、启动并管理官方 OpenClaw</p>' +
|
||||
'</div>' +
|
||||
|
||||
'<section class="oclm-card">' +
|
||||
'<h2>服务状态</h2>' +
|
||||
'<div class="oclm-status-grid">' +
|
||||
'<div class="oclm-status-row"><span class="oclm-status-label">状态</span><span class="oclm-status-pill" style="--oclm-spin-duration:' + escapeAttr(statusSpinSeconds(status)) + 's"><span class="oclm-dot ' + statusDotClass(status) + '"></span><span class="oclm-status-spinner" aria-hidden="true"></span>' + escapeHtml(statusText(status)) + '</span>' + (status.running && status.uptime_human ? ('<span class="oclm-tag">运行时间 <strong>' + escapeHtml(status.uptime_human) + '</strong></span>') : '') + '</div>' +
|
||||
'<div>' + ((status.running && status.pid) ? ('<div class="oclm-inline-row"><span class="oclm-tag">PID <strong>' + escapeHtml(status.pid) + '</strong></span></div>') : '') + '</div>' +
|
||||
'<div><div class="oclm-meta-label">地址</div><div class="oclm-address-row"><span class="oclm-address-text">' + escapeHtml(maskedTokenUrl(status.token_url || status.base_url || "")) + '</span>' + ((status.token_url || status.base_url) ? ('<button class="oclm-icon-button" type="button" data-copy-token-url="1" aria-label="复制地址">' + copyIcon("oclm-copy-icon") + '</button>') : '') + '</div></div>' +
|
||||
'<div><div class="oclm-meta-label">目录</div><div>' + escapeHtml(status.base_dir || "-") + '</div></div>' +
|
||||
'<div><div class="oclm-meta-label">版本</div><div class="oclm-version-tags"><a class="oclm-tag oclm-tag-link" href="https://github.com/openclaw/openclaw/releases" target="_blank" rel="noreferrer">OpenClaw <strong>' + escapeHtml(status.openclaw_version || "-") + '</strong></a><span class="oclm-tag">Node <strong>' + escapeHtml(status.node_version || "-") + '</strong></span></div></div>' +
|
||||
'<div></div>' +
|
||||
'</div>' +
|
||||
'<div class="oclm-status-actions">' +
|
||||
(showInstallAction ? '<div class="oclm-install-inline"><button class="oclm-button oclm-button-primary" type="button" data-install-action="1">' + installLabel + '</button><label class="oclm-check"><input type="checkbox" id="oclm-install-accelerated"' + (installAcceleratedChecked ? ' checked' : '') + ' />Kspeeder 加速安装</label></div>' : '') +
|
||||
(status.running ? '<a class="oclm-button oclm-button-primary" href="' + escapeAttr(status.token_url || "#") + '" target="_blank" rel="noreferrer">' + openclawIcon("oclm-button-icon") + '打开控制台</a>' : '') +
|
||||
(!status.running && showServiceActions ? '<button class="oclm-button" type="button" data-op="start">启动服务</button>' : '') +
|
||||
(status.running && showServiceActions ? '<button class="oclm-button" type="button" data-op="stop">停止服务</button>' : '') +
|
||||
(showServiceActions ? '<button class="oclm-button" type="button" data-op="restart">重启服务</button>' : '') +
|
||||
(status.installing ? '<button class="oclm-button oclm-button-danger" type="button" data-op="cancel_install">停止安装</button>' : '') +
|
||||
'</div>' +
|
||||
(status.installing ? '<div class="oclm-status-note">安装任务正在后台运行,点击“安装中”可继续查看日志。</div>' : '') +
|
||||
'</section>' +
|
||||
|
||||
'<section class="oclm-card">' +
|
||||
'<div class="oclm-tabs">' +
|
||||
'<button class="oclm-tab' + (activeTab === "basic" ? ' is-active' : '') + '" type="button" data-tab="basic">基础配置</button>' +
|
||||
'<button class="oclm-tab' + (activeTab === "access" ? ' is-active' : '') + '" type="button" data-tab="access">访问控制</button>' +
|
||||
'<button class="oclm-tab' + (activeTab === "cleanup" ? ' is-active' : '') + '" type="button" data-tab="cleanup">卸载清理</button>' +
|
||||
'</div>' +
|
||||
'<div class="' + (activeTab === "basic" ? '' : 'oclm-hidden') + '">' +
|
||||
'<h2>基础配置</h2>' +
|
||||
'<div class="oclm-form-grid">' +
|
||||
fieldToggle("启用服务", "enabled", form.enabled) +
|
||||
fieldInput("监听端口", '<input class="oclm-control" type="number" min="1" max="65535" id="oclm-port" value="' + escapeHtml(form.port || "18789") + '" />') +
|
||||
fieldInput("监听范围", selectHtml("oclm-bind", form.bind, [
|
||||
["lan", "所有地址"],
|
||||
["loopback", "仅本机"],
|
||||
["auto", "自动"]
|
||||
])) +
|
||||
fieldInput("数据目录", '<input class="oclm-control" type="text" id="oclm-base-dir" list="oclm-base-dir-options" value="' + escapeAttr(form.base_dir || "") + '" /><datalist id="oclm-base-dir-options">' + baseDirOptions + '</datalist>') +
|
||||
fieldInput("默认服务提供商", selectHtml("oclm-agent", form.default_agent, [
|
||||
["openai", "OpenAI"],
|
||||
["anthropic", "Anthropic"],
|
||||
["minimax-cn", "MiniMax CN"],
|
||||
["moonshot", "Moonshot CN"]
|
||||
])) +
|
||||
fieldInput("API 密钥", passwordHtml("oclm-api-key", form.provider_api_key || "", "sk-...")) +
|
||||
fieldInput("中转地址(可选)", '<input class="oclm-control" type="text" id="oclm-base-url" value="' + escapeHtml(form.provider_base_url || "") + '" placeholder="https://api.example.com" />') +
|
||||
fieldInput("默认模型", '<input class="oclm-control" type="text" id="oclm-model" value="' + escapeAttr(resolveModelValue(form)) + '" placeholder="请按照<provider>/<model-id>格式填写" />') +
|
||||
'<div class="oclm-section-submit"><button class="oclm-button oclm-button-primary" type="button" id="oclm-save-basic"' + (savingBasic ? ' disabled' : '') + '>' + (savingBasic ? '应用中…' : '保存并应用') + '</button>' + (state.lastAppliedAt ? '<span class="oclm-applied-hint">已于 ' + escapeHtml(state.lastAppliedAt) + ' 更新配置</span>' : '') + '</div>' +
|
||||
'</div></div>' +
|
||||
|
||||
'<div class="' + (activeTab === "access" ? '' : 'oclm-hidden') + '">' +
|
||||
'<h2>访问控制</h2>' +
|
||||
'<div class="oclm-form-grid">' +
|
||||
fieldInput("访问令牌", passwordHtml("oclm-token", form.token || "", "")) +
|
||||
fieldInput("允许访问来源", '<div class="oclm-origin-list">' + origins +
|
||||
'<div class="oclm-origin-new"><input class="oclm-control" type="text" id="oclm-new-origin" value="' + escapeHtml(state.newOrigin || "") + '" placeholder="' + escapeHtml((options.default_origin || "http://192.168.1.1:18789")) + '" /><button class="oclm-button" type="button" id="oclm-add-origin">添加</button></div>' +
|
||||
'<div class="oclm-hint">展示:仅允许来源于该地址访问控制台的控制功能</div></div>') +
|
||||
fieldToggle("允许通过 HTTP 访问时认证", "allow_insecure_auth", form.allow_insecure_auth, "仅控制 HTTP 下的控制台认证,不影响端口监听") +
|
||||
fieldToggle("关闭设备身份校验", "disable_device_auth", form.disable_device_auth, "警告:仅建议在可信环境中开启,在内网生效") +
|
||||
'<div class="oclm-section-submit"><button class="oclm-button oclm-button-primary" type="button" id="oclm-save-access"' + (savingAccess ? ' disabled' : '') + '>' + (savingAccess ? '应用中…' : '保存访问控制设置') + '</button>' + (state.lastAppliedAt ? '<span class="oclm-applied-hint">已于 ' + escapeHtml(state.lastAppliedAt) + ' 更新配置</span>' : '') + '</div>' +
|
||||
'</div></div>' +
|
||||
|
||||
'<div class="' + (activeTab === "cleanup" ? '' : 'oclm-hidden') + '">' +
|
||||
'<h2>卸载清理</h2>' +
|
||||
'<div class="oclm-banner">以下操作会影响当前安装环境,请在确认后执行。</div>' +
|
||||
'<div class="oclm-danger-actions">' +
|
||||
'<button class="oclm-button" type="button" data-op="uninstall_openclaw">卸载 OpenClaw(保留 Node)</button>' +
|
||||
'<button class="oclm-button oclm-button-danger" type="button" data-op="uninstall">卸载</button>' +
|
||||
'<button class="oclm-button oclm-button-danger" type="button" data-op="purge">彻底清理</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</section>' +
|
||||
|
||||
(state.showLogModal ? (
|
||||
'<div class="oclm-log-modal" data-close-log="1">' +
|
||||
'<div class="oclm-log-dialog" onclick="event.stopPropagation()">' +
|
||||
'<div class="oclm-log-head">' +
|
||||
'<h3>安装日志</h3>' +
|
||||
'<div class="oclm-log-actions">' +
|
||||
'<button class="oclm-button" type="button" data-refresh-log="1">刷新</button>' +
|
||||
'<button class="oclm-button" type="button" data-copy-log="1">复制日志</button>' +
|
||||
'<button class="oclm-button" type="button" data-close-log-btn="1">关闭</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="oclm-log-body"><pre>' + escapeHtml(state.logText || "日志为空") + '</pre></div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
) : '') +
|
||||
|
||||
'</div></div></div>';
|
||||
|
||||
bindEvents();
|
||||
scrollLogToBottom();
|
||||
}
|
||||
|
||||
function fieldInput(label, control) {
|
||||
return '<div class="oclm-field"><div class="oclm-label">' + label + '</div><div>' + control + '</div></div>';
|
||||
}
|
||||
|
||||
function fieldToggle(label, key, checked, hint) {
|
||||
return '' +
|
||||
'<div class="oclm-field">' +
|
||||
'<div class="oclm-label">' + label + '</div>' +
|
||||
'<div><label class="oclm-toggle"><input type="checkbox" id="oclm-' + key + '"' + (checked ? ' checked' : '') + ' /><span class="oclm-toggle-track"></span></label>' +
|
||||
(hint ? '<div class="oclm-hint">' + hint + '</div>' : '') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function selectHtml(id, value, items) {
|
||||
return '<select class="oclm-select" id="' + id + '">' + items.map(function(item) {
|
||||
return '<option value="' + escapeHtml(item[0]) + '"' + (value === item[0] ? ' selected' : '') + '>' + escapeHtml(item[1]) + '</option>';
|
||||
}).join("") + '</select>';
|
||||
}
|
||||
|
||||
function passwordHtml(id, value, placeholder) {
|
||||
return '' +
|
||||
'<div class="oclm-password-wrap">' +
|
||||
'<input class="oclm-control" type="password" id="' + id + '" value="' + escapeAttr(value || "") + '" placeholder="' + escapeAttr(placeholder || "") + '" />' +
|
||||
'<button class="oclm-button oclm-eye-button" type="button" data-toggle-password="' + id + '" aria-label="显示密钥">' + eyeIcon(false) + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function eyeIcon(off) {
|
||||
if (off) {
|
||||
return '' +
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
|
||||
'<path d="M3 3l18 18"></path>' +
|
||||
'<path d="M10.6 10.7a3 3 0 0 0 4.2 4.2"></path>' +
|
||||
'<path d="M9.9 5.1A10.9 10.9 0 0 1 12 5c5.2 0 9.3 4.1 10 7-0.3 1.1-1.1 2.6-2.4 3.9"></path>' +
|
||||
'<path d="M6.2 6.2C4.3 7.6 3.2 9.6 2 12c0.7 2.9 4.8 7 10 7 1 0 2-.2 2.9-.5"></path>' +
|
||||
'</svg>';
|
||||
}
|
||||
return '' +
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
|
||||
'<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"></path>' +
|
||||
'<circle cx="12" cy="12" r="3"></circle>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-tab]"), function(el) {
|
||||
el.onclick = function() {
|
||||
state.activeTab = el.getAttribute("data-tab") || "basic";
|
||||
render();
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-op]"), function(el) {
|
||||
el.onclick = function() {
|
||||
var op = el.getAttribute("data-op");
|
||||
if (!op) return;
|
||||
if (op === "uninstall_openclaw" && !window.confirm("卸载 OpenClaw 运行时但保留 Node.js,确认继续?")) return;
|
||||
if (op === "uninstall" && !window.confirm("卸载将删除运行时,但保留数据目录。确认继续?")) return;
|
||||
if (op === "purge" && !window.confirm("彻底清理会删除运行时和数据目录。确认继续?")) return;
|
||||
postForm(config.opUrl, { op: op }).then(function(rv) {
|
||||
if (!rv || !rv.ok) {
|
||||
window.alert((rv && rv.error) || "操作失败");
|
||||
return;
|
||||
}
|
||||
scheduleStatusRefresh(op === "restart" ? 8 : 6, 1000);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-install-action]"), function(el) {
|
||||
el.onclick = function() {
|
||||
openLogModal();
|
||||
var accelerated = !!(root.getElementById("oclm-install-accelerated") && root.getElementById("oclm-install-accelerated").checked);
|
||||
if (!state.status || state.status.installing) {
|
||||
return;
|
||||
}
|
||||
state.status.installing = true;
|
||||
state.status.task_running = true;
|
||||
state.status.task_op = "install";
|
||||
render();
|
||||
postJson(config.configUrl, { install_accelerated: accelerated }).then(function(cfgRv) {
|
||||
if (!cfgRv || !cfgRv.ok) {
|
||||
state.status.installing = false;
|
||||
render();
|
||||
window.alert((cfgRv && cfgRv.error) || "保存安装选项失败");
|
||||
return;
|
||||
}
|
||||
state.form.install_accelerated = accelerated;
|
||||
return postForm(config.opUrl, { op: "install" });
|
||||
}).then(function(rv) {
|
||||
if (!rv) return;
|
||||
if (!rv || !rv.ok) {
|
||||
state.status.installing = false;
|
||||
render();
|
||||
window.alert((rv && rv.error) || "启动安装失败");
|
||||
return;
|
||||
}
|
||||
scheduleStatusRefresh(10, 1000);
|
||||
}).catch(function() {
|
||||
state.status.installing = false;
|
||||
render();
|
||||
window.alert("启动安装失败");
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-close-log], [data-close-log-btn]"), function(el) {
|
||||
el.onclick = function() {
|
||||
closeLogModal();
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-toggle-password]"), function(el) {
|
||||
el.onclick = function() {
|
||||
var id = el.getAttribute("data-toggle-password");
|
||||
var input = id ? root.getElementById(id) : null;
|
||||
if (!input) return;
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
el.innerHTML = eyeIcon(input.type !== "password");
|
||||
el.setAttribute("aria-label", input.type === "password" ? "显示密钥" : "隐藏密钥");
|
||||
};
|
||||
});
|
||||
|
||||
var installAccelerated = root.getElementById("oclm-install-accelerated");
|
||||
if (installAccelerated) {
|
||||
installAccelerated.onchange = function() {
|
||||
state.form.install_accelerated = !!installAccelerated.checked;
|
||||
};
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-refresh-log]"), function(el) {
|
||||
el.onclick = function() {
|
||||
pollLogs();
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-copy-log]"), function(el) {
|
||||
el.onclick = function() {
|
||||
copyText(state.logText || "");
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-copy-token-url]"), function(el) {
|
||||
el.onclick = function() {
|
||||
var value = (state.status && (state.status.token_url || state.status.base_url)) || "";
|
||||
copyText(value).then(function(ok) {
|
||||
if (ok) {
|
||||
flashCopied(el);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
var agent = root.getElementById("oclm-agent");
|
||||
if (agent) {
|
||||
agent.onchange = function() {
|
||||
state.form.default_agent = agent.value;
|
||||
var model = root.getElementById("oclm-model");
|
||||
if (model) {
|
||||
var nextModel = modelForAgent(agent.value);
|
||||
model.value = nextModel;
|
||||
state.form.default_model = nextModel;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var addOrigin = root.getElementById("oclm-add-origin");
|
||||
if (addOrigin) {
|
||||
addOrigin.onclick = function() {
|
||||
var input = root.getElementById("oclm-new-origin");
|
||||
var value = input && input.value ? input.value.trim() : "";
|
||||
if (!value) return;
|
||||
state.form.allowed_origins = Array.isArray(state.form.allowed_origins) ? state.form.allowed_origins : [];
|
||||
state.form.allowed_origins.push(value);
|
||||
state.newOrigin = "";
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-remove-origin]"), function(el) {
|
||||
el.onclick = function() {
|
||||
var index = parseInt(el.getAttribute("data-remove-origin"), 10);
|
||||
if (isNaN(index)) return;
|
||||
state.form.allowed_origins.splice(index, 1);
|
||||
render();
|
||||
};
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-origin-index]"), function(el) {
|
||||
el.oninput = function() {
|
||||
var index = parseInt(el.getAttribute("data-origin-index"), 10);
|
||||
if (!isNaN(index)) {
|
||||
state.form.allowed_origins[index] = el.value;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var saveBasic = root.getElementById("oclm-save-basic");
|
||||
if (saveBasic) {
|
||||
saveBasic.onclick = function() {
|
||||
var payload = {
|
||||
enabled: !!root.getElementById("oclm-enabled").checked,
|
||||
port: root.getElementById("oclm-port").value,
|
||||
bind: root.getElementById("oclm-bind").value,
|
||||
base_dir: root.getElementById("oclm-base-dir").value,
|
||||
default_agent: root.getElementById("oclm-agent").value,
|
||||
default_model: root.getElementById("oclm-model").value,
|
||||
install_accelerated: !!(root.getElementById("oclm-install-accelerated") && root.getElementById("oclm-install-accelerated").checked),
|
||||
provider_api_key: root.getElementById("oclm-api-key").value,
|
||||
provider_base_url: root.getElementById("oclm-base-url").value
|
||||
};
|
||||
state.savingSection = "basic";
|
||||
render();
|
||||
postJson(config.configUrl, payload).then(function(rv) { handleSaveResult(rv, "basic"); }).catch(function() {
|
||||
state.savingSection = "";
|
||||
render();
|
||||
window.alert("保存失败");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var saveAccess = root.getElementById("oclm-save-access");
|
||||
if (saveAccess) {
|
||||
saveAccess.onclick = function() {
|
||||
var payload = {
|
||||
token: root.getElementById("oclm-token").value,
|
||||
allowed_origins: (Array.isArray(state.form.allowed_origins) ? state.form.allowed_origins : []).map(function(item) { return item.trim(); }).filter(Boolean),
|
||||
allow_insecure_auth: !!root.getElementById("oclm-allow_insecure_auth").checked,
|
||||
disable_device_auth: !!root.getElementById("oclm-disable_device_auth").checked
|
||||
};
|
||||
state.savingSection = "access";
|
||||
render();
|
||||
postJson(config.configUrl, payload).then(function(rv) { handleSaveResult(rv, "access"); }).catch(function() {
|
||||
state.savingSection = "";
|
||||
render();
|
||||
window.alert("保存失败");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveResult(rv, section) {
|
||||
if (!rv || !rv.ok) {
|
||||
state.savingSection = "";
|
||||
render();
|
||||
window.alert((rv && rv.error) || "保存失败");
|
||||
return;
|
||||
}
|
||||
postForm(config.applyUrl, {}).then(function(applyRv) {
|
||||
state.savingSection = "";
|
||||
if (!applyRv || !applyRv.ok) {
|
||||
render();
|
||||
window.alert((applyRv && applyRv.error) || "应用配置失败");
|
||||
loadConfig();
|
||||
scheduleStatusRefresh(3, 600);
|
||||
return;
|
||||
}
|
||||
state.lastAppliedAt = applyRv.applied_at || "";
|
||||
render();
|
||||
loadConfig();
|
||||
scheduleStatusRefresh(6, 800);
|
||||
}).catch(function() {
|
||||
state.savingSection = "";
|
||||
render();
|
||||
window.alert("应用配置失败");
|
||||
loadConfig();
|
||||
scheduleStatusRefresh(3, 600);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshStatus(done) {
|
||||
request(config.statusUrl).then(function(data) {
|
||||
state.status = data || {};
|
||||
render();
|
||||
ensureInstallWatch();
|
||||
if (typeof done === "function") {
|
||||
done();
|
||||
}
|
||||
}).catch(function() {
|
||||
state.status = state.status || {};
|
||||
render();
|
||||
ensureInstallWatch();
|
||||
if (typeof done === "function") {
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
request(config.configUrl).then(function(data) {
|
||||
if (!data || !data.ok) {
|
||||
return;
|
||||
}
|
||||
state.form = data.config || {};
|
||||
state.form.default_model = resolveModelValue(state.form);
|
||||
state.options = data.options || {};
|
||||
render();
|
||||
refreshStatus();
|
||||
}).catch(function() {
|
||||
state.form = state.form || {};
|
||||
state.options = state.options || { base_dir_choices: [] };
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
fetch(config.staticBase + "/app.css").then(function(r) { return r.text(); }).then(function(css) {
|
||||
styleText = css || "";
|
||||
loadConfig();
|
||||
});
|
||||
})();
|
||||
@ -0,0 +1,17 @@
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
784
luci-app-openclawmgr/luasrc/controller/openclawmgr.lua
Normal file
784
luci-app-openclawmgr/luasrc/controller/openclawmgr.lua
Normal file
@ -0,0 +1,784 @@
|
||||
module("luci.controller.openclawmgr", package.seeall)
|
||||
|
||||
function index()
|
||||
local fs = require "nixio.fs"
|
||||
if not fs.access("/etc/config/openclawmgr") then
|
||||
return
|
||||
end
|
||||
|
||||
entry({"admin", "services", "openclawmgr"}, alias("admin", "services", "openclawmgr", "config"), _("OpenClaw 启动器"), 90).dependent = true
|
||||
local page = entry({"admin", "services", "openclawmgr", "config"}, call("action_app"), _("Config"), 10)
|
||||
page.leaf = true
|
||||
|
||||
entry({"admin", "services", "openclawmgr", "status"}, call("action_status")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "op"}, call("action_op")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "config_data"}, call("action_config_data")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "apply_config"}, call("action_apply_config")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "log"}, call("action_log")).leaf = true
|
||||
|
||||
entry({"admin", "services", "openclawmgr", "diag_info"}, call("action_diag_info")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "diag_run"}, call("action_diag_run")).leaf = true
|
||||
entry({"admin", "services", "openclawmgr", "diag_poll"}, call("action_diag_poll")).leaf = true
|
||||
end
|
||||
|
||||
function action_app()
|
||||
local http = require "luci.http"
|
||||
local tmpl = require "luci.template"
|
||||
local disp = require "luci.dispatcher"
|
||||
local ctx = disp.context or {}
|
||||
|
||||
tmpl.render("openclawmgr/app", {
|
||||
token = ctx.token or "",
|
||||
})
|
||||
http.close()
|
||||
end
|
||||
|
||||
local function lan_ipv4()
|
||||
local sys = require "luci.sys"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local raw = sys.exec("ubus call network.interface.lan status 2>/dev/null")
|
||||
local obj = jsonc.parse(raw)
|
||||
if obj and obj["ipv4-address"] then
|
||||
for _, addr in ipairs(obj["ipv4-address"]) do
|
||||
if addr.address and addr.address ~= "" then
|
||||
return addr.address
|
||||
end
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function default_allowed_origin(port_val)
|
||||
local ip = lan_ipv4()
|
||||
if ip ~= "" then
|
||||
return "http://" .. ip .. ":" .. port_val
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function write_json(obj)
|
||||
local http = require "luci.http"
|
||||
http.prepare_content("application/json")
|
||||
http.write_json(obj)
|
||||
end
|
||||
|
||||
local function read_json_body()
|
||||
local http = require "luci.http"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local ctype = http.getenv("CONTENT_TYPE") or ""
|
||||
if not ctype:match("^application/json") then
|
||||
return nil
|
||||
end
|
||||
local raw = http.content() or ""
|
||||
if #raw == 0 then
|
||||
return nil
|
||||
end
|
||||
local obj = jsonc.parse(raw)
|
||||
if type(obj) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
return obj
|
||||
end
|
||||
|
||||
local function get_task_state(task_id)
|
||||
local fs = require "nixio.fs"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
if not fs.access("/etc/init.d/tasks") then
|
||||
return {
|
||||
running = false,
|
||||
op = "",
|
||||
command = "",
|
||||
}
|
||||
end
|
||||
|
||||
local raw = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null")
|
||||
local obj = jsonc.parse(raw) or {}
|
||||
local cmd = ""
|
||||
local cmd_parts = {}
|
||||
|
||||
local function as_bool(v)
|
||||
return v == true or v == "true" or v == "1" or v == 1
|
||||
end
|
||||
|
||||
if type(obj.command) == "table" then
|
||||
cmd_parts = obj.command
|
||||
cmd = table.concat(obj.command, " ")
|
||||
elseif type(obj.command) == "string" then
|
||||
cmd = obj.command
|
||||
elseif type(obj.data) == "table" and type(obj.data.command) == "string" then
|
||||
cmd = obj.data.command
|
||||
end
|
||||
|
||||
local op = ""
|
||||
for _, candidate in ipairs({ "install", "upgrade", "uninstall_openclaw", "uninstall", "purge", "restart", "start", "stop" }) do
|
||||
if cmd:match("(^|[^%w_])" .. candidate .. "([^%w_]|$)") then
|
||||
op = candidate
|
||||
break
|
||||
end
|
||||
for _, part in ipairs(cmd_parts) do
|
||||
if type(part) == "string" and part:match("(^|[^%w_])" .. candidate .. "([^%w_]|$)") then
|
||||
op = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
if op ~= "" then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
running = as_bool(obj.running),
|
||||
op = op,
|
||||
command = cmd,
|
||||
pid = obj.pid and tostring(obj.pid) or "",
|
||||
}
|
||||
end
|
||||
|
||||
local function require_csrf()
|
||||
local http = require "luci.http"
|
||||
local disp = require "luci.dispatcher"
|
||||
|
||||
local method = http.getenv("REQUEST_METHOD") or ""
|
||||
if method ~= "POST" then
|
||||
return true
|
||||
end
|
||||
|
||||
local ctx = disp.context
|
||||
if not (ctx and ctx.authsession) then
|
||||
write_json({ ok = false, error = "auth session missing" })
|
||||
return false
|
||||
end
|
||||
|
||||
local expected = ctx.token
|
||||
local header_token = http.getenv("HTTP_X_LUCI_TOKEN")
|
||||
local form_token = http.formvalue("token")
|
||||
local body = read_json_body()
|
||||
local body_token = (type(body) == "table") and body["token"] or nil
|
||||
local provided = header_token or form_token or body_token
|
||||
|
||||
if expected and provided ~= expected then
|
||||
write_json({ ok = false, error = "bad csrf token" })
|
||||
return false
|
||||
end
|
||||
if not expected and (not provided or #provided == 0) then
|
||||
write_json({ ok = false, error = "csrf token missing" })
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function get_host()
|
||||
local http = require "luci.http"
|
||||
local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or ""
|
||||
host = host:gsub(":%d+$", "")
|
||||
if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then
|
||||
host = http.getenv("SERVER_ADDR") or "localhost"
|
||||
end
|
||||
return host
|
||||
end
|
||||
|
||||
local function configured_base_path(base_dir)
|
||||
local jsonc = require "luci.jsonc"
|
||||
local path = (base_dir or "/opt/openclawmgr") .. "/data/.openclaw/openclaw.json"
|
||||
local f = io.open(path, "r")
|
||||
if not f then
|
||||
return "/"
|
||||
end
|
||||
|
||||
local raw = f:read("*a") or ""
|
||||
f:close()
|
||||
|
||||
local obj = jsonc.parse(raw)
|
||||
local base_path = obj
|
||||
and obj.gateway
|
||||
and obj.gateway.controlUi
|
||||
and obj.gateway.controlUi.basePath
|
||||
if type(base_path) ~= "string" or base_path == "" or base_path == "/" then
|
||||
return "/"
|
||||
end
|
||||
if base_path:sub(1, 1) ~= "/" then
|
||||
base_path = "/" .. base_path
|
||||
end
|
||||
return base_path:gsub("/+$", "") .. "/"
|
||||
end
|
||||
|
||||
local function fmt_elapsed(seconds)
|
||||
seconds = tonumber(seconds) or 0
|
||||
if seconds < 60 then
|
||||
return string.format("%ds", seconds)
|
||||
elseif seconds < 3600 then
|
||||
return string.format("%dm%02ds", math.floor(seconds / 60), seconds % 60)
|
||||
elseif seconds < 86400 then
|
||||
return string.format("%dh%02dm", math.floor(seconds / 3600), math.floor((seconds % 3600) / 60))
|
||||
end
|
||||
return string.format("%dd%02dh", math.floor(seconds / 86400), math.floor((seconds % 86400) / 3600))
|
||||
end
|
||||
|
||||
local function get_running_pid()
|
||||
local sys = require "luci.sys"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local raw = sys.exec("ubus call service list '{\"name\":\"openclawmgr\"}' 2>/dev/null")
|
||||
local obj = jsonc.parse(raw) or {}
|
||||
local svc = obj.openclawmgr
|
||||
if type(svc) ~= "table" or type(svc.instances) ~= "table" then
|
||||
return ""
|
||||
end
|
||||
local inst = svc.instances.gateway
|
||||
if type(inst) == "table" and inst.pid then
|
||||
return tostring(inst.pid)
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function installer_lock_pid()
|
||||
local f = io.open("/tmp/openclawmgr-installer.lock/pid", "r")
|
||||
if not f then
|
||||
return ""
|
||||
end
|
||||
local pid = (f:read("*l") or ""):gsub("%s+$", "")
|
||||
f:close()
|
||||
return pid
|
||||
end
|
||||
|
||||
local function installer_lock_running()
|
||||
local sys = require "luci.sys"
|
||||
local pid = installer_lock_pid()
|
||||
if pid ~= "" and sys.call("kill -0 " .. pid .. " >/dev/null 2>&1") == 0 then
|
||||
local f = io.open("/proc/" .. pid .. "/cmdline", "r")
|
||||
local cmdline = ""
|
||||
if f then
|
||||
cmdline = (f:read("*a") or ""):gsub("%z", " ")
|
||||
f:close()
|
||||
end
|
||||
if cmdline:match("openclawmgr%.sh") and (cmdline:match(" install([%s]|$)") or cmdline:match(" upgrade([%s]|$)")) then
|
||||
return true, pid
|
||||
end
|
||||
end
|
||||
return false, pid
|
||||
end
|
||||
|
||||
local function get_pid_uptime_human(pid)
|
||||
if not pid or pid == "" then
|
||||
return ""
|
||||
end
|
||||
local f = io.open("/proc/" .. pid .. "/stat", "r")
|
||||
if not f then
|
||||
return ""
|
||||
end
|
||||
local stat_line = f:read("*l") or ""
|
||||
f:close()
|
||||
|
||||
local uf = io.open("/proc/uptime", "r")
|
||||
if not uf then
|
||||
return ""
|
||||
end
|
||||
local uptime_line = uf:read("*l") or ""
|
||||
uf:close()
|
||||
|
||||
local after = stat_line:match("%) (.+)$")
|
||||
if not after then
|
||||
return ""
|
||||
end
|
||||
local fields = {}
|
||||
for part in after:gmatch("%S+") do
|
||||
fields[#fields + 1] = part
|
||||
end
|
||||
local start_ticks = tonumber(fields[20] or "")
|
||||
local system_uptime = tonumber((uptime_line:match("^(%S+)"))) or 0
|
||||
if not start_ticks or system_uptime <= 0 then
|
||||
return ""
|
||||
end
|
||||
|
||||
local ticks_per_sec = 100
|
||||
local elapsed = math.floor(system_uptime - (start_ticks / ticks_per_sec))
|
||||
if elapsed < 0 then
|
||||
elapsed = 0
|
||||
end
|
||||
return fmt_elapsed(elapsed)
|
||||
end
|
||||
|
||||
function action_status()
|
||||
local sys = require "luci.sys"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local task = get_task_state("openclawmgr")
|
||||
|
||||
local enabled = uci:get("openclawmgr", "main", "enabled") or "0"
|
||||
local port = uci:get("openclawmgr", "main", "port") or "18789"
|
||||
local bind = uci:get("openclawmgr", "main", "bind") or "lan"
|
||||
local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr"
|
||||
local token = uci:get("openclawmgr", "main", "token") or ""
|
||||
|
||||
if not port:match("^%d+$") then port = "18789" end
|
||||
|
||||
local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh status 2>/dev/null"):gsub("%s+$", "")
|
||||
local running = (st == "running")
|
||||
local installed = (st == "running" or st == "stopped")
|
||||
local lock_running, lock_pid = installer_lock_running()
|
||||
local installing = (task.running and (task.op == "install" or task.op == "upgrade")) or lock_running
|
||||
if not installing and task.running and not installed and not running then
|
||||
installing = true
|
||||
end
|
||||
|
||||
local node_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh node_version 2>/dev/null"):gsub("%s+$", "")
|
||||
local oc_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh openclaw_version 2>/dev/null"):gsub("%s+$", "")
|
||||
local pid = running and get_running_pid() or ""
|
||||
if pid == "" and installing and lock_pid ~= "" then
|
||||
pid = lock_pid
|
||||
end
|
||||
local uptime_human = running and get_pid_uptime_human(pid) or ""
|
||||
|
||||
local base_url = "http://" .. get_host() .. ":" .. port .. configured_base_path(base_dir)
|
||||
local token_url = base_url
|
||||
if token ~= "" then
|
||||
token_url = token_url .. "#token=" .. token
|
||||
end
|
||||
|
||||
write_json({
|
||||
ok = true,
|
||||
enabled = enabled,
|
||||
installed = installed,
|
||||
running = running,
|
||||
task_running = task.running,
|
||||
task_op = task.op,
|
||||
installing = installing,
|
||||
port = port,
|
||||
bind = bind,
|
||||
base_dir = base_dir,
|
||||
token = token,
|
||||
node_version = node_ver,
|
||||
openclaw_version = oc_ver,
|
||||
pid = pid,
|
||||
uptime_human = uptime_human,
|
||||
base_url = base_url,
|
||||
token_url = token_url,
|
||||
url = token_url,
|
||||
})
|
||||
end
|
||||
|
||||
function action_log()
|
||||
local sys = require "luci.sys"
|
||||
local util = require "luci.util"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr"
|
||||
local log_file = base_dir .. "/log/installer.log"
|
||||
|
||||
local n = tonumber((require "luci.http").formvalue("n") or "") or 200
|
||||
if n < 10 then n = 10 end
|
||||
if n > 2000 then n = 2000 end
|
||||
|
||||
local content = sys.exec("tail -n " .. n .. " " .. util.shellquote(log_file) .. " 2>/dev/null") or ""
|
||||
write_json({ ok = true, log = content })
|
||||
end
|
||||
|
||||
function action_apply_config()
|
||||
local sys = require "luci.sys"
|
||||
|
||||
if not require_csrf() then
|
||||
return
|
||||
end
|
||||
|
||||
local rc = sys.call("/usr/libexec/istorec/openclawmgr.sh apply_config >/dev/null 2>&1")
|
||||
if rc ~= 0 then
|
||||
write_json({ ok = false, error = "apply config failed" })
|
||||
return
|
||||
end
|
||||
|
||||
write_json({
|
||||
ok = true,
|
||||
applied_at = os.date("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
end
|
||||
|
||||
function action_config_data()
|
||||
local http = require "luci.http"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local model = require "luci.model.openclawmgr"
|
||||
|
||||
if (http.getenv("REQUEST_METHOD") or "GET") == "POST" then
|
||||
if not require_csrf() then
|
||||
return
|
||||
end
|
||||
|
||||
local body = read_json_body() or {}
|
||||
local section = "main"
|
||||
|
||||
local function bool_to_uci(value)
|
||||
return value and "1" or "0"
|
||||
end
|
||||
|
||||
local function has(key)
|
||||
return body[key] ~= nil
|
||||
end
|
||||
|
||||
if has("enabled") then
|
||||
uci:set("openclawmgr", section, "enabled", bool_to_uci(body.enabled == true or body.enabled == "1"))
|
||||
end
|
||||
|
||||
if has("port") then
|
||||
local port = tostring(body.port or "")
|
||||
if not port:match("^%d+$") then
|
||||
write_json({ ok = false, error = "invalid port" })
|
||||
return
|
||||
end
|
||||
local port_num = tonumber(port) or 0
|
||||
if port_num < 1 or port_num > 65535 then
|
||||
write_json({ ok = false, error = "invalid port" })
|
||||
return
|
||||
end
|
||||
uci:set("openclawmgr", section, "port", port)
|
||||
end
|
||||
|
||||
if has("bind") then
|
||||
local bind = tostring(body.bind or "")
|
||||
if bind ~= "loopback" and bind ~= "lan" and bind ~= "auto" and bind ~= "tailnet" and bind ~= "custom" then
|
||||
write_json({ ok = false, error = "invalid bind" })
|
||||
return
|
||||
end
|
||||
uci:set("openclawmgr", section, "bind", bind)
|
||||
end
|
||||
|
||||
if has("base_dir") then
|
||||
local base_dir = tostring(body.base_dir or "")
|
||||
if base_dir == "" then
|
||||
write_json({ ok = false, error = "base_dir required" })
|
||||
return
|
||||
end
|
||||
uci:set("openclawmgr", section, "base_dir", base_dir)
|
||||
end
|
||||
|
||||
if has("default_agent") then
|
||||
local agent = tostring(body.default_agent or "")
|
||||
if agent ~= "openai" and agent ~= "anthropic" and agent ~= "minimax-cn" and agent ~= "moonshot" then
|
||||
write_json({ ok = false, error = "invalid default_agent" })
|
||||
return
|
||||
end
|
||||
uci:set("openclawmgr", section, "default_agent", agent)
|
||||
end
|
||||
|
||||
if has("default_model") then
|
||||
uci:set("openclawmgr", section, "default_model", tostring(body.default_model or ""))
|
||||
end
|
||||
|
||||
if has("install_accelerated") then
|
||||
uci:set("openclawmgr", section, "install_accelerated", bool_to_uci(body.install_accelerated == true or body.install_accelerated == "1"))
|
||||
end
|
||||
|
||||
if has("provider_api_key") then
|
||||
uci:set("openclawmgr", section, "provider_api_key", tostring(body.provider_api_key or ""))
|
||||
end
|
||||
|
||||
if has("provider_base_url") then
|
||||
local value = tostring(body.provider_base_url or "")
|
||||
if value ~= "" and not value:match("^https?://") then
|
||||
write_json({ ok = false, error = "invalid provider_base_url" })
|
||||
return
|
||||
end
|
||||
uci:set("openclawmgr", section, "provider_base_url", value)
|
||||
end
|
||||
|
||||
if has("token") then
|
||||
uci:set("openclawmgr", section, "token", tostring(body.token or ""))
|
||||
end
|
||||
|
||||
if has("allowed_origins") then
|
||||
local origins = {}
|
||||
if type(body.allowed_origins) == "table" then
|
||||
for _, item in ipairs(body.allowed_origins) do
|
||||
item = tostring(item or ""):match("^%s*(.-)%s*$")
|
||||
if item and item ~= "" then
|
||||
table.insert(origins, item)
|
||||
end
|
||||
end
|
||||
end
|
||||
if #origins > 0 then
|
||||
uci:set_list("openclawmgr", section, "allowed_origins", origins)
|
||||
else
|
||||
uci:delete("openclawmgr", section, "allowed_origins")
|
||||
end
|
||||
end
|
||||
|
||||
if has("allow_insecure_auth") then
|
||||
uci:set("openclawmgr", section, "allow_insecure_auth", bool_to_uci(body.allow_insecure_auth == true or body.allow_insecure_auth == "1"))
|
||||
end
|
||||
|
||||
if has("disable_device_auth") then
|
||||
uci:set("openclawmgr", section, "disable_device_auth", bool_to_uci(body.disable_device_auth == true or body.disable_device_auth == "1"))
|
||||
end
|
||||
|
||||
uci:commit("openclawmgr")
|
||||
write_json({ ok = true })
|
||||
return
|
||||
end
|
||||
|
||||
local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr"
|
||||
local port = uci:get("openclawmgr", "main", "port") or "18789"
|
||||
local blocks = model.blocks()
|
||||
local home = model.home()
|
||||
local paths, default_path = model.find_paths(blocks, home, "Configs")
|
||||
local choices = {}
|
||||
local seen = {}
|
||||
|
||||
local function add_choice(path)
|
||||
if path and path ~= "" and not seen[path] then
|
||||
table.insert(choices, path)
|
||||
seen[path] = true
|
||||
end
|
||||
end
|
||||
|
||||
add_choice(base_dir)
|
||||
for _, path in ipairs(paths or {}) do
|
||||
add_choice(path)
|
||||
end
|
||||
add_choice(default_path or "/opt/openclawmgr")
|
||||
|
||||
local allowed_origins = {}
|
||||
for _, item in ipairs(uci:get_list("openclawmgr", "main", "allowed_origins") or {}) do
|
||||
allowed_origins[#allowed_origins + 1] = item
|
||||
end
|
||||
|
||||
write_json({
|
||||
ok = true,
|
||||
config = {
|
||||
enabled = (uci:get("openclawmgr", "main", "enabled") or "0") == "1",
|
||||
port = port,
|
||||
bind = uci:get("openclawmgr", "main", "bind") or "lan",
|
||||
base_dir = base_dir,
|
||||
token = uci:get("openclawmgr", "main", "token") or "",
|
||||
allowed_origins = allowed_origins,
|
||||
allow_insecure_auth = (uci:get("openclawmgr", "main", "allow_insecure_auth") or "0") == "1",
|
||||
disable_device_auth = (uci:get("openclawmgr", "main", "disable_device_auth") or "0") == "1",
|
||||
default_agent = uci:get("openclawmgr", "main", "default_agent") or "anthropic",
|
||||
default_model = uci:get("openclawmgr", "main", "default_model") or "",
|
||||
install_accelerated = (uci:get("openclawmgr", "main", "install_accelerated") or "1") == "1",
|
||||
provider_api_key = uci:get("openclawmgr", "main", "provider_api_key") or "",
|
||||
provider_base_url = uci:get("openclawmgr", "main", "provider_base_url") or "",
|
||||
},
|
||||
options = {
|
||||
base_dir_choices = choices,
|
||||
default_origin = default_allowed_origin(port),
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
function action_op()
|
||||
local http = require "luci.http"
|
||||
local i18n = require "luci.i18n"
|
||||
local sys = require "luci.sys"
|
||||
local util = require "luci.util"
|
||||
local fs = require "nixio.fs"
|
||||
local tasks = nil
|
||||
do
|
||||
local ok, mod = pcall(require, "luci.model.tasks")
|
||||
if ok then tasks = mod end
|
||||
end
|
||||
|
||||
if not require_csrf() then
|
||||
return
|
||||
end
|
||||
|
||||
local op = http.formvalue("op") or ""
|
||||
local ok_ops = { install = true, upgrade = true, start = true, stop = true, restart = true, apply_config = true, uninstall = true, uninstall_openclaw = true, purge = true, cancel_install = true }
|
||||
if not ok_ops[op] then
|
||||
write_json({ ok = false, error = "unknown op" })
|
||||
return
|
||||
end
|
||||
|
||||
local task_id = "openclawmgr"
|
||||
local script_path = "/usr/libexec/istorec/openclawmgr.sh"
|
||||
|
||||
if op == "cancel_install" then
|
||||
if not fs.access("/etc/init.d/tasks") then
|
||||
write_json({ ok = false, error = i18n.translate("taskd is not available") })
|
||||
return
|
||||
end
|
||||
|
||||
local task = get_task_state(task_id)
|
||||
local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh status 2>/dev/null"):gsub("%s+$", "")
|
||||
local installed = (st == "running" or st == "stopped")
|
||||
local install_like = task.running and (task.op == "install" or task.op == "upgrade" or not installed)
|
||||
if not install_like then
|
||||
write_json({ ok = false, error = i18n.translate("No install task is running") })
|
||||
return
|
||||
end
|
||||
|
||||
local rc = sys.call("/etc/init.d/tasks task_del " .. task_id .. " >/dev/null 2>&1")
|
||||
if rc ~= 0 and task.pid ~= "" then
|
||||
sys.call("kill -TERM " .. util.shellquote(task.pid) .. " >/dev/null 2>&1")
|
||||
sys.call("sleep 1")
|
||||
sys.call("kill -KILL " .. util.shellquote(task.pid) .. " >/dev/null 2>&1")
|
||||
rc = sys.call("/etc/init.d/tasks task_del " .. task_id .. " >/dev/null 2>&1")
|
||||
end
|
||||
|
||||
if rc == 0 or not get_task_state(task_id).running then
|
||||
sys.call("rm -rf /tmp/openclawmgr-installer.lock >/dev/null 2>&1")
|
||||
write_json({ ok = true, canceled = true, task_id = task_id })
|
||||
return
|
||||
end
|
||||
|
||||
write_json({ ok = false, error = i18n.translate("Failed to stop installation"), task_id = task_id, pid = task.pid })
|
||||
return
|
||||
end
|
||||
|
||||
if not fs.access("/etc/init.d/tasks") then
|
||||
-- fallback (shouldn't happen on iStoreOS with luci-lib-taskd installed)
|
||||
local cmd = script_path .. " " .. util.shellquote(op)
|
||||
sys.exec("( " .. cmd .. " ) >/dev/null 2>&1 &")
|
||||
write_json({ ok = true, queued = true, task_id = task_id, warning = "taskd missing; fallback to background exec" })
|
||||
return
|
||||
end
|
||||
|
||||
local cmd = string.format("\"%s\" %s", script_path, op)
|
||||
local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(cmd) .. " >/dev/null 2>&1")
|
||||
if rc == 0 then
|
||||
write_json({ ok = true, task_id = task_id })
|
||||
return
|
||||
end
|
||||
|
||||
-- busy: try to report which task is running
|
||||
local running_task = ""
|
||||
if tasks and tasks.status then
|
||||
local all = tasks.status("") or {}
|
||||
for id, st in pairs(all) do
|
||||
if type(st) == "table" and st.running then
|
||||
running_task = id
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
write_json({ ok = false, busy = true, task_id = task_id, running_task_id = running_task })
|
||||
end
|
||||
|
||||
local function file_read(path)
|
||||
local f = io.open(path, "r")
|
||||
if not f then return nil end
|
||||
local c = f:read("*a")
|
||||
f:close()
|
||||
return c
|
||||
end
|
||||
|
||||
function action_diag_info()
|
||||
local sys = require "luci.sys"
|
||||
local util = require "luci.util"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
|
||||
local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr"
|
||||
local port = uci:get("openclawmgr", "main", "port") or "18789"
|
||||
local bind = uci:get("openclawmgr", "main", "bind") or "lan"
|
||||
local enabled = uci:get("openclawmgr", "main", "enabled") or "0"
|
||||
|
||||
if not port:match("^%d+$") then port = "18789" end
|
||||
|
||||
local df_kb = sys.exec("df -kP " .. util.shellquote(base_dir) .. " 2>/dev/null | awk 'NR==2{print $4}'"):gsub("%s+", "")
|
||||
local avail_mb = math.floor((tonumber(df_kb) or 0) / 1024)
|
||||
|
||||
local node_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh node_version 2>/dev/null"):gsub("%s+$", "")
|
||||
local oc_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh openclaw_version 2>/dev/null"):gsub("%s+$", "")
|
||||
|
||||
local has_node = sys.call("[ -x " .. util.shellquote(base_dir .. "/node/bin/node") .. " ] >/dev/null 2>&1") == 0
|
||||
local has_openclaw = sys.call("[ -x " .. util.shellquote(base_dir .. "/global/bin/openclaw") .. " ] >/dev/null 2>&1") == 0
|
||||
local has_config = sys.call("[ -f " .. util.shellquote(base_dir .. "/data/.openclaw/openclaw.json") .. " ] >/dev/null 2>&1") == 0
|
||||
|
||||
local default_gw = sys.exec("ip route 2>/dev/null | awk '/^default/{print $3; exit}' 2>/dev/null"):gsub("%s+", "")
|
||||
|
||||
local svc = util.ubus("service", "list", { name = "openclawmgr" })
|
||||
local pid = ""
|
||||
if type(svc) == "table" and type(svc.openclawmgr) == "table" and type(svc.openclawmgr.instances) == "table" then
|
||||
local inst = svc.openclawmgr.instances.gateway
|
||||
if type(inst) == "table" and inst.pid then
|
||||
pid = tostring(inst.pid)
|
||||
end
|
||||
end
|
||||
local running = pid ~= ""
|
||||
|
||||
write_json({
|
||||
ok = true,
|
||||
base_dir = base_dir,
|
||||
available_mb = avail_mb,
|
||||
enabled = enabled,
|
||||
port = port,
|
||||
bind = bind,
|
||||
default_gw = default_gw,
|
||||
node_version = node_ver,
|
||||
openclaw_version = oc_ver,
|
||||
has_node = has_node,
|
||||
has_openclaw = has_openclaw,
|
||||
has_config = has_config,
|
||||
procd_pid = pid,
|
||||
running = running,
|
||||
url = "http://" .. get_host() .. ":" .. port .. configured_base_path(base_dir),
|
||||
})
|
||||
end
|
||||
|
||||
function action_diag_run()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
local util = require "luci.util"
|
||||
|
||||
if not require_csrf() then
|
||||
return
|
||||
end
|
||||
|
||||
local op = http.formvalue("op") or ""
|
||||
local limit = http.formvalue("limit") or ""
|
||||
local ok_ops = {
|
||||
doctor = true,
|
||||
gateway_status = true,
|
||||
gateway_health = true,
|
||||
logs = true,
|
||||
channels_status = true,
|
||||
}
|
||||
if not ok_ops[op] then
|
||||
write_json({ ok = false, error = "unknown op" })
|
||||
return
|
||||
end
|
||||
|
||||
local cmd = "/usr/libexec/istorec/openclawmgr.sh diag " .. util.shellquote(op)
|
||||
if op == "logs" and limit:match("^%d+$") then
|
||||
cmd = cmd .. " " .. util.shellquote(limit)
|
||||
end
|
||||
|
||||
sys.exec("( " .. cmd .. " ) >/dev/null 2>&1 &")
|
||||
write_json({ ok = true, queued = true })
|
||||
end
|
||||
|
||||
function action_diag_poll()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
local util = require "luci.util"
|
||||
|
||||
local op = http.formvalue("op") or ""
|
||||
local ok_ops = {
|
||||
doctor = true,
|
||||
gateway_status = true,
|
||||
gateway_health = true,
|
||||
logs = true,
|
||||
channels_status = true,
|
||||
}
|
||||
if not ok_ops[op] then
|
||||
write_json({ ok = false, error = "unknown op" })
|
||||
return
|
||||
end
|
||||
|
||||
local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh diag_poll " .. util.shellquote(op) .. " 2>/dev/null"):gsub("%s+$", "")
|
||||
|
||||
local log = file_read("/tmp/openclawmgr-diag-" .. op .. ".log") or ""
|
||||
local exit_code = nil
|
||||
local state = "idle"
|
||||
if st == "running" then
|
||||
state = "running"
|
||||
elseif st:match("^done:") then
|
||||
state = "done"
|
||||
exit_code = tonumber(st:gsub("^done:", "")) or -1
|
||||
end
|
||||
|
||||
write_json({
|
||||
ok = true,
|
||||
state = state,
|
||||
exit_code = exit_code,
|
||||
log = log,
|
||||
})
|
||||
end
|
||||
54
luci-app-openclawmgr/luasrc/model/openclawmgr.lua
Normal file
54
luci-app-openclawmgr/luasrc/model/openclawmgr.lua
Normal file
@ -0,0 +1,54 @@
|
||||
local jsonc = require "luci.jsonc"
|
||||
|
||||
local openclawmgr = {}
|
||||
|
||||
openclawmgr.blocks = function()
|
||||
local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r")
|
||||
local vals = {}
|
||||
if f then
|
||||
local ret = f:read("*all")
|
||||
f:close()
|
||||
local obj = jsonc.parse(ret)
|
||||
for _, val in pairs(obj and obj["blockdevices"] or {}) do
|
||||
local fsize = val["fssize"]
|
||||
if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then
|
||||
vals[#vals + 1] = val["mountpoint"]
|
||||
end
|
||||
end
|
||||
end
|
||||
return vals
|
||||
end
|
||||
|
||||
openclawmgr.home = function()
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local home_dirs = {}
|
||||
home_dirs["main_dir"] = uci:get_first("quickstart", "main", "main_dir", "/root")
|
||||
home_dirs["Configs"] = uci:get_first("quickstart", "main", "conf_dir", home_dirs["main_dir"] .. "/Configs")
|
||||
home_dirs["Public"] = uci:get_first("quickstart", "main", "pub_dir", home_dirs["main_dir"] .. "/Public")
|
||||
home_dirs["Downloads"] = uci:get_first("quickstart", "main", "dl_dir", home_dirs["Public"] .. "/Downloads")
|
||||
home_dirs["Caches"] = uci:get_first("quickstart", "main", "tmp_dir", home_dirs["main_dir"] .. "/Caches")
|
||||
return home_dirs
|
||||
end
|
||||
|
||||
openclawmgr.find_paths = function(blocks, home_dirs, path_name)
|
||||
local default_path = ""
|
||||
local paths = {}
|
||||
|
||||
default_path = home_dirs[path_name] .. "/OpenClawMgr"
|
||||
if #blocks == 0 then
|
||||
table.insert(paths, default_path)
|
||||
else
|
||||
for _, val in pairs(blocks) do
|
||||
table.insert(paths, val .. "/" .. path_name .. "/OpenClawMgr")
|
||||
end
|
||||
local without_conf_dir = "/root/" .. path_name .. "/OpenClawMgr"
|
||||
if default_path == without_conf_dir then
|
||||
default_path = paths[1]
|
||||
end
|
||||
end
|
||||
|
||||
return paths, default_path
|
||||
end
|
||||
|
||||
return openclawmgr
|
||||
|
||||
19
luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm
Normal file
19
luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm
Normal file
@ -0,0 +1,19 @@
|
||||
<%+header%>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
window.openclawmgrConfig = {
|
||||
token: "<%=token or ''%>",
|
||||
statusUrl: "<%=url('admin/services/openclawmgr/status')%>",
|
||||
opUrl: "<%=url('admin/services/openclawmgr/op')%>",
|
||||
configUrl: "<%=url('admin/services/openclawmgr/config_data')%>",
|
||||
applyUrl: "<%=url('admin/services/openclawmgr/apply_config')%>",
|
||||
logUrl: "<%=url('admin/services/openclawmgr/log')%>",
|
||||
staticBase: "<%=resource%>/openclawmgr"
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<div class="openclawmgr-host">
|
||||
<div id="openclawmgr-app" data-openclawmgr-shadow></div>
|
||||
</div>
|
||||
<script type="text/javascript" src="<%=resource%>/openclawmgr/app.js?v=<%=os.time()%>"></script>
|
||||
<%+footer%>
|
||||
258
luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm
Normal file
258
luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm
Normal file
@ -0,0 +1,258 @@
|
||||
<%+header%>
|
||||
|
||||
<%
|
||||
local info_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_info")
|
||||
local run_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_run")
|
||||
local poll_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_poll")
|
||||
local op_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "op")
|
||||
local log_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "log")
|
||||
local token = (luci.dispatcher.context and luci.dispatcher.context.token) or ""
|
||||
%>
|
||||
|
||||
<%+tasks/embed%>
|
||||
|
||||
<style type="text/css">
|
||||
.oc-box{border:1px solid #e5e5e5;border-radius:8px;background:#fff;margin:0 0 16px 0;overflow:hidden}
|
||||
.oc-box .hd{padding:10px 14px;background:#f6f8fa;border-bottom:1px solid #eee;font-weight:600}
|
||||
.oc-box .bd{padding:12px 14px}
|
||||
.oc-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.oc-kv{font-size:13px;line-height:1.8}
|
||||
.oc-kv code{background:#f6f8fa;padding:2px 6px;border-radius:4px}
|
||||
.oc-tag{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;border:1px solid #d0d7de;background:#fff}
|
||||
.ok{color:#1a7f37;border-color:#b7dfc3;background:#e6f7e9}
|
||||
.bad{color:#cf222e;border-color:#f0b8bf;background:#ffeef0}
|
||||
.warn{color:#9a6700;border-color:#f2d084;background:#fff8c5}
|
||||
.oc-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.oc-row .btn{display:inline-block;padding:6px 12px;border:1px solid #d0d7de;border-radius:6px;background:#fff;cursor:pointer}
|
||||
.oc-row .btn.primary{background:#0969da;border-color:#0969da;color:#fff}
|
||||
.oc-row .btn.danger{background:#cf222e;border-color:#cf222e;color:#fff}
|
||||
.oc-row .btn:disabled{opacity:.6;cursor:not-allowed}
|
||||
.oc-out{width:100%;height:300px;white-space:pre-wrap;background:#0b1020;color:#dbe2ff;border-radius:8px;padding:10px;overflow:auto;font-family:monospace;font-size:12px}
|
||||
@media (max-width: 1000px){.oc-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
<div class="oc-box">
|
||||
<div class="hd"><%:Quick Checks%></div>
|
||||
<div class="bd">
|
||||
<div class="oc-grid">
|
||||
<div class="oc-kv">
|
||||
<div><%:Base Dir%>: <code id="oc-base">-</code></div>
|
||||
<div><%:Disk Available%>: <code id="oc-disk">-</code></div>
|
||||
<div><%:Enable%>: <span id="oc-enabled" class="oc-tag">-</span></div>
|
||||
<div><%:Running%>: <span id="oc-running" class="oc-tag">-</span> <span id="oc-pid"></span></div>
|
||||
</div>
|
||||
<div class="oc-kv">
|
||||
<div><%:Node%>: <code id="oc-node">-</code> <span id="oc-has-node" class="oc-tag">-</span></div>
|
||||
<div><%:OpenClaw%>: <code id="oc-ver">-</code> <span id="oc-has-oc" class="oc-tag">-</span></div>
|
||||
<div><%:Config%>: <span id="oc-has-cfg" class="oc-tag">-</span></div>
|
||||
<div><%:URL%>: <a id="oc-url" href="#" target="_blank" rel="noopener">-</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="oc-hints" style="margin-top:10px;color:#666;font-size:12px;line-height:1.6"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-box">
|
||||
<div class="hd"><%:Service Actions%></div>
|
||||
<div class="bd">
|
||||
<div class="oc-row">
|
||||
<button class="btn" onclick="ocSvc('restart')"><%:Restart Service%></button>
|
||||
<button class="btn" onclick="ocSvc('upgrade')"><%:Upgrade%></button>
|
||||
<button class="btn" onclick="ocConfirmUninstallOpenclaw()"><%:Uninstall OpenClaw (Keep Node)%></button>
|
||||
<button class="btn" onclick="ocConfirmUninstall()"><%:Uninstall%></button>
|
||||
<button class="btn" onclick="ocShowInstallerLog()"><%:Installer Log%></button>
|
||||
<button class="btn danger" onclick="ocConfirmPurge()"><%:Purge%></button>
|
||||
</div>
|
||||
<div style="margin-top:10px;color:#666;font-size:12px;line-height:1.6">
|
||||
<%:Purge is destructive and will remove data under Base Dir. Use it only for troubleshooting.%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-box">
|
||||
<div class="hd"><%:Diagnostics%></div>
|
||||
<div class="bd">
|
||||
<div class="oc-row">
|
||||
<button id="btn-doctor" class="btn primary" onclick="ocRun('doctor')"><%:Run Doctor%></button>
|
||||
<button id="btn-gwst" class="btn" onclick="ocRun('gateway_status')"><%:Gateway Status%></button>
|
||||
<button id="btn-gwh" class="btn" onclick="ocRun('gateway_health')"><%:Gateway Health%></button>
|
||||
<button id="btn-chst" class="btn" onclick="ocRun('channels_status')"><%:Channels Status%></button>
|
||||
<button id="btn-logs" class="btn" onclick="ocRunLogs()"><%:Gateway Logs%></button>
|
||||
<select id="oc-log-limit" style="padding:6px 8px;border:1px solid #d0d7de;border-radius:6px">
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
<option value="1000">1000</option>
|
||||
<option value="2000">2000</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-top:10px;color:#666;font-size:12px">
|
||||
<%:All actions run in background; output will appear below.%>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="oc-out" id="oc-out">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function() {
|
||||
var infoUrl = '<%=info_url%>';
|
||||
var runUrl = '<%=run_url%>';
|
||||
var pollUrl = '<%=poll_url%>';
|
||||
var opUrl = '<%=op_url%>';
|
||||
var logUrl = '<%=log_url%>';
|
||||
var csrfToken = '<%=token%>';
|
||||
|
||||
var currentOp = '';
|
||||
var pollTimer = null;
|
||||
|
||||
function tag(el, ok, text) {
|
||||
if (!el) return;
|
||||
el.className = 'oc-tag ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn');
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function setText(id, v) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = (v === undefined || v === null || v === '') ? '-' : v;
|
||||
}
|
||||
|
||||
function setLink(id, href) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (!href || href === '-') {
|
||||
el.textContent = '-';
|
||||
el.href = '#';
|
||||
return;
|
||||
}
|
||||
el.textContent = href;
|
||||
el.href = href;
|
||||
}
|
||||
|
||||
function refreshInfo() {
|
||||
(new XHR()).get(infoUrl, null, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText);
|
||||
setText('oc-base', d.base_dir);
|
||||
setText('oc-disk', (d.available_mb || 0) + ' MB');
|
||||
tag(document.getElementById('oc-enabled'), d.enabled === '1', d.enabled === '1' ? 'yes' : 'no');
|
||||
tag(document.getElementById('oc-running'), d.running === true, d.running === true ? 'yes' : 'no');
|
||||
setText('oc-pid', d.procd_pid ? ('(pid ' + d.procd_pid + ')') : '');
|
||||
setText('oc-node', d.node_version || '-');
|
||||
setText('oc-ver', d.openclaw_version || '-');
|
||||
tag(document.getElementById('oc-has-node'), d.has_node === true, d.has_node ? 'node ok' : 'node missing');
|
||||
tag(document.getElementById('oc-has-oc'), d.has_openclaw === true, d.has_openclaw ? 'openclaw ok' : 'openclaw missing');
|
||||
tag(document.getElementById('oc-has-cfg'), d.has_config === true, d.has_config ? 'config ok' : 'config missing');
|
||||
setLink('oc-url', d.url || '-');
|
||||
|
||||
var hints = [];
|
||||
if (!d.default_gw) hints.push('No default gateway: internet unreachable (install/upgrade will fail).');
|
||||
if (!d.has_node) hints.push('Node.js missing: click Install in the action bar.');
|
||||
if (d.has_node && !d.has_openclaw) hints.push('OpenClaw not installed: click Install in the action bar.');
|
||||
if (d.has_openclaw && !d.has_config) hints.push('Config missing: run Doctor or re-run Install to generate minimal config.');
|
||||
if (d.available_mb && d.available_mb < 2048) hints.push('Disk space low (< 2GB): installation may fail.');
|
||||
if (d.enabled === '1' && d.has_openclaw && !d.running) hints.push('Service enabled but not running: run Gateway Status or Doctor for details.');
|
||||
document.getElementById('oc-hints').textContent = hints.length ? ('Hints: ' + hints.join(' ')) : 'Hints: -';
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function pollOnce() {
|
||||
if (!currentOp) return;
|
||||
(new XHR()).get(pollUrl, { op: currentOp }, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText);
|
||||
document.getElementById('oc-out').textContent = d.log || '-';
|
||||
if (d.state === 'done') {
|
||||
stopPoll();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function startPoll(op) {
|
||||
currentOp = op;
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollOnce();
|
||||
pollTimer = window.setInterval(pollOnce, 1200);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
function disableButtons(disabled) {
|
||||
var ids = ['btn-doctor','btn-gwst','btn-gwh','btn-chst','btn-logs'];
|
||||
for (var i=0;i<ids.length;i++) {
|
||||
var el = document.getElementById(ids[i]);
|
||||
if (el) el.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
window.ocRun = function(op) {
|
||||
disableButtons(true);
|
||||
(new XHR()).post(runUrl, { op: op, token: csrfToken }, function() {
|
||||
disableButtons(false);
|
||||
startPoll(op);
|
||||
});
|
||||
};
|
||||
|
||||
window.ocRunLogs = function() {
|
||||
var limit = document.getElementById('oc-log-limit').value || '200';
|
||||
disableButtons(true);
|
||||
(new XHR()).post(runUrl, { op: 'logs', limit: limit, token: csrfToken }, function() {
|
||||
disableButtons(false);
|
||||
startPoll('logs');
|
||||
});
|
||||
};
|
||||
|
||||
window.ocSvc = function(op) {
|
||||
(new XHR()).post(opUrl, { op: op, token: csrfToken }, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText || '{}');
|
||||
if (window.taskd && window.taskd.show_log) {
|
||||
if (d.running_task_id && d.running_task_id !== '') {
|
||||
window.taskd.show_log(d.running_task_id);
|
||||
} else if (d.task_id) {
|
||||
window.taskd.show_log(d.task_id);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
window.ocConfirmUninstall = function() {
|
||||
if (!confirm('<%:Uninstall will remove runtime but keep data under Base Dir. Continue?%>')) return;
|
||||
window.ocSvc('uninstall');
|
||||
};
|
||||
|
||||
window.ocConfirmUninstallOpenclaw = function() {
|
||||
if (!confirm('<%:Uninstall OpenClaw runtime but keep Node.js. Continue?%>')) return;
|
||||
window.ocSvc('uninstall_openclaw');
|
||||
};
|
||||
|
||||
window.ocConfirmPurge = function() {
|
||||
if (!confirm('<%:Purge will remove runtime and data under Base Dir. Continue?%>')) return;
|
||||
window.ocSvc('purge');
|
||||
};
|
||||
|
||||
window.ocShowInstallerLog = function() {
|
||||
(new XHR()).get(logUrl, { n: 400 }, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText || '{}');
|
||||
if (window.taskd && window.taskd.show_log_txt) {
|
||||
window.taskd.show_log_txt('installer.log', d.log || '-');
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
refreshInfo();
|
||||
window.setInterval(refreshInfo, 5000);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+footer%>
|
||||
245
luci-app-openclawmgr/po/zh-cn/openclawmgr.po
Normal file
245
luci-app-openclawmgr/po/zh-cn/openclawmgr.po
Normal file
@ -0,0 +1,245 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8\n"
|
||||
|
||||
msgid "OpenClaw"
|
||||
msgstr "OpenClaw"
|
||||
|
||||
msgid "OpenClaw Launcher"
|
||||
msgstr "OpenClaw 启动器"
|
||||
|
||||
msgid "Native installer & service manager for OpenClaw (procd + Node/npm)."
|
||||
msgstr "OpenClaw 原生安装器与服务管理(procd + Node/npm)。"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "状态"
|
||||
|
||||
msgid "Open Web UI"
|
||||
msgstr "打开 Web 界面"
|
||||
|
||||
msgid "Unavailable"
|
||||
msgstr "不可用"
|
||||
|
||||
msgid "Service Status"
|
||||
msgstr "服务状态"
|
||||
|
||||
msgid "Installed"
|
||||
msgstr "已安装"
|
||||
|
||||
msgid "Running"
|
||||
msgstr "运行中"
|
||||
|
||||
msgid "Not running"
|
||||
msgstr "未运行"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "未安装"
|
||||
|
||||
msgid "Node"
|
||||
msgstr "Node.js"
|
||||
|
||||
msgid "URL"
|
||||
msgstr "访问地址"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "操作"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "安装"
|
||||
|
||||
msgid "Apply Settings"
|
||||
msgstr "应用设置"
|
||||
|
||||
msgid "Upgrade"
|
||||
msgstr "升级"
|
||||
|
||||
msgid "Start"
|
||||
msgstr "启动"
|
||||
|
||||
msgid "Stop"
|
||||
msgstr "停止"
|
||||
|
||||
msgid "Restart"
|
||||
msgstr "重启"
|
||||
|
||||
msgid "Purge"
|
||||
msgstr "清理(含数据)"
|
||||
|
||||
msgid "Installer Log"
|
||||
msgstr "安装日志"
|
||||
|
||||
msgid "Service Actions"
|
||||
msgstr "服务操作"
|
||||
|
||||
msgid "Restart Service"
|
||||
msgstr "重启服务"
|
||||
|
||||
msgid "Purge will remove runtime and data under Base Dir. Continue?"
|
||||
msgstr "清理将删除运行时与安装目录下的数据。确定继续?"
|
||||
|
||||
msgid "Purge is destructive and will remove data under Base Dir. Use it only for troubleshooting."
|
||||
msgstr "清理操作具有破坏性,会删除安装目录下的数据;仅建议用于排障。"
|
||||
|
||||
msgid "Install/upgrade runs in background; check logs in the popup task window."
|
||||
msgstr "安装/升级在后台执行;请在弹出的任务日志窗口查看。"
|
||||
|
||||
msgid "Task log will pop up after clicking an action."
|
||||
msgstr "点击操作后会弹出任务日志窗口。"
|
||||
|
||||
msgid "Task window is not available (taskd/xterm not loaded). Please install luci-lib-taskd and luci-lib-xterm, then hard refresh the page."
|
||||
msgstr "任务窗口不可用(未加载 taskd/xterm)。请安装 luci-lib-taskd 和 luci-lib-xterm,然后强制刷新页面。"
|
||||
|
||||
msgid "Config saved, but apply failed"
|
||||
msgstr "配置已保存,但应用失败"
|
||||
|
||||
msgid "Another task running, try again later."
|
||||
msgstr "有任务正在运行,请稍后再试。"
|
||||
|
||||
msgid "Click here to check running task"
|
||||
msgstr "点击这里查看正在运行的任务"
|
||||
|
||||
msgid "Task Running"
|
||||
msgstr "任务运行中"
|
||||
|
||||
msgid "Installing"
|
||||
msgstr "安装中"
|
||||
|
||||
msgid "Stop Install"
|
||||
msgstr "停止安装"
|
||||
|
||||
msgid "Failed to stop installation"
|
||||
msgstr "停止安装失败"
|
||||
|
||||
msgid "No install task is running"
|
||||
msgstr "当前没有正在执行的安装任务"
|
||||
|
||||
msgid "taskd is not available"
|
||||
msgstr "taskd 不可用"
|
||||
|
||||
msgid "Diagnostics"
|
||||
msgstr "诊断"
|
||||
|
||||
msgid "Quick Checks"
|
||||
msgstr "快速检查"
|
||||
|
||||
msgid "Disk Available"
|
||||
msgstr "可用空间"
|
||||
|
||||
msgid "Config"
|
||||
msgstr "配置文件"
|
||||
|
||||
msgid "Run Doctor"
|
||||
msgstr "运行 Doctor"
|
||||
|
||||
msgid "Gateway Status"
|
||||
msgstr "网关状态"
|
||||
|
||||
msgid "Gateway Health"
|
||||
msgstr "网关健康检查"
|
||||
|
||||
msgid "Gateway Logs"
|
||||
msgstr "网关日志"
|
||||
|
||||
msgid "Channels Status"
|
||||
msgstr "渠道状态"
|
||||
|
||||
msgid "All actions run in background; output will appear below."
|
||||
msgstr "所有操作在后台执行;输出会显示在下方。"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "设置"
|
||||
|
||||
msgid "Enable"
|
||||
msgstr "启用"
|
||||
|
||||
msgid "Port"
|
||||
msgstr "端口"
|
||||
|
||||
msgid "Bind"
|
||||
msgstr "监听地址"
|
||||
|
||||
msgid "Loopback"
|
||||
msgstr "仅本机"
|
||||
|
||||
msgid "LAN"
|
||||
msgstr "LAN"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Base Dir"
|
||||
msgstr "安装目录"
|
||||
|
||||
msgid "Recommended. OpenClaw Launcher will install Node.js/OpenClaw runtime and store data under this directory."
|
||||
msgstr "推荐设置。OpenClaw 启动器会在该目录下安装 Node.js/OpenClaw 运行时并存放数据。"
|
||||
|
||||
msgid "Gateway Token"
|
||||
msgstr "网关 Token"
|
||||
|
||||
msgid "Allowed Origins"
|
||||
msgstr "允许的访问地址"
|
||||
|
||||
msgid "Origins allowed to access Control UI over HTTP. Example: http://lan-ip:port"
|
||||
msgstr "允许通过 HTTP 访问控制台的来源域名,例如 http://LAN-IP:端口"
|
||||
|
||||
msgid "Allow Insecure HTTP Control UI"
|
||||
msgstr "允许 HTTP 访问"
|
||||
|
||||
msgid "Enable token-only Control UI access over HTTP (no device identity). Recommended only on trusted LAN; prefer HTTPS when possible."
|
||||
msgstr "允许通过 HTTP 使用仅令牌访问控制台(不校验设备身份)。仅建议在受信任的局域网使用,尽量使用 HTTPS。"
|
||||
|
||||
msgid "Disable Device Auth"
|
||||
msgstr "关闭设备校验"
|
||||
|
||||
msgid "Disable Control UI device identity checks (dangerous). Use only on trusted LAN."
|
||||
msgstr "关闭控制台设备身份校验(危险)。仅建议在受信任的局域网使用。"
|
||||
|
||||
msgid "Default Agent"
|
||||
msgstr "默认 AGENT"
|
||||
|
||||
msgid "Select the provider used by agents.defaults.model.primary."
|
||||
msgstr "选择 agents.defaults.model.primary 使用的提供商。"
|
||||
|
||||
msgid "API Key"
|
||||
msgstr "API Key"
|
||||
|
||||
msgid "Only updates the API key for the selected Default Agent."
|
||||
msgstr "只更新当前所选默认 AGENT 对应的 API Key。"
|
||||
|
||||
msgid "Relay URL"
|
||||
msgstr "中转地址"
|
||||
|
||||
msgid "Optional. Must start with http:// or https://. Leave empty to restore the selected provider's default baseUrl."
|
||||
msgstr "可选。必须以 http:// 或 https:// 开头。留空会恢复当前所选 provider 的默认 baseUrl。"
|
||||
|
||||
msgid "Relay URL must start with http:// or https://"
|
||||
msgstr "中转地址必须以 http:// 或 https:// 开头"
|
||||
|
||||
msgid "Default Model"
|
||||
msgstr "默认模型"
|
||||
|
||||
msgid "OpenAI"
|
||||
msgstr "OpenAI"
|
||||
|
||||
msgid "Anthropic"
|
||||
msgstr "Anthropic"
|
||||
|
||||
msgid "MiniMax CN"
|
||||
msgstr "MiniMax CN"
|
||||
|
||||
msgid "Moonshot CN"
|
||||
msgstr "Moonshot CN"
|
||||
|
||||
msgid "Uninstall"
|
||||
msgstr "卸载(保留数据)"
|
||||
|
||||
msgid "Uninstall OpenClaw (Keep Node)"
|
||||
msgstr "卸载 OpenClaw(保留 Node)"
|
||||
|
||||
msgid "Uninstall OpenClaw runtime but keep Node.js. Continue?"
|
||||
msgstr "卸载 OpenClaw 运行时但保留 Node.js。确定继续?"
|
||||
|
||||
msgid "Uninstall will remove runtime but keep data under Base Dir. Continue?"
|
||||
msgstr "卸载将删除运行时,但保留安装目录下的数据。确定继续?"
|
||||
|
||||
msgid "Network Diagnostics"
|
||||
msgstr "网络诊断"
|
||||
1
luci-app-openclawmgr/po/zh_Hans
Symbolic link
1
luci-app-openclawmgr/po/zh_Hans
Symbolic link
@ -0,0 +1 @@
|
||||
zh-cn
|
||||
14
luci-app-openclawmgr/root/etc/config/openclawmgr
Normal file
14
luci-app-openclawmgr/root/etc/config/openclawmgr
Normal file
@ -0,0 +1,14 @@
|
||||
config openclawmgr 'main'
|
||||
option enabled '0'
|
||||
option port '18789'
|
||||
option bind 'lan'
|
||||
option base_dir '/opt/openclawmgr'
|
||||
option token ''
|
||||
option allow_insecure_auth '0'
|
||||
option disable_device_auth '0'
|
||||
option node_version '22.16.0'
|
||||
option install_accelerated '1'
|
||||
option default_agent 'anthropic'
|
||||
option default_model ''
|
||||
option provider_api_key ''
|
||||
option provider_base_url ''
|
||||
87
luci-app-openclawmgr/root/etc/init.d/openclawmgr
Executable file
87
luci-app-openclawmgr/root/etc/init.d/openclawmgr
Executable file
@ -0,0 +1,87 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
SERVICE_NAME="openclawmgr"
|
||||
|
||||
_find_entry() {
|
||||
local global_dir="$1"
|
||||
local d="${global_dir}/lib/node_modules/openclaw"
|
||||
[ -f "${d}/openclaw.mjs" ] && { echo "${d}/openclaw.mjs"; return 0; }
|
||||
[ -f "${d}/dist/cli.js" ] && { echo "${d}/dist/cli.js"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
start_service() {
|
||||
config_load openclawmgr
|
||||
config_get_bool enabled main enabled 0
|
||||
config_get port main port 18789
|
||||
config_get bind main bind lan
|
||||
config_get base_dir main base_dir /opt/openclawmgr
|
||||
config_get token main token ""
|
||||
|
||||
[ "$enabled" -eq 1 ] || return 0
|
||||
|
||||
local node_bin="${base_dir}/node/bin/node"
|
||||
local global_dir="${base_dir}/global"
|
||||
local data_dir="${base_dir}/data"
|
||||
local env_file="${data_dir}/.openclaw/openclaw.env"
|
||||
local entry="$(_find_entry "$global_dir")"
|
||||
local openai_api_key=""
|
||||
local anthropic_api_key=""
|
||||
local minimax_api_key=""
|
||||
local moonshot_api_key=""
|
||||
|
||||
[ -x "$node_bin" ] || return 1
|
||||
[ -n "$entry" ] || return 1
|
||||
[ -n "$token" ] || return 1
|
||||
|
||||
mkdir -p "${data_dir}/.openclaw" 2>/dev/null
|
||||
if [ -f "$env_file" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
openai_api_key="${OPENAI_API_KEY:-}"
|
||||
anthropic_api_key="${ANTHROPIC_API_KEY:-}"
|
||||
minimax_api_key="${MINIMAX_API_KEY:-}"
|
||||
moonshot_api_key="${MOONSHOT_API_KEY:-}"
|
||||
fi
|
||||
|
||||
# OpenClaw uses --force to kill conflicting processes, but requires either `lsof`
|
||||
# or `fuser`. iStoreOS images may not include them by default.
|
||||
local force_opt=""
|
||||
if command -v lsof >/dev/null 2>&1 || command -v fuser >/dev/null 2>&1; then
|
||||
force_opt="--force"
|
||||
fi
|
||||
|
||||
local user_opt=""
|
||||
if id openclawmgr >/dev/null 2>&1; then
|
||||
user_opt="openclawmgr"
|
||||
fi
|
||||
|
||||
procd_open_instance "gateway"
|
||||
procd_set_param command "$node_bin" "$entry" gateway run --port "$port" --bind "$bind" --auth token --token "$token" --allow-unconfigured $force_opt
|
||||
procd_set_param env \
|
||||
HOME="$data_dir" \
|
||||
OPENCLAW_HOME="$data_dir" \
|
||||
OPENCLAW_STATE_DIR="${data_dir}/.openclaw" \
|
||||
OPENCLAW_CONFIG_PATH="${data_dir}/.openclaw/openclaw.json" \
|
||||
OPENCLAW_GATEWAY_PORT="$port" \
|
||||
OPENCLAW_GATEWAY_BIND="$bind" \
|
||||
OPENCLAW_GATEWAY_TOKEN="$token" \
|
||||
OPENAI_API_KEY="$openai_api_key" \
|
||||
ANTHROPIC_API_KEY="$anthropic_api_key" \
|
||||
MINIMAX_API_KEY="$minimax_api_key" \
|
||||
MOONSHOT_API_KEY="$moonshot_api_key" \
|
||||
PATH="${base_dir}/node/bin:${global_dir}/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
[ -n "$user_opt" ] && procd_set_param user "$user_opt"
|
||||
procd_set_param respawn 3600 10 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "openclawmgr"
|
||||
}
|
||||
46
luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr
Executable file
46
luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr
Executable file
@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
conf_dir="$(uci -q get quickstart.@main[0].conf_dir 2>/dev/null || true)"
|
||||
[ -n "$conf_dir" ] && suggested_base_dir="$conf_dir/OpenClawMgr" || suggested_base_dir=""
|
||||
|
||||
# Ensure section exists
|
||||
if ! uci -q get openclawmgr.main >/dev/null 2>&1; then
|
||||
uci -q set openclawmgr.main=openclawmgr >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Fill defaults (only if missing)
|
||||
[ -n "$(uci -q get openclawmgr.main.enabled 2>/dev/null)" ] || uci -q set openclawmgr.main.enabled='0'
|
||||
[ -n "$(uci -q get openclawmgr.main.port 2>/dev/null)" ] || uci -q set openclawmgr.main.port='18789'
|
||||
[ -n "$(uci -q get openclawmgr.main.bind 2>/dev/null)" ] || uci -q set openclawmgr.main.bind='lan'
|
||||
[ -n "$(uci -q get openclawmgr.main.node_version 2>/dev/null)" ] || uci -q set openclawmgr.main.node_version='22.16.0'
|
||||
[ -n "$(uci -q get openclawmgr.main.install_accelerated 2>/dev/null)" ] || uci -q set openclawmgr.main.install_accelerated='1'
|
||||
[ -n "$(uci -q get openclawmgr.main.token 2>/dev/null)" ] || uci -q set openclawmgr.main.token=''
|
||||
[ -n "$(uci -q get openclawmgr.main.allow_insecure_auth 2>/dev/null)" ] || uci -q set openclawmgr.main.allow_insecure_auth='0'
|
||||
[ -n "$(uci -q get openclawmgr.main.disable_device_auth 2>/dev/null)" ] || uci -q set openclawmgr.main.disable_device_auth='0'
|
||||
[ -n "$(uci -q get openclawmgr.main.default_agent 2>/dev/null)" ] || uci -q set openclawmgr.main.default_agent='anthropic'
|
||||
[ -n "$(uci -q get openclawmgr.main.default_model 2>/dev/null)" ] || uci -q set openclawmgr.main.default_model=''
|
||||
[ -n "$(uci -q get openclawmgr.main.provider_api_key 2>/dev/null)" ] || uci -q set openclawmgr.main.provider_api_key=''
|
||||
[ -n "$(uci -q get openclawmgr.main.provider_base_url 2>/dev/null)" ] || uci -q set openclawmgr.main.provider_base_url=''
|
||||
|
||||
# Improve default base_dir on iStoreOS: if still unset or using legacy /opt, prefer quickstart conf_dir.
|
||||
current_base_dir="$(uci -q get openclawmgr.main.base_dir 2>/dev/null || true)"
|
||||
if [ -z "$current_base_dir" ] || [ "$current_base_dir" = "/opt/openclawmgr" ]; then
|
||||
if [ -n "$suggested_base_dir" ]; then
|
||||
uci -q set openclawmgr.main.base_dir="$suggested_base_dir"
|
||||
else
|
||||
uci -q set openclawmgr.main.base_dir='/opt/openclawmgr'
|
||||
fi
|
||||
fi
|
||||
|
||||
uci -q commit openclawmgr >/dev/null 2>&1 || true
|
||||
|
||||
# best-effort: create an unprivileged user for procd
|
||||
if ! id openclawmgr >/dev/null 2>&1; then
|
||||
if command -v adduser >/dev/null 2>&1; then
|
||||
adduser -D -H -s /bin/false openclawmgr >/dev/null 2>&1 || true
|
||||
elif command -v useradd >/dev/null 2>&1; then
|
||||
useradd -r -M -s /bin/false openclawmgr >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
1161
luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh
Executable file
1161
luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh
Executable file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,11 @@
|
||||
{
|
||||
"luci-app-openclawmgr": {
|
||||
"description": "Grant UCI access for luci-app-openclawmgr",
|
||||
"read": {
|
||||
"uci": [ "openclawmgr" ]
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "openclawmgr" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=mtproxy
|
||||
PKG_VERSION:=3.0.10
|
||||
PKG_RELEASE:=1
|
||||
PKG_VERSION:=3.0.11
|
||||
PKG_RELEASE:=2
|
||||
|
||||
PKG_MAINTAINER:=Kosntantine Shevlakov <shevlakov@132lan.ru>
|
||||
PKG_LICENSE:=GPLv2
|
||||
@ -10,7 +10,7 @@ PKG_LICENSE_FILES:=LICENSE
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/GetPageSpeed/MTProxy.git
|
||||
PKG_SOURCE_VERSION:=4333cafad15acaf6f627b23c852ef3a4e4b68ae0
|
||||
PKG_SOURCE_VERSION:=eab5096fb539bb63d323e81c03130d408591cf41
|
||||
|
||||
PKG_SOURCE_SUBDIR:=$(PKG_NAME)
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
|
||||
Loading…
Reference in New Issue
Block a user