mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
feat(channels): add gateway runtime status
This commit is contained in:
170
src/lib/channel-runtime.js
Normal file
170
src/lib/channel-runtime.js
Normal file
@@ -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]) : ''
|
||||
}
|
||||
@@ -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 `
|
||||
<div class="channel-runtime-notice error">
|
||||
${icon('alert-triangle', 14)}
|
||||
<span>无法读取 Gateway 渠道运行态:${escapeAttr(state.runtimeStatusError)}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
if (!state.runtimeStatus?.supported) {
|
||||
return `
|
||||
<div class="channel-runtime-notice warning">
|
||||
${icon('alert-triangle', 14)}
|
||||
<span>当前 OpenClaw 内核不支持通用渠道运行态,请升级到新版内核;配置编辑仍可继续使用。</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
const warnings = Array.isArray(state.runtimeStatus.warnings) ? state.runtimeStatus.warnings : []
|
||||
if (state.runtimeStatus.partial || warnings.length) {
|
||||
return `
|
||||
<div class="channel-runtime-notice warning">
|
||||
${icon('alert-triangle', 14)}
|
||||
<span>${state.runtimeStatus.partial ? '运行态结果不完整' : '运行态探测有提示'}${warnings.length ? `:${escapeAttr(warnings.join(';'))}` : ''}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function renderRuntimeBadge(summary) {
|
||||
if (!summary.supported) return ''
|
||||
const meta = getRuntimeStateMeta(summary.state)
|
||||
return `<span class="runtime-badge ${meta.tone}" title="Gateway 运行态">${icon(meta.icon, 12)} ${meta.label}</span>`
|
||||
}
|
||||
|
||||
function renderRuntimeSummary(summary) {
|
||||
if (!summary.supported) {
|
||||
return `<div class="channel-runtime-summary muted">通用运行态不可用</div>`
|
||||
}
|
||||
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 `<div class="channel-runtime-summary ${summary.state}">${escapeAttr(parts.join(' · ') || 'Gateway 暂未返回账号状态')}</div>`
|
||||
}
|
||||
|
||||
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 `
|
||||
<span class="runtime-account ${meta.tone}" title="${escapeAttr(details.join(' · ') || meta.label)}">
|
||||
${icon(meta.icon, 12)} ${meta.label}
|
||||
</span>
|
||||
`
|
||||
}
|
||||
|
||||
function renderRuntimeActions(summary, accountId = '') {
|
||||
if (!summary.supported) {
|
||||
return `<button class="btn btn-sm btn-secondary" data-runtime-action="refresh">${icon('refresh-cw', 14)} 刷新状态</button>`
|
||||
}
|
||||
const accountAttr = escapeAttr(accountId || '')
|
||||
const stopDisabled = summary.state === 'missing' || summary.state === 'configured'
|
||||
const logoutDisabled = summary.state === 'missing'
|
||||
return `
|
||||
<button class="btn btn-sm btn-secondary" data-runtime-action="refresh" data-account-id="${accountAttr}" title="刷新 Gateway 渠道状态">${icon('refresh-cw', 14)} 刷新</button>
|
||||
<button class="btn btn-sm btn-secondary" data-runtime-action="start" data-account-id="${accountAttr}" title="启动该渠道运行时">${icon('play', 14)} 启动</button>
|
||||
<button class="btn btn-sm btn-secondary" data-runtime-action="stop" data-account-id="${accountAttr}" ${stopDisabled ? 'disabled' : ''} title="停止该渠道运行时">${icon('stop', 14)} 停止</button>
|
||||
<button class="btn btn-sm btn-secondary" data-runtime-action="logout" data-account-id="${accountAttr}" ${logoutDisabled ? 'disabled' : ''} title="注销该渠道账号登录态">${icon('x-circle', 14)} 注销</button>
|
||||
`
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">${t('channels.configured')}</div>
|
||||
${renderRuntimeNotice(state)}
|
||||
<div class="platforms-grid">
|
||||
${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) {
|
||||
<span class="account-id">${escapeAttr(accId)}</span>
|
||||
${acc.appId ? `<span class="account-appid">${escapeAttr(acc.appId)}</span>` : ''}
|
||||
${badgesHtml}
|
||||
${renderRuntimeAccountInfo(runtimeSummary, acc.accountId || '')}
|
||||
<span class="account-actions">
|
||||
<button class="btn btn-xs btn-secondary" data-action="edit-account" data-account-id="${escapeAttr(acc.accountId || '')}">${icon('edit', 12)} ${t('channels.editAccount')}</button>
|
||||
<button class="btn btn-xs btn-danger" data-action="remove-account" data-account-id="${escapeAttr(acc.accountId || '')}">${icon('trash', 12)}</button>
|
||||
@@ -440,10 +594,13 @@ function renderConfigured(page, state) {
|
||||
<span class="platform-emoji">${ic}</span>
|
||||
<span class="platform-name">${label}</span>
|
||||
<span class="account-count">${t('channels.accountCount', { count: accounts.length })}</span>
|
||||
${renderRuntimeBadge(runtimeSummary)}
|
||||
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
|
||||
</div>
|
||||
${renderRuntimeSummary(runtimeSummary)}
|
||||
<div class="platform-accounts">${accountsHtml}</div>
|
||||
<div class="platform-card-actions">
|
||||
${renderRuntimeActions(runtimeSummary)}
|
||||
${supportsMulti ? `<button class="btn btn-sm btn-secondary" data-action="add-account">${icon('plus', 14)} ${t('channels.addAccount')}</button>` : ''}
|
||||
${reg ? `<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)} ${t('channels.editDefault')}</button>` : `<span class="form-hint" style="align-self:center">${t('channels.noGuide')}</span>`}
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">${p.enabled ? icon('pause', 14) + ' ' + t('channels.disable') : icon('play', 14) + ' ' + t('channels.enable')}</button>
|
||||
@@ -465,9 +622,12 @@ function renderConfigured(page, state) {
|
||||
<span class="platform-emoji">${ic}</span>
|
||||
<span class="platform-name">${label}</span>
|
||||
${agentBadges}
|
||||
${renderRuntimeBadge(runtimeSummary)}
|
||||
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
|
||||
</div>
|
||||
${renderRuntimeSummary(runtimeSummary)}
|
||||
<div class="platform-card-actions">
|
||||
${renderRuntimeActions(runtimeSummary)}
|
||||
${supportsMulti ? `<button class="btn btn-sm btn-secondary" data-action="add-account">${icon('plus', 14)} ${t('channels.addAccount')}</button>` : ''}
|
||||
${reg ? `<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)} ${t('channels.editAccount')}</button>` : `<span class="form-hint" style="align-self:center">${t('channels.noGuide')}</span>`}
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">${p.enabled ? icon('pause', 14) + ' ' + t('channels.disable') : icon('play', 14) + ' ' + t('channels.enable')}</button>
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
tests/channel-runtime.test.js
Normal file
93
tests/channel-runtime.test.js
Normal file
@@ -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 天前')
|
||||
})
|
||||
Reference in New Issue
Block a user