mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-17 14:17:36 +08:00
fix: Gateway status discrepancy — foreign detection, claim action, banner sync
- Services page: foreign Gateway now shows warning dot (yellow) instead of green - Add claim_gateway command (Rust + dev-api + frontend API) to adopt foreign Gateway - Services page: add "Claim Gateway" button for foreign Gateway instances - Top banner: distinguish foreign Gateway (warning + claim) vs stopped (info + start) - app-state: expose isGatewayForeign(), pass foreign flag to onGatewayChange listeners - Services page actions now immediately sync global Gateway state (no 15s poll wait) - Relax owner matching: cli_path missing on either side no longer triggers foreign - Add i18n keys: foreignGatewayBanner, claimGateway, claimSuccess, claimFailed, processing
This commit is contained in:
@@ -2354,10 +2354,10 @@ function matchesCurrentGatewayOwnerSignature(owner) {
|
||||
if (!owner || owner.startedBy !== 'clawpanel') return false
|
||||
const current = currentGatewayOwnerSignature()
|
||||
if (Number(owner.port || 0) !== current.port) return false
|
||||
if (!current.cliPath) return false
|
||||
const ownerCliPath = canonicalCliPath(owner.cliPath)
|
||||
if (!ownerCliPath || ownerCliPath !== current.cliPath) return false
|
||||
if (!owner.openclawDir || path.resolve(owner.openclawDir) !== current.openclawDir) return false
|
||||
// 仅当双方都有 cliPath 且不同时才视为不匹配;任一侧缺失时放宽为兼容(向后兼容旧记录/未绑定 CLI)
|
||||
const ownerCliPath = canonicalCliPath(owner.cliPath)
|
||||
if (ownerCliPath && current.cliPath && ownerCliPath !== current.cliPath) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3031,6 +3031,15 @@ const handlers = {
|
||||
return true
|
||||
},
|
||||
|
||||
async claim_gateway() {
|
||||
const label = 'ai.openclaw.gateway'
|
||||
const status = await getLocalGatewayRuntime(label)
|
||||
if (!status?.running) throw new Error('Gateway 未运行,无需认领')
|
||||
writeGatewayOwner(status.pid || null)
|
||||
serverCacheInvalidate('svc_status')
|
||||
return true
|
||||
},
|
||||
|
||||
async stop_service({ label }) {
|
||||
const status = await getLocalGatewayRuntime(label)
|
||||
if (status?.running) {
|
||||
|
||||
@@ -103,10 +103,11 @@ fn matches_current_gateway_owner_signature(owner: &GatewayOwnerRecord) -> bool {
|
||||
return false;
|
||||
}
|
||||
let owner_cli_path = owner.cli_path.as_ref().map(normalize_owned_path);
|
||||
matches!(
|
||||
(owner_cli_path.as_deref(), cli_path.as_deref()),
|
||||
(Some(owner_cli), Some(current_cli)) if owner_cli == current_cli
|
||||
)
|
||||
// 仅当双方都有 cli_path 且不同才视为不匹配;任一侧缺失时放宽为兼容(向后兼容旧记录/未绑定 CLI)
|
||||
match (owner_cli_path.as_deref(), cli_path.as_deref()) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_owner_pid_needs_refresh(owner: &GatewayOwnerRecord, pid: Option<u32>) -> bool {
|
||||
@@ -1689,3 +1690,14 @@ pub async fn restart_service(label: String) -> Result<(), String> {
|
||||
guardian_resume("manual restart");
|
||||
result
|
||||
}
|
||||
|
||||
/// 认领外部 Gateway:将 gateway-owner.json 强制覆写为当前面板实例签名
|
||||
#[tauri::command]
|
||||
pub async fn claim_gateway() -> Result<(), String> {
|
||||
let (running, pid) = current_gateway_runtime("ai.openclaw.gateway").await;
|
||||
if !running {
|
||||
return Err("Gateway 未运行,无需认领".into());
|
||||
}
|
||||
write_gateway_owner(pid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ pub fn run() {
|
||||
service::start_service,
|
||||
service::stop_service,
|
||||
service::restart_service,
|
||||
service::claim_gateway,
|
||||
service::guardian_status,
|
||||
// 日志
|
||||
logs::read_log_tail,
|
||||
|
||||
@@ -12,6 +12,7 @@ const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
let _openclawReady = false
|
||||
let _gatewayRunning = false
|
||||
let _gatewayForeign = false
|
||||
let _platform = '' // 'macos' | 'win32' | ...
|
||||
let _deployMode = 'local' // 'local' | 'docker'
|
||||
let _inDocker = false
|
||||
@@ -54,11 +55,16 @@ export function onGuardianGiveUp(fn) {
|
||||
return () => { _guardianListeners = _guardianListeners.filter(cb => cb !== fn) }
|
||||
}
|
||||
|
||||
/** Gateway 是否正在运行 */
|
||||
/** Gateway 是否正在运行(仅 owned) */
|
||||
export function isGatewayRunning() {
|
||||
return _gatewayRunning
|
||||
}
|
||||
|
||||
/** Gateway 是否在运行但属于外部实例 */
|
||||
export function isGatewayForeign() {
|
||||
return _gatewayForeign
|
||||
}
|
||||
|
||||
/** 获取后端平台 ('macos' | 'win32') */
|
||||
export function getPlatform() {
|
||||
return _platform
|
||||
@@ -130,7 +136,8 @@ export async function detectOpenclawStatus() {
|
||||
// 顺便检测 Gateway 运行状态
|
||||
if (services.status === 'fulfilled' && services.value?.length > 0) {
|
||||
const gw = services.value.find?.(s => s.label === 'ai.openclaw.gateway') || services.value[0]
|
||||
_setGatewayRunning(gw?.running === true && gw?.owned_by_current_instance !== false)
|
||||
const foreign = gw?.running === true && gw?.owned_by_current_instance === false
|
||||
_setGatewayRunning(gw?.running === true && !foreign, foreign)
|
||||
}
|
||||
} catch {
|
||||
_openclawReady = false
|
||||
@@ -139,22 +146,24 @@ export async function detectOpenclawStatus() {
|
||||
return _openclawReady
|
||||
}
|
||||
|
||||
function _setGatewayRunning(val) {
|
||||
function _setGatewayRunning(val, foreign = false) {
|
||||
const wasRunning = _gatewayRunning
|
||||
const changed = wasRunning !== val
|
||||
const wasForeign = _gatewayForeign
|
||||
const changed = wasRunning !== val || wasForeign !== foreign
|
||||
_gatewayRunning = val
|
||||
_gatewayForeign = foreign
|
||||
if (changed) {
|
||||
if (val) {
|
||||
// 仅记录恢复运行时间,避免短暂存活就把重启计数清零
|
||||
_gatewayRunningSince = Date.now()
|
||||
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
|
||||
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady && !foreign) {
|
||||
_gatewayRunningSince = 0
|
||||
// Gateway 意外停止,尝试自动重启
|
||||
_tryAutoRestart()
|
||||
} else if (!val) {
|
||||
_gatewayRunningSince = 0
|
||||
}
|
||||
_gwListeners.forEach(fn => { try { fn(val) } catch {} })
|
||||
_gwListeners.forEach(fn => { try { fn(val, foreign) } catch {} })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +225,7 @@ export async function refreshGatewayStatus() {
|
||||
if (nowRunning) {
|
||||
_gwStopCount = 0
|
||||
if (!_gatewayRunning) {
|
||||
_setGatewayRunning(true)
|
||||
_setGatewayRunning(true, false)
|
||||
} else if (shouldResetAutoRestartCount({
|
||||
autoRestartCount: _autoRestartCount,
|
||||
runningSince: _gatewayRunningSince,
|
||||
@@ -231,7 +240,7 @@ export async function refreshGatewayStatus() {
|
||||
_gwStopCount++
|
||||
}
|
||||
if (foreignRunning || _gwStopCount >= 2 || !_gatewayRunning) {
|
||||
_setGatewayRunning(false)
|
||||
_setGatewayRunning(false, foreignRunning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export const api = {
|
||||
startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) },
|
||||
stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) },
|
||||
restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) },
|
||||
claimGateway: () => { invalidate('get_services_status'); return invoke('claim_gateway') },
|
||||
guardianStatus: () => invoke('guardian_status'),
|
||||
|
||||
// 配置(读缓存,写清缓存)
|
||||
|
||||
@@ -68,4 +68,5 @@ export default {
|
||||
upgradeCompleted: _('升级完成', 'Upgrade completed', '升級完成', 'アップグレード完了', '업그레이드 완료', 'Nâng cấp hoàn tất'),
|
||||
upgradeFailed: _('升级失败', 'Upgrade failed', '升級失敗', 'アップグレード失敗', '업그레이드 실패', 'Nâng cấp thất bại'),
|
||||
unknownCommand: _('未知命令', 'Unknown command', '未知命令', '不明なコマンド', '알 수 없는 명령', 'Lệnh không xác định'),
|
||||
processing: _('处理中...', 'Processing...', '處理中...', '処理中...', '처리 중...', 'Đang xử lý...', 'Procesando...', 'Processando...', 'Обработка...', 'Traitement...', 'Verarbeitung...'),
|
||||
}
|
||||
|
||||
@@ -107,4 +107,6 @@ export default {
|
||||
fixDoneRestartFail: _('✅ 修复完成,但 Gateway 启动失败,请手动检查', '✅ Fix completed, but Gateway failed to start. Please check manually', '✅ 修復完成,但 Gateway 啟動失敗,請手動檢查', '✅ 修復完了、しかし Gateway の起動に失敗。手動で確認してください', '✅ 수정 완료, 그러나 Gateway 시작 실패. 수동으로 확인하세요'),
|
||||
fixFailed: _('❌ 修复失败:', '❌ Fix failed:', '❌ 修復失敗:', '❌ 修復失敗:', '❌ 수정 실패:'),
|
||||
startSent: _('已发送启动命令', 'Start command sent', '已發送啟動指令', '起動コマンド送信済み', '시작 명령 전송됨'),
|
||||
foreignGatewayBanner: _('检测到外部 Gateway 正在运行,当前面板无法管理', 'An external Gateway is running, not managed by this panel', '偵測到外部 Gateway 正在執行,目前面板無法管理', '外部 Gateway が実行中です。このパネルでは管理できません', '외부 Gateway가 실행 중이며, 이 패널에서 관리할 수 없습니다', 'Đã phát hiện Gateway bên ngoài đang chạy, bảng điều khiển này không thể quản lý', 'Se detectó un Gateway externo en ejecución, no gestionado por este panel', 'Um Gateway externo está em execução, não gerenciado por este painel', 'Обнаружен внешний Gateway, не управляемый этой панелью', 'Un Gateway externe est en cours d\'exécution, non géré par ce panneau', 'Ein externer Gateway läuft, der nicht von diesem Panel verwaltet wird'),
|
||||
claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'),
|
||||
}
|
||||
|
||||
@@ -162,4 +162,7 @@ export default {
|
||||
taskDone: _('操作完成', 'Operation complete'),
|
||||
upgradeDone: _('升级完成', 'Upgrade complete', '升級完成'),
|
||||
upgradeScene: _('升级 OpenClaw', 'Upgrade OpenClaw', '升級 OpenClaw'),
|
||||
claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'),
|
||||
claimSuccess: _('Gateway 已认领,当前面板已接管管理权', 'Gateway claimed, this panel now manages it', 'Gateway 已認領,目前面板已接管管理權', 'Gateway を引き取りました。このパネルが管理します', 'Gateway를 인수했습니다. 이 패널이 관리합니다'),
|
||||
claimFailed: _('认领失败', 'Claim failed', '認領失敗', '引き取り失敗', '인수 실패'),
|
||||
}
|
||||
|
||||
54
src/main.js
54
src/main.js
@@ -8,7 +8,7 @@ if (window._splashTimer) { clearTimeout(window._splashTimer); window._splashTime
|
||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
|
||||
import { initTheme } from './lib/theme.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, isGatewayForeign, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { wsClient } from './lib/ws-client.js'
|
||||
import { api, checkBackendHealth, isBackendOnline, isTauriRuntime, onBackendStatusChange } from './lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../package.json'
|
||||
@@ -525,17 +525,20 @@ function setupGatewayBanner() {
|
||||
const banner = document.getElementById('gw-banner')
|
||||
if (!banner) return
|
||||
|
||||
function update(running) {
|
||||
function update(running, foreign) {
|
||||
if (running || sessionStorage.getItem('gw-banner-dismissed')) {
|
||||
banner.classList.add('gw-banner-hidden')
|
||||
return
|
||||
} else {
|
||||
banner.classList.remove('gw-banner-hidden')
|
||||
}
|
||||
banner.classList.remove('gw-banner-hidden')
|
||||
|
||||
if (foreign) {
|
||||
// Gateway 在运行但属于外部实例 —— 显示认领按钮
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
|
||||
<span>${t('dashboard.controlUINotRunning')}</span>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">${t('dashboard.startBtn')}</button>
|
||||
<span class="gw-banner-icon">${statusIcon('warning', 16)}</span>
|
||||
<span>${t('dashboard.foreignGatewayBanner')}</span>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-claim" style="margin-left:auto">${t('dashboard.claimGateway')}</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services">${t('sidebar.services')}</a>
|
||||
<button class="gw-banner-close" id="btn-gw-dismiss" title="${t('common.close')}">×</button>
|
||||
</div>
|
||||
@@ -544,7 +547,39 @@ function setupGatewayBanner() {
|
||||
banner.classList.add('gw-banner-hidden')
|
||||
sessionStorage.setItem('gw-banner-dismissed', '1')
|
||||
})
|
||||
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
|
||||
banner.querySelector('#btn-gw-claim')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target
|
||||
btn.disabled = true
|
||||
btn.textContent = t('common.processing')
|
||||
try {
|
||||
await api.claimGateway()
|
||||
// 认领后立刻刷新全局状态
|
||||
const { refreshGatewayStatus } = await import('./lib/app-state.js')
|
||||
await refreshGatewayStatus()
|
||||
} catch (err) {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('dashboard.claimGateway')
|
||||
console.error('[banner] claim failed:', err)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Gateway 未运行 —— 显示启动按钮
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
|
||||
<span>${t('dashboard.controlUINotRunning')}</span>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">${t('dashboard.startBtn')}</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services">${t('sidebar.services')}</a>
|
||||
<button class="gw-banner-close" id="btn-gw-dismiss" title="${t('common.close')}">×</button>
|
||||
</div>
|
||||
`
|
||||
banner.querySelector('#btn-gw-dismiss')?.addEventListener('click', () => {
|
||||
banner.classList.add('gw-banner-hidden')
|
||||
sessionStorage.setItem('gw-banner-dismissed', '1')
|
||||
})
|
||||
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target
|
||||
btn.disabled = true
|
||||
btn.classList.add('btn-loading')
|
||||
@@ -600,10 +635,9 @@ function setupGatewayBanner() {
|
||||
`
|
||||
update(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
update(isGatewayRunning())
|
||||
update(isGatewayRunning(), isGatewayForeign())
|
||||
onGatewayChange(update)
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@ function renderServices(container, services) {
|
||||
html += `
|
||||
<div class="service-card" data-label="${gw.label}">
|
||||
<div class="service-info">
|
||||
<span class="status-dot ${cliMissing ? 'stopped' : gw.running ? 'running' : 'stopped'}"></span>
|
||||
<span class="status-dot ${cliMissing ? 'stopped' : foreignGateway ? 'warning' : gw.running ? 'running' : 'stopped'}"></span>
|
||||
<div>
|
||||
<div class="service-name">${gw.label}</div>
|
||||
<div class="service-desc">${cliMissing
|
||||
@@ -481,6 +481,7 @@ function renderServices(container, services) {
|
||||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||||
<div style="color:var(--warning);font-size:var(--font-size-xs);max-width:320px;text-align:right">${t('services.foreignGatewayHint')}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
|
||||
<button class="btn btn-primary btn-sm" data-action="claim-gateway">${t('services.claimGateway')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services">${t('services.refreshStatus')}</button>
|
||||
</div>
|
||||
@@ -604,6 +605,9 @@ function bindEvents(page) {
|
||||
case 'refresh-services':
|
||||
await loadServices(page)
|
||||
break
|
||||
case 'claim-gateway':
|
||||
await handleClaimGateway(btn, page)
|
||||
break
|
||||
case 'resolve-foreign-gateway':
|
||||
await openGatewayConflict(page)
|
||||
break
|
||||
@@ -721,6 +725,8 @@ async function handleServiceAction(action, label, page) {
|
||||
const svc = services?.find?.(s => s.label === label) || services?.[0]
|
||||
if (svc && svc.running === expectRunning) {
|
||||
toast(t('services.actionDone', { label, action: actionLabel }) + (svc.pid ? ' (PID: ' + svc.pid + ')' : ''), 'success')
|
||||
// 立刻同步全局 Gateway 状态(顶部 banner + WS 连接)
|
||||
import('../lib/app-state.js').then(m => m.refreshGatewayStatus()).catch(() => {})
|
||||
await loadServices(page)
|
||||
return
|
||||
}
|
||||
@@ -971,6 +977,25 @@ async function handleSwitchSource(target, page) {
|
||||
await doUpgradeWithModal(target, page, null)
|
||||
}
|
||||
|
||||
// ===== 认领外部 Gateway =====
|
||||
|
||||
async function handleClaimGateway(btn, page) {
|
||||
btn.classList.add('btn-loading')
|
||||
btn.textContent = t('common.processing')
|
||||
try {
|
||||
await api.claimGateway()
|
||||
toast(t('services.claimSuccess'), 'success')
|
||||
// 立刻刷新全局 Gateway 状态
|
||||
const { refreshGatewayStatus } = await import('../lib/app-state.js')
|
||||
await refreshGatewayStatus()
|
||||
await loadServices(page)
|
||||
} catch (e) {
|
||||
toast(t('services.claimFailed') + ': ' + e, 'error')
|
||||
btn.classList.remove('btn-loading')
|
||||
btn.textContent = t('services.claimGateway')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Gateway 安装/卸载 =====
|
||||
|
||||
async function handleInstallGateway(btn, page) {
|
||||
|
||||
Reference in New Issue
Block a user