refactor(channels): integrate channel runtime status management and enhance account status handling (#547)
This commit is contained in:
101
src/lib/channel-status.ts
Normal file
101
src/lib/channel-status.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
|
||||
export interface ChannelRuntimeAccountSnapshot {
|
||||
connected?: boolean;
|
||||
linked?: boolean;
|
||||
running?: boolean;
|
||||
lastError?: string | null;
|
||||
lastConnectedAt?: number | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
lastProbeAt?: number | null;
|
||||
probe?: {
|
||||
ok?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ChannelRuntimeSummarySnapshot {
|
||||
error?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
const RECENT_ACTIVITY_MS = 10 * 60 * 1000;
|
||||
|
||||
function hasNonEmptyError(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasRecentChannelActivity(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'lastConnectedAt' | 'lastInboundAt' | 'lastOutboundAt'>,
|
||||
now = Date.now(),
|
||||
recentMs = RECENT_ACTIVITY_MS,
|
||||
): boolean {
|
||||
return (
|
||||
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs) ||
|
||||
(typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs) ||
|
||||
(typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSuccessfulChannelProbe(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'probe'>,
|
||||
): boolean {
|
||||
return account.probe?.ok === true;
|
||||
}
|
||||
|
||||
export function hasChannelRuntimeError(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'lastError'>,
|
||||
): boolean {
|
||||
return hasNonEmptyError(account.lastError);
|
||||
}
|
||||
|
||||
export function hasSummaryRuntimeError(
|
||||
summary: ChannelRuntimeSummarySnapshot | undefined,
|
||||
): boolean {
|
||||
if (!summary) return false;
|
||||
return hasNonEmptyError(summary.error) || hasNonEmptyError(summary.lastError);
|
||||
}
|
||||
|
||||
export function isChannelRuntimeConnected(
|
||||
account: ChannelRuntimeAccountSnapshot,
|
||||
): boolean {
|
||||
if (account.connected === true || account.linked === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasRecentChannelActivity(account) || hasSuccessfulChannelProbe(account)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenClaw integrations such as Feishu/WeCom may stay "running" without ever
|
||||
// setting a durable connected=true flag. Treat healthy running as connected.
|
||||
return account.running === true && !hasChannelRuntimeError(account);
|
||||
}
|
||||
|
||||
export function computeChannelRuntimeStatus(
|
||||
account: ChannelRuntimeAccountSnapshot,
|
||||
): ChannelConnectionStatus {
|
||||
if (isChannelRuntimeConnected(account)) return 'connected';
|
||||
if (hasChannelRuntimeError(account)) return 'error';
|
||||
if (account.running === true) return 'connecting';
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
export function pickChannelRuntimeStatus(
|
||||
accounts: ChannelRuntimeAccountSnapshot[],
|
||||
summary?: ChannelRuntimeSummarySnapshot,
|
||||
): ChannelConnectionStatus {
|
||||
if (accounts.some((account) => isChannelRuntimeConnected(account))) {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
if (accounts.some((account) => hasChannelRuntimeError(account)) || hasSummaryRuntimeError(summary)) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (accounts.some((account) => account.running === true)) {
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
return 'disconnected';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||
import type { AgentSummary } from '@/types/agent';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -43,6 +44,7 @@ interface ChannelGroupItem {
|
||||
export function Agents() {
|
||||
const { t } = useTranslation('agents');
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
const {
|
||||
agents,
|
||||
loading,
|
||||
@@ -70,6 +72,28 @@ export function Agents() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
||||
}, [fetchAgents, fetchChannelAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
|
||||
void fetchChannelAccounts();
|
||||
});
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [fetchChannelAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousGatewayState = lastGatewayStateRef.current;
|
||||
lastGatewayStateRef.current = gatewayStatus.state;
|
||||
|
||||
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchChannelAccounts();
|
||||
}
|
||||
}, [fetchChannelAccounts, gatewayStatus.state]);
|
||||
|
||||
const activeAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
||||
[activeAgentId, agents],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { RefreshCw, Trash2, AlertCircle, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -73,6 +73,7 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget):
|
||||
export function Channels() {
|
||||
const { t } = useTranslation('channels');
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -130,6 +131,15 @@ export function Channels() {
|
||||
};
|
||||
}, [fetchPageData]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousGatewayState = lastGatewayStateRef.current;
|
||||
lastGatewayStateRef.current = gatewayStatus.state;
|
||||
|
||||
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||
void fetchPageData();
|
||||
}
|
||||
}, [fetchPageData, gatewayStatus.state]);
|
||||
|
||||
const configuredTypes = useMemo(
|
||||
() => channelGroups.map((group) => group.channelType),
|
||||
[channelGroups],
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
isChannelRuntimeConnected,
|
||||
pickChannelRuntimeStatus,
|
||||
type ChannelRuntimeAccountSnapshot,
|
||||
} from '@/lib/channel-status';
|
||||
import { useGatewayStore } from './gateway';
|
||||
import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel';
|
||||
|
||||
@@ -52,6 +57,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
lastConnectedAt?: number | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
lastProbeAt?: number | null;
|
||||
probe?: {
|
||||
ok?: boolean;
|
||||
} | null;
|
||||
}>>;
|
||||
channelDefaultAccountId?: Record<string, string>;
|
||||
}>('channels.status', { probe: true });
|
||||
@@ -72,39 +81,19 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
|
||||
const accounts = data.channelAccounts?.[channelId] || [];
|
||||
const defaultAccountId = data.channelDefaultAccountId?.[channelId];
|
||||
const summarySignal = summary as { error?: string; lastError?: string } | undefined;
|
||||
const primaryAccount =
|
||||
(defaultAccountId ? accounts.find((a) => a.accountId === defaultAccountId) : undefined) ||
|
||||
accounts.find((a) => a.connected === true || a.linked === true) ||
|
||||
accounts.find((a) => isChannelRuntimeConnected(a as ChannelRuntimeAccountSnapshot)) ||
|
||||
accounts[0];
|
||||
|
||||
// Map gateway status to our status format
|
||||
let status: Channel['status'] = 'disconnected';
|
||||
const now = Date.now();
|
||||
const RECENT_MS = 10 * 60 * 1000;
|
||||
const hasRecentActivity = (a: { lastInboundAt?: number | null; lastOutboundAt?: number | null; lastConnectedAt?: number | null }) =>
|
||||
(typeof a.lastInboundAt === 'number' && now - a.lastInboundAt < RECENT_MS) ||
|
||||
(typeof a.lastOutboundAt === 'number' && now - a.lastOutboundAt < RECENT_MS) ||
|
||||
(typeof a.lastConnectedAt === 'number' && now - a.lastConnectedAt < RECENT_MS);
|
||||
const anyConnected = accounts.some((a) => a.connected === true || a.linked === true || hasRecentActivity(a));
|
||||
const anyRunning = accounts.some((a) => a.running === true);
|
||||
const status: Channel['status'] = pickChannelRuntimeStatus(accounts, summarySignal);
|
||||
const summaryError =
|
||||
typeof (summary as { error?: string })?.error === 'string'
|
||||
? (summary as { error?: string }).error
|
||||
: typeof (summary as { lastError?: string })?.lastError === 'string'
|
||||
? (summary as { lastError?: string }).lastError
|
||||
typeof summarySignal?.error === 'string'
|
||||
? summarySignal.error
|
||||
: typeof summarySignal?.lastError === 'string'
|
||||
? summarySignal.lastError
|
||||
: undefined;
|
||||
const anyError =
|
||||
accounts.some((a) => typeof a.lastError === 'string' && a.lastError) || Boolean(summaryError);
|
||||
|
||||
if (anyConnected) {
|
||||
status = 'connected';
|
||||
} else if (anyRunning && !anyError) {
|
||||
status = 'connected';
|
||||
} else if (anyError) {
|
||||
status = 'error';
|
||||
} else if (anyRunning) {
|
||||
status = 'connecting';
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: `${channelId}-${primaryAccount?.accountId || 'default'}`,
|
||||
|
||||
Reference in New Issue
Block a user