From 1ae223a0b12def8956e274578303ed0f72c2f80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 00:09:46 +0800 Subject: [PATCH] feat(channels): add gateway runtime status --- src/lib/channel-runtime.js | 170 ++++++++++++++++++++++++++++++++++ src/pages/channels.js | 165 +++++++++++++++++++++++++++++++++ src/style/pages.css | 141 +++++++++++++++++++++++++++- tests/channel-runtime.test.js | 93 +++++++++++++++++++ 4 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 src/lib/channel-runtime.js create mode 100644 tests/channel-runtime.test.js diff --git a/src/lib/channel-runtime.js b/src/lib/channel-runtime.js new file mode 100644 index 0000000..31fd8a7 --- /dev/null +++ b/src/lib/channel-runtime.js @@ -0,0 +1,170 @@ +const EMPTY_STATUS = Object.freeze({ + supported: false, + ts: 0, + partial: false, + warnings: Object.freeze([]), + channelOrder: Object.freeze([]), + channelLabels: Object.freeze({}), + channelDetailLabels: Object.freeze({}), + channelAccounts: Object.freeze({}), + channelDefaultAccountId: Object.freeze({}), + channels: Object.freeze({}), + eventLoop: null, +}) + +export function normalizeChannelRuntimeStatus(raw) { + if (!raw || typeof raw !== 'object') { + return { ...EMPTY_STATUS } + } + + return { + supported: true, + ts: Number.isFinite(raw.ts) ? raw.ts : Date.now(), + partial: raw.partial === true, + warnings: Array.isArray(raw.warnings) ? raw.warnings.filter(Boolean).map(String) : [], + channelOrder: Array.isArray(raw.channelOrder) ? raw.channelOrder.filter(Boolean).map(String) : [], + channelLabels: plainObject(raw.channelLabels), + channelDetailLabels: plainObject(raw.channelDetailLabels), + channelAccounts: normalizeChannelAccounts(raw.channelAccounts), + channelDefaultAccountId: plainObject(raw.channelDefaultAccountId), + channels: plainObject(raw.channels), + eventLoop: raw.eventLoop && typeof raw.eventLoop === 'object' ? raw.eventLoop : null, + } +} + +export function getChannelRuntimeSummary(status, channelId, fallbackLabel = '') { + const normalized = status && typeof status === 'object' && Object.prototype.hasOwnProperty.call(status, 'supported') + ? status + : normalizeChannelRuntimeStatus(status) + const channel = String(channelId || '') + const accounts = normalizeChannelAccounts(normalized.channelAccounts)[channel] || [] + const counts = countAccountStates(accounts) + const supported = normalized.supported === true + const state = supported ? pickSummaryState({ accounts, counts }) : 'unsupported' + + return { + supported, + channel, + label: normalized.channelLabels?.[channel] || fallbackLabel || channel, + detailLabel: normalized.channelDetailLabels?.[channel] || '', + defaultAccountId: normalized.channelDefaultAccountId?.[channel] || '', + rawChannel: normalized.channels?.[channel] || null, + accounts, + counts, + state, + lastError: firstAccountValue(accounts, 'lastError'), + lastInboundAt: latestNumber(accounts, 'lastInboundAt'), + lastOutboundAt: latestNumber(accounts, 'lastOutboundAt'), + lastTransportActivityAt: latestNumber(accounts, 'lastTransportActivityAt'), + } +} + +export function getRuntimeStateMeta(state) { + switch (state) { + case 'error': + return { label: '异常', tone: 'error', icon: 'alert-triangle' } + case 'connected': + return { label: '已连接', tone: 'success', icon: 'check' } + case 'running': + return { label: '运行中', tone: 'accent', icon: 'play' } + case 'configured': + return { label: '已配置', tone: 'warning', icon: 'gear' } + case 'disabled': + return { label: '已停用', tone: 'muted', icon: 'pause' } + case 'missing': + return { label: '未配置', tone: 'muted', icon: 'circle' } + case 'unsupported': + return { label: '内核不支持', tone: 'warning', icon: 'alert-triangle' } + default: + return { label: '未知', tone: 'muted', icon: 'info' } + } +} + +export function formatRuntimeAge(value, now = Date.now()) { + const ts = Number(value) + if (!Number.isFinite(ts) || ts <= 0) return '' + const delta = Math.max(0, Number(now) - ts) + const seconds = Math.floor(delta / 1000) + if (seconds < 45) return '刚刚' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes} 分钟前` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours} 小时前` + const days = Math.floor(hours / 24) + return `${days} 天前` +} + +function normalizeChannelAccounts(value) { + const result = {} + if (!value || typeof value !== 'object' || Array.isArray(value)) return result + for (const [channel, accounts] of Object.entries(value)) { + if (!Array.isArray(accounts)) continue + result[channel] = accounts + .filter(account => account && typeof account === 'object') + .map(account => { + const accountId = account.accountId == null || account.accountId === '' ? 'default' : String(account.accountId) + return { + ...account, + accountId, + state: pickAccountState(account), + lastError: account.lastError ? String(account.lastError) : '', + } + }) + } + return result +} + +function plainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return { ...value } +} + +function pickAccountState(account) { + if (account.lastError) return 'error' + if (account.enabled === false) return 'disabled' + if (account.connected === true || account.linked === true) return 'connected' + if (account.running === true) return 'running' + if (account.configured === true) return 'configured' + return 'missing' +} + +function pickSummaryState({ accounts, counts }) { + if (!accounts.length) return 'missing' + if (counts.error > 0) return 'error' + if (counts.connected > 0) return 'connected' + if (counts.running > 0) return 'running' + if (counts.configured > 0) return 'configured' + if (counts.disabled > 0) return 'disabled' + return 'missing' +} + +function countAccountStates(accounts) { + const counts = { + total: accounts.length, + error: 0, + connected: 0, + running: 0, + configured: 0, + disabled: 0, + missing: 0, + } + for (const account of accounts) { + const state = account.state || pickAccountState(account) + counts[state] = (counts[state] || 0) + 1 + } + return counts +} + +function latestNumber(accounts, key) { + let latest = 0 + for (const account of accounts) { + const value = Number(account?.[key]) + if (Number.isFinite(value) && value > latest) latest = value + } + return latest || null +} + +function firstAccountValue(accounts, key) { + const found = accounts.find(account => account?.[key]) + return found?.[key] ? String(found[key]) : '' +} diff --git a/src/pages/channels.js b/src/pages/channels.js index 49059d8..24da556 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -11,6 +11,12 @@ import { CHANNEL_LABELS } from '../lib/channel-labels.js' import { t } from '../lib/i18n.js' import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js' import { wsClient } from '../lib/ws-client.js' +import { + formatRuntimeAge, + getChannelRuntimeSummary, + getRuntimeStateMeta, + normalizeChannelRuntimeStatus, +} from '../lib/channel-runtime.js' // ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ── @@ -301,6 +307,8 @@ export async function render() { configured: [], bindings: [], agents: [], + runtimeStatus: normalizeChannelRuntimeStatus(null), + runtimeStatusError: '', routeIntent: parseChannelsRouteIntent(), routeIntentConsumed: false, routeIntentHintShown: false, @@ -346,6 +354,26 @@ async function loadPlatforms(page, state) { renderAvailable(page, state) renderAgentBindings(page, state) applyRouteIntent(page, state) + refreshChannelRuntimeStatus(page, state, { probe: true, timeoutMs: 5000 }) +} + +async function loadChannelRuntimeStatus(state, options = {}) { + state.runtimeStatusError = '' + try { + const raw = await wsClient.requestCompat('channels.status', { + probe: options.probe !== false, + timeoutMs: options.timeoutMs || 5000, + }, null) + state.runtimeStatus = normalizeChannelRuntimeStatus(raw) + } catch (e) { + state.runtimeStatus = normalizeChannelRuntimeStatus(null) + state.runtimeStatusError = e?.message || String(e) + } +} + +async function refreshChannelRuntimeStatus(page, state, options = {}) { + await loadChannelRuntimeStatus(state, options) + renderConfigured(page, state) } function applyRouteIntent(page, state) { @@ -390,6 +418,129 @@ function platformLabel(pid) { return PLATFORM_REGISTRY[pid]?.label || CHANNEL_LABELS[pid] || pid } +function renderRuntimeNotice(state) { + if (state.runtimeStatusError) { + return ` +
+ ${icon('alert-triangle', 14)} + 无法读取 Gateway 渠道运行态:${escapeAttr(state.runtimeStatusError)} +
+ ` + } + if (!state.runtimeStatus?.supported) { + return ` +
+ ${icon('alert-triangle', 14)} + 当前 OpenClaw 内核不支持通用渠道运行态,请升级到新版内核;配置编辑仍可继续使用。 +
+ ` + } + const warnings = Array.isArray(state.runtimeStatus.warnings) ? state.runtimeStatus.warnings : [] + if (state.runtimeStatus.partial || warnings.length) { + return ` +
+ ${icon('alert-triangle', 14)} + ${state.runtimeStatus.partial ? '运行态结果不完整' : '运行态探测有提示'}${warnings.length ? `:${escapeAttr(warnings.join(';'))}` : ''} +
+ ` + } + return '' +} + +function renderRuntimeBadge(summary) { + if (!summary.supported) return '' + const meta = getRuntimeStateMeta(summary.state) + return `${icon(meta.icon, 12)} ${meta.label}` +} + +function renderRuntimeSummary(summary) { + if (!summary.supported) { + return `
通用运行态不可用
` + } + const parts = [] + if (summary.counts.total) parts.push(`${summary.counts.total} 个账号`) + if (summary.counts.connected) parts.push(`${summary.counts.connected} 已连接`) + if (summary.counts.running) parts.push(`${summary.counts.running} 运行中`) + if (summary.counts.error) parts.push(`${summary.counts.error} 异常`) + if (summary.lastInboundAt) parts.push(`最近接收 ${formatRuntimeAge(summary.lastInboundAt)}`) + if (summary.lastOutboundAt) parts.push(`最近发送 ${formatRuntimeAge(summary.lastOutboundAt)}`) + if (summary.lastError) parts.push(`错误:${summary.lastError}`) + return `
${escapeAttr(parts.join(' · ') || 'Gateway 暂未返回账号状态')}
` +} + +function renderRuntimeAccountInfo(summary, accountId) { + if (!summary.supported) return '' + const normalizedAccountId = accountId || 'default' + const account = summary.accounts.find(a => (a.accountId || 'default') === normalizedAccountId) + || (!accountId ? summary.accounts.find(a => a.accountId === summary.defaultAccountId) : null) + if (!account) return '' + const meta = getRuntimeStateMeta(account.state) + const details = [] + if (account.lastError) details.push(account.lastError) + if (account.healthState) details.push(`health=${account.healthState}`) + if (account.lastInboundAt) details.push(`收 ${formatRuntimeAge(account.lastInboundAt)}`) + if (account.lastOutboundAt) details.push(`发 ${formatRuntimeAge(account.lastOutboundAt)}`) + if (account.probe && typeof account.probe === 'object' && account.probe.ok === false) details.push('探测失败') + return ` + + ${icon(meta.icon, 12)} ${meta.label} + + ` +} + +function renderRuntimeActions(summary, accountId = '') { + if (!summary.supported) { + return `` + } + const accountAttr = escapeAttr(accountId || '') + const stopDisabled = summary.state === 'missing' || summary.state === 'configured' + const logoutDisabled = summary.state === 'missing' + return ` + + + + + ` +} + +async function handleRuntimeAction(pid, action, accountId, btn, page, state) { + const channel = getChannelBindingKey(pid) + const prevHtml = btn?.innerHTML + if (btn) { + btn.disabled = true + btn.textContent = action === 'refresh' ? '刷新中' : '处理中' + } + try { + if (action === 'refresh') { + await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 }) + renderConfigured(page, state) + toast('渠道运行态已刷新', 'success') + return + } + + if (!wsClient.connected || !wsClient.gatewayReady) { + throw new Error('Gateway WebSocket 未连接,请先启动 OpenClaw Gateway') + } + + const params = { channel } + if (accountId) params.accountId = accountId + await wsClient.request(`channels.${action}`, params) + await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 }) + renderConfigured(page, state) + const actionText = action === 'start' ? '启动' : action === 'stop' ? '停止' : '注销' + toast(`${platformLabel(pid)} ${actionText}完成`, 'success') + } catch (e) { + state.runtimeStatusError = e?.message || String(e) + renderConfigured(page, state) + toast(humanizeError(e, '渠道运行时操作失败'), 'error') + } finally { + if (btn) { + btn.disabled = false + if (prevHtml != null) btn.innerHTML = prevHtml + } + } +} + function renderConfigured(page, state) { const el = page.querySelector('#platforms-configured') if (!state.configured.length) { @@ -400,12 +551,14 @@ function renderConfigured(page, state) { el.innerHTML = `
${t('channels.configured')}
+ ${renderRuntimeNotice(state)}
${state.configured.map(p => { const reg = PLATFORM_REGISTRY[p.id] const label = platformLabel(p.id) const ic = icon(reg?.iconName || 'radio', 22) const channelKey = getChannelBindingKey(p.id) + const runtimeSummary = getChannelRuntimeSummary(state.runtimeStatus, channelKey, label) const accounts = Array.isArray(p.accounts) ? p.accounts : [] const hasAccounts = accounts.length > 0 const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(p.id) @@ -426,6 +579,7 @@ function renderConfigured(page, state) { ${acc.appId ? `` : ''} ${badgesHtml} + ${renderRuntimeAccountInfo(runtimeSummary, acc.accountId || '')}
+ ${renderRuntimeSummary(runtimeSummary)}
${accountsHtml}
+ ${renderRuntimeActions(runtimeSummary)} ${supportsMulti ? `` : ''} ${reg ? `` : `${t('channels.noGuide')}`} @@ -465,9 +622,12 @@ function renderConfigured(page, state) { ${ic} ${label} ${agentBadges} + ${renderRuntimeBadge(runtimeSummary)}
+ ${renderRuntimeSummary(runtimeSummary)}
+ ${renderRuntimeActions(runtimeSummary)} ${supportsMulti ? `` : ''} ${reg ? `` : `${t('channels.noGuide')}`} @@ -691,6 +851,11 @@ function renderConfigured(page, state) { card.querySelector('[data-action="add-account"]')?.addEventListener('click', () => openConfigDialog(pid, page, state, '')) card.querySelector('[data-action="edit"]')?.addEventListener('click', () => openConfigDialog(pid, page, state)) + card.querySelectorAll('[data-runtime-action]').forEach(btn => { + btn.addEventListener('click', () => { + handleRuntimeAction(pid, btn.dataset.runtimeAction, btn.dataset.accountId || '', btn, page, state) + }) + }) card.querySelectorAll('[data-action="edit-account"]').forEach(btn => { btn.addEventListener('click', () => { diff --git a/src/style/pages.css b/src/style/pages.css index 554f569..412aaa7 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -1005,6 +1005,7 @@ align-items: center; gap: var(--space-sm); margin-bottom: var(--space-md); + flex-wrap: wrap; } .platform-emoji { font-size: 22px; @@ -1035,6 +1036,144 @@ flex-wrap: wrap; } +.channel-runtime-notice { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: 10px 12px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + line-height: 1.55; + margin-bottom: var(--space-md); +} +.channel-runtime-notice.warning { + color: var(--warning); + background: var(--warning-muted); + border-color: rgba(217, 119, 6, 0.25); +} +.channel-runtime-notice.error { + color: var(--error); + background: var(--error-muted); + border-color: rgba(220, 38, 38, 0.25); +} +.runtime-badge, +.runtime-account { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 2px 7px; + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: 600; + white-space: nowrap; +} +.runtime-badge.success, +.runtime-account.success { + color: var(--success); + background: var(--success-muted); +} +.runtime-badge.error, +.runtime-account.error { + color: var(--error); + background: var(--error-muted); +} +.runtime-badge.warning, +.runtime-account.warning { + color: var(--warning); + background: var(--warning-muted); +} +.runtime-badge.accent, +.runtime-account.accent { + color: var(--accent); + background: var(--accent-muted); +} +.runtime-badge.muted, +.runtime-account.muted { + color: var(--text-secondary); + background: var(--bg-tertiary); +} +.channel-runtime-summary { + margin: -4px 0 var(--space-md); + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: var(--font-size-xs); + line-height: 1.55; + word-break: break-word; +} +.channel-runtime-summary.error { + color: var(--error); + background: var(--error-muted); +} +.channel-runtime-summary.connected { + color: var(--success); + background: var(--success-muted); +} +.channel-runtime-summary.running { + color: var(--accent); + background: var(--accent-muted); +} +.channel-runtime-summary.configured { + color: var(--warning); + background: var(--warning-muted); +} +.platform-accounts { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} +.account-item { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-wrap: wrap; + padding: 8px 10px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} +.account-id { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 700; + color: var(--text-primary); +} +.account-appid { + min-width: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-xs); + color: var(--text-tertiary); +} +.account-actions { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +@media (max-width: 640px) { + .platforms-grid { + grid-template-columns: 1fr; + } + .platform-card { + padding: var(--space-md); + } + .platform-card-actions .btn { + min-height: 36px; + } + .account-actions { + width: 100%; + margin-left: 0; + } +} + /* 可接入平台选择按钮 */ .platform-pick { display: flex; @@ -3147,4 +3286,4 @@ transition: none !important; animation: none !important; } -} \ No newline at end of file +} diff --git a/tests/channel-runtime.test.js b/tests/channel-runtime.test.js new file mode 100644 index 0000000..f26ef3a --- /dev/null +++ b/tests/channel-runtime.test.js @@ -0,0 +1,93 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + formatRuntimeAge, + getChannelRuntimeSummary, + normalizeChannelRuntimeStatus, +} from '../src/lib/channel-runtime.js' + +test('normalizeChannelRuntimeStatus marks missing RPC result as unsupported', () => { + const status = normalizeChannelRuntimeStatus(null) + + assert.equal(status.supported, false) + assert.deepEqual(status.channelOrder, []) + assert.deepEqual(status.channels, {}) +}) + +test('getChannelRuntimeSummary preserves normalized unsupported status', () => { + const status = normalizeChannelRuntimeStatus(null) + const summary = getChannelRuntimeSummary(status, 'telegram', 'Telegram') + + assert.equal(summary.supported, false) + assert.equal(summary.state, 'unsupported') + assert.equal(summary.label, 'Telegram') + assert.deepEqual(summary.accounts, []) +}) + +test('getChannelRuntimeSummary prefers account errors over connected state', () => { + const status = normalizeChannelRuntimeStatus({ + ts: 1000, + channelOrder: ['telegram'], + channelLabels: { telegram: 'Telegram' }, + channelAccounts: { + telegram: [ + { + accountId: 'bot-a', + configured: true, + enabled: true, + running: true, + connected: true, + lastError: '401 Unauthorized', + }, + ], + }, + channelDefaultAccountId: { telegram: 'bot-a' }, + channels: {}, + }) + + const summary = getChannelRuntimeSummary(status, 'telegram') + + assert.equal(summary.supported, true) + assert.equal(summary.state, 'error') + assert.equal(summary.label, 'Telegram') + assert.equal(summary.defaultAccountId, 'bot-a') + assert.equal(summary.accounts[0].state, 'error') + assert.equal(summary.accounts[0].lastError, '401 Unauthorized') +}) + +test('getChannelRuntimeSummary counts account states and preserves unknown channel fields', () => { + const status = normalizeChannelRuntimeStatus({ + ts: 2000, + partial: true, + warnings: ['probe timeout'], + channelOrder: ['slack'], + channelLabels: { slack: 'Slack' }, + channelAccounts: { + slack: [ + { accountId: 'team-a', configured: true, running: true }, + { accountId: 'team-b', configured: true, connected: true, audit: { messages: 3 } }, + { accountId: 'team-c', enabled: false, configured: true }, + ], + }, + channelDefaultAccountId: { slack: 'team-a' }, + channels: { slack: { custom: 'value' } }, + }) + + const summary = getChannelRuntimeSummary(status, 'slack') + + assert.equal(status.supported, true) + assert.equal(status.partial, true) + assert.deepEqual(status.warnings, ['probe timeout']) + assert.deepEqual(status.channels.slack, { custom: 'value' }) + assert.equal(summary.state, 'connected') + assert.equal(summary.counts.connected, 1) + assert.equal(summary.counts.running, 1) + assert.equal(summary.counts.disabled, 1) +}) + +test('formatRuntimeAge returns compact relative time labels', () => { + assert.equal(formatRuntimeAge(1_000, 61_000), '1 分钟前') + assert.equal(formatRuntimeAge(1_000, 3_601_000), '1 小时前') + assert.equal(formatRuntimeAge(1_000, 172_801_000), '2 天前') +})