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:
晴天
2026-04-05 23:12:16 +08:00
parent b2ab316353
commit 42aeb8b077
10 changed files with 123 additions and 26 deletions

View File

@@ -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) {

View File

@@ -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(())
}

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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'),
// 配置(读缓存,写清缓存)

View File

@@ -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...'),
}

View File

@@ -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'),
}

View File

@@ -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', '認領失敗', '引き取り失敗', '인수 실패'),
}

View File

@@ -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')}">&times;</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')}">&times;</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)
}

View File

@@ -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) {