@@ -33,7 +33,9 @@ import {
3333import {
3434 computeChannelRuntimeStatus ,
3535 pickChannelRuntimeStatus ,
36+ type ChannelConnectionStatus ,
3637 type ChannelRuntimeAccountSnapshot ,
38+ type GatewayHealthState ,
3739} from '../../utils/channel-status' ;
3840import {
3941 OPENCLAW_WECHAT_CHANNEL_TYPE ,
@@ -65,6 +67,8 @@ import {
6567 normalizeWhatsAppMessagingTarget ,
6668} from '../../utils/openclaw-sdk' ;
6769import { logger } from '../../utils/logger' ;
70+ import { buildGatewayHealthSummary } from '../../utils/gateway-health' ;
71+ import type { GatewayHealthSummary } from '../../gateway/manager' ;
6872
6973// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports
7074// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery.
@@ -405,18 +409,43 @@ interface ChannelAccountView {
405409 running : boolean ;
406410 linked : boolean ;
407411 lastError ?: string ;
408- status : 'connected' | 'connecting' | 'disconnected' | 'error' ;
412+ status : ChannelConnectionStatus ;
413+ statusReason ?: string ;
409414 isDefault : boolean ;
410415 agentId ?: string ;
411416}
412417
413418interface ChannelAccountsView {
414419 channelType : string ;
415420 defaultAccountId : string ;
416- status : 'connected' | 'connecting' | 'disconnected' | 'error' ;
421+ status : ChannelConnectionStatus ;
422+ statusReason ?: string ;
417423 accounts : ChannelAccountView [ ] ;
418424}
419425
426+ export function getChannelStatusDiagnostics ( ) : {
427+ lastChannelsStatusOkAt ?: number ;
428+ lastChannelsStatusFailureAt ?: number ;
429+ } {
430+ return {
431+ lastChannelsStatusOkAt,
432+ lastChannelsStatusFailureAt,
433+ } ;
434+ }
435+
436+ function gatewayHealthStateForChannels (
437+ gatewayHealthState : GatewayHealthState ,
438+ ) : GatewayHealthState | undefined {
439+ return gatewayHealthState === 'healthy' ? undefined : gatewayHealthState ;
440+ }
441+
442+ function overlayStatusReason (
443+ gatewayHealth : GatewayHealthSummary ,
444+ fallbackReason : string ,
445+ ) : string {
446+ return gatewayHealth . reasons [ 0 ] || fallbackReason ;
447+ }
448+
420449function buildGatewayStatusSnapshot ( status : GatewayChannelStatusPayload | null ) : string {
421450 if ( ! status ?. channelAccounts ) return 'none' ;
422451 const entries = Object . entries ( status . channelAccounts ) ;
@@ -480,11 +509,13 @@ type DirectoryEntry = {
480509const CHANNEL_TARGET_CACHE_TTL_MS = 60_000 ;
481510const CHANNEL_TARGET_CACHE_ENABLED = process . env . VITEST !== 'true' ;
482511const channelTargetCache = new Map < string , { expiresAt : number ; targets : ChannelTargetOptionView [ ] } > ( ) ;
512+ let lastChannelsStatusOkAt : number | undefined ;
513+ let lastChannelsStatusFailureAt : number | undefined ;
483514
484- async function buildChannelAccountsView (
515+ export async function buildChannelAccountsView (
485516 ctx : HostApiContext ,
486517 options ?: { probe ?: boolean } ,
487- ) : Promise < ChannelAccountsView [ ] > {
518+ ) : Promise < { channels : ChannelAccountsView [ ] ; gatewayHealth : GatewayHealthSummary } > {
488519 const startedAt = Date . now ( ) ;
489520 // Read config once and share across all sub-calls (was 5 readFile calls before).
490521 const openClawConfig = await readOpenClawConfig ( ) ;
@@ -507,17 +538,32 @@ async function buildChannelAccountsView(
507538 { probe } ,
508539 probe ? 5000 : 8000 ,
509540 ) ;
541+ lastChannelsStatusOkAt = Date . now ( ) ;
510542 logger . info (
511543 `[channels.accounts] channels.status probe=${ probe ? '1' : '0' } elapsedMs=${ Date . now ( ) - rpcStartedAt } snapshot=${ buildGatewayStatusSnapshot ( gatewayStatus ) } `
512544 ) ;
513545 } catch {
514546 const probe = options ?. probe === true ;
547+ lastChannelsStatusFailureAt = Date . now ( ) ;
515548 logger . warn (
516549 `[channels.accounts] channels.status probe=${ probe ? '1' : '0' } failed after ${ Date . now ( ) - startedAt } ms`
517550 ) ;
518551 gatewayStatus = null ;
519552 }
520553
554+ const gatewayDiagnostics = ctx . gatewayManager . getDiagnostics ?.( ) ?? {
555+ consecutiveHeartbeatMisses : 0 ,
556+ consecutiveRpcFailures : 0 ,
557+ } ;
558+ const gatewayHealth = buildGatewayHealthSummary ( {
559+ status : ctx . gatewayManager . getStatus ( ) ,
560+ diagnostics : gatewayDiagnostics ,
561+ lastChannelsStatusOkAt,
562+ lastChannelsStatusFailureAt,
563+ platform : process . platform ,
564+ } ) ;
565+ const gatewayHealthState = gatewayHealthStateForChannels ( gatewayHealth . state ) ;
566+
521567 const channelTypes = new Set < string > ( [
522568 ...configuredChannels ,
523569 ...Object . keys ( configuredAccounts ) ,
@@ -566,7 +612,9 @@ async function buildChannelAccountsView(
566612 const accounts : ChannelAccountView [ ] = accountIds . map ( ( accountId ) => {
567613 const runtime = runtimeAccounts . find ( ( item ) => item . accountId === accountId ) ;
568614 const runtimeSnapshot : ChannelRuntimeAccountSnapshot = runtime ?? { } ;
569- const status = computeChannelRuntimeStatus ( runtimeSnapshot ) ;
615+ const status = computeChannelRuntimeStatus ( runtimeSnapshot , {
616+ gatewayHealthState,
617+ } ) ;
570618 return {
571619 accountId,
572620 name : runtime ?. name || accountId ,
@@ -576,6 +624,11 @@ async function buildChannelAccountsView(
576624 linked : runtime ?. linked === true ,
577625 lastError : typeof runtime ?. lastError === 'string' ? runtime . lastError : undefined ,
578626 status,
627+ statusReason : status === 'degraded'
628+ ? overlayStatusReason ( gatewayHealth , 'gateway_degraded' )
629+ : status === 'error'
630+ ? 'runtime_error'
631+ : undefined ,
579632 isDefault : accountId === defaultAccountId ,
580633 agentId : agentsSnapshot . channelAccountOwners [ `${ rawChannelType } :${ accountId } ` ] ,
581634 } ;
@@ -585,10 +638,32 @@ async function buildChannelAccountsView(
585638 return left . accountId . localeCompare ( right . accountId ) ;
586639 } ) ;
587640
641+ const visibleAccountSnapshots : ChannelRuntimeAccountSnapshot [ ] = accounts . map ( ( account ) => ( {
642+ connected : account . connected ,
643+ running : account . running ,
644+ linked : account . linked ,
645+ lastError : account . lastError ,
646+ } ) ) ;
647+ const hasRuntimeError = visibleAccountSnapshots . some ( ( account ) => typeof account . lastError === 'string' && account . lastError . trim ( ) )
648+ || Boolean ( channelSummary ?. error ?. trim ( ) || channelSummary ?. lastError ?. trim ( ) ) ;
649+ const baseGroupStatus = pickChannelRuntimeStatus ( visibleAccountSnapshots , channelSummary ) ;
650+ const groupStatus = ! gatewayStatus && ctx . gatewayManager . getStatus ( ) . state === 'running'
651+ ? 'degraded'
652+ : gatewayHealthState && ! hasRuntimeError && baseGroupStatus === 'connected'
653+ ? 'degraded'
654+ : pickChannelRuntimeStatus ( visibleAccountSnapshots , channelSummary , {
655+ gatewayHealthState,
656+ } ) ;
657+
588658 channels . push ( {
589659 channelType : uiChannelType ,
590660 defaultAccountId,
591- status : pickChannelRuntimeStatus ( runtimeAccounts , channelSummary ) ,
661+ status : groupStatus ,
662+ statusReason : ! gatewayStatus && ctx . gatewayManager . getStatus ( ) . state === 'running'
663+ ? 'channels_status_timeout'
664+ : groupStatus === 'degraded'
665+ ? overlayStatusReason ( gatewayHealth , 'gateway_degraded' )
666+ : undefined ,
592667 accounts,
593668 } ) ;
594669 }
@@ -597,7 +672,7 @@ async function buildChannelAccountsView(
597672 logger . info (
598673 `[channels.accounts] response probe=${ options ?. probe === true ? '1' : '0' } elapsedMs=${ Date . now ( ) - startedAt } view=${ sorted . map ( ( item ) => `${ item . channelType } :${ item . status } ` ) . join ( ',' ) } `
599674 ) ;
600- return sorted ;
675+ return { channels : sorted , gatewayHealth } ;
601676}
602677
603678function buildChannelTargetLabel ( baseLabel : string , value : string ) : string {
@@ -1193,8 +1268,8 @@ export async function handleChannelRoutes(
11931268 try {
11941269 const probe = url . searchParams . get ( 'probe' ) === '1' ;
11951270 logger . info ( `[channels.accounts] request probe=${ probe ? '1' : '0' } ` ) ;
1196- const channels = await buildChannelAccountsView ( ctx , { probe } ) ;
1197- sendJson ( res , 200 , { success : true , channels } ) ;
1271+ const { channels, gatewayHealth } = await buildChannelAccountsView ( ctx , { probe } ) ;
1272+ sendJson ( res , 200 , { success : true , channels, gatewayHealth } ) ;
11981273 } catch ( error ) {
11991274 sendJson ( res , 500 , { success : false , error : String ( error ) } ) ;
12001275 }
0 commit comments