From d8750e135bace6d83768bd93ad204e49166b7c55 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:04:57 +0800 Subject: [PATCH] Feat/perf dashboard (#770) --- electron/api/routes/channels.ts | 21 ++++++--- electron/api/routes/cron.ts | 74 ++++++++++++++++++++++--------- electron/utils/agent-config.ts | 9 +++- electron/utils/channel-config.ts | 16 +++++-- src/pages/Channels/index.tsx | 35 ++++++++++++++- src/stores/channels.ts | 2 +- src/stores/cron.ts | 9 +++- src/stores/skills.ts | 3 +- tests/unit/channel-routes.test.ts | 5 ++- 9 files changed, 135 insertions(+), 39 deletions(-) diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 1be1b4fc2..946af98a0 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -6,8 +6,9 @@ import { deleteChannelConfig, cleanupDanglingWeChatPluginState, getChannelFormValues, - listConfiguredChannelAccounts, + listConfiguredChannelAccountsFromConfig, listConfiguredChannels, + listConfiguredChannelsFromConfig, readOpenClawConfig, saveChannelConfig, setChannelDefaultAccount, @@ -20,6 +21,7 @@ import { clearAllBindingsForChannel, clearChannelBinding, listAgentsSnapshot, + listAgentsSnapshotFromConfig, } from '../../utils/agent-config'; import { ensureDingTalkPluginInstalled, @@ -344,16 +346,21 @@ const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true'; const channelTargetCache = new Map(); async function buildChannelAccountsView(ctx: HostApiContext): Promise { - const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([ - listConfiguredChannels(), - listConfiguredChannelAccounts(), - readOpenClawConfig(), - listAgentsSnapshot(), + // Read config once and share across all sub-calls (was 5 readFile calls before). + const openClawConfig = await readOpenClawConfig(); + + const [configuredChannels, configuredAccounts, agentsSnapshot] = await Promise.all([ + listConfiguredChannelsFromConfig(openClawConfig), + Promise.resolve(listConfiguredChannelAccountsFromConfig(openClawConfig)), + listAgentsSnapshotFromConfig(openClawConfig), ]); let gatewayStatus: GatewayChannelStatusPayload | null; try { - gatewayStatus = await ctx.gatewayManager.rpc('channels.status', { probe: true }); + // probe: false — use cached runtime state instead of active network probes + // per channel. Real-time status updates arrive via channel.status events. + // 8s timeout — fail fast when Gateway is busy with AI tasks. + gatewayStatus = await ctx.gatewayManager.rpc('channels.status', { probe: false }, 8000); } catch { gatewayStatus = null; } diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts index 249dc079f..8f485c8b8 100644 --- a/electron/api/routes/cron.ts +++ b/electron/api/routes/cron.ts @@ -414,7 +414,7 @@ export async function handleCronRoutes( try { const [jobsResult, runs, sessionEntry] = await Promise.all([ - ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }) + ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000) .catch(() => ({ jobs: [] as GatewayCronJob[] })), readCronRunLog(parsedSession.jobId), readSessionStoreEntry(parsedSession.agentId, sessionKey), @@ -442,34 +442,66 @@ export async function handleCronRoutes( if (url.pathname === '/api/cron/jobs' && req.method === 'GET') { try { - const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }); - const data = result as { jobs?: GatewayCronJob[] }; - const jobs = data?.jobs ?? []; - for (const job of jobs) { - const isIsolatedAgent = - (job.sessionTarget === 'isolated' || !job.sessionTarget) && - job.payload?.kind === 'agentTurn'; - const needsRepair = - isIsolatedAgent && - job.delivery?.mode === 'announce' && - !job.delivery?.channel; - if (needsRepair) { - try { - await ctx.gatewayManager.rpc('cron.update', { - id: job.id, - patch: { delivery: { mode: 'none' } }, - }); + let jobs: GatewayCronJob[] = []; + let usedFallback = false; + + try { + // 8s timeout — fail fast when Gateway is busy with AI tasks. + const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000); + const data = result as { jobs?: GatewayCronJob[] }; + jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []); + } catch { + // Fallback: read cron.json directly when Gateway RPC fails/times out. + try { + const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json'); + const raw = await readFile(cronJsonPath, 'utf-8'); + const parsed = JSON.parse(raw); + const fileJobs = Array.isArray(parsed) ? parsed : (parsed?.jobs ?? []); + jobs = fileJobs as GatewayCronJob[]; + usedFallback = true; + } catch { + // No fallback data available either + } + } + + // Run repair in background — don't block the response. + if (!usedFallback && jobs.length > 0) { + const jobsToRepair = jobs.filter((job) => { + const isIsolatedAgent = + (job.sessionTarget === 'isolated' || !job.sessionTarget) && + job.payload?.kind === 'agentTurn'; + return ( + isIsolatedAgent && + job.delivery?.mode === 'announce' && + !job.delivery?.channel + ); + }); + if (jobsToRepair.length > 0) { + // Fire-and-forget: repair in background + void (async () => { + for (const job of jobsToRepair) { + try { + await ctx.gatewayManager.rpc('cron.update', { + id: job.id, + patch: { delivery: { mode: 'none' } }, + }); + } catch { + // ignore per-job repair failure + } + } + })(); + // Optimistically fix the response data + for (const job of jobsToRepair) { job.delivery = { mode: 'none' }; if (job.state?.lastError?.includes('Channel is required')) { job.state.lastError = undefined; job.state.lastStatus = 'ok'; } - } catch { - // ignore per-job repair failure } } } - sendJson(res, 200, jobs.map(transformCronJob)); + + sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }))); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 386735e18..a72a19f65 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -2,6 +2,7 @@ import { access, copyFile, mkdir, readdir, rm } from 'fs/promises'; import { constants } from 'fs'; import { join, normalize } from 'path'; import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import type { OpenClawConfig } from './channel-config'; import { withConfigLock } from './config-mutex'; import { expandPath, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; @@ -450,9 +451,9 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel }); } -async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise { +async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise { const { entries, defaultAgentId } = normalizeAgentsConfig(config); - const configuredChannels = await listConfiguredChannels(); + const configuredChannels = preloadedChannels ?? await listConfiguredChannels(); const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings); const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId); const channelOwners: Record = {}; @@ -539,6 +540,10 @@ export async function listAgentsSnapshot(): Promise { return buildSnapshotFromConfig(config); } +export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise { + return buildSnapshotFromConfig(config as AgentConfigDocument, configuredChannels); +} + export async function listConfiguredAgentIds(): Promise { const config = await readOpenClawConfig() as AgentConfigDocument; const { entries } = normalizeAgentsConfig(config); diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index ee4b16d2a..3ab3d7193 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -966,8 +966,7 @@ function channelHasAnyAccount(channelSection: ChannelConfigData): boolean { return false; } -export async function listConfiguredChannels(): Promise { - const config = await readOpenClawConfig(); +export async function listConfiguredChannelsFromConfig(config: OpenClawConfig): Promise { const channels: string[] = []; if (config.channels) { @@ -1005,13 +1004,17 @@ export async function listConfiguredChannels(): Promise { return channels; } +export async function listConfiguredChannels(): Promise { + const config = await readOpenClawConfig(); + return listConfiguredChannelsFromConfig(config); +} + export interface ConfiguredChannelAccounts { defaultAccountId: string; accountIds: string[]; } -export async function listConfiguredChannelAccounts(): Promise> { - const config = await readOpenClawConfig(); +export function listConfiguredChannelAccountsFromConfig(config: OpenClawConfig): Record { const result: Record = {}; if (!config.channels) { @@ -1059,6 +1062,11 @@ export async function listConfiguredChannelAccounts(): Promise> { + const config = await readOpenClawConfig(); + return listConfiguredChannelAccountsFromConfig(config); +} + export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise { return withConfigLock(async () => { const resolvedChannelType = resolveStoredChannelType(channelType); diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index ae34c4183..31cf71a71 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -96,8 +96,20 @@ export function Channels() { const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0; const isUsingStableValue = hasStableValue && (loading || Boolean(error)); + // Use refs to read current state inside fetchPageData without making it + // a dependency — keeps the callback reference stable across renders so + // downstream useEffects don't re-execute every time data changes. + const channelGroupsRef = useRef(channelGroups); + channelGroupsRef.current = channelGroups; + const agentsRef = useRef(agents); + agentsRef.current = agents; + const fetchPageData = useCallback(async () => { - setLoading(true); + // Only show loading spinner on first load (stale-while-revalidate). + const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0; + if (!hasData) { + setLoading(true); + } setError(null); try { const [channelsRes, agentsRes] = await Promise.all([ @@ -116,10 +128,13 @@ export function Channels() { setChannelGroups(channelsRes.channels || []); setAgents(agentsRes.agents || []); } catch (fetchError) { + // Preserve previous data on error — don't clear channelGroups/agents. setError(String(fetchError)); } finally { setLoading(false); } + // Stable reference — reads state via refs, no deps needed. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -127,13 +142,31 @@ export function Channels() { }, [fetchPageData]); useEffect(() => { + // Throttle channel-status events to avoid flooding fetchPageData during AI tasks. + let throttleTimer: ReturnType | null = null; + let pending = false; + const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { + if (throttleTimer) { + pending = true; + return; + } void fetchPageData(); + throttleTimer = setTimeout(() => { + throttleTimer = null; + if (pending) { + pending = false; + void fetchPageData(); + } + }, 2000); }); return () => { if (typeof unsubscribe === 'function') { unsubscribe(); } + if (throttleTimer) { + clearTimeout(throttleTimer); + } }; }, [fetchPageData]); diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 5dbc21242..647dc7219 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -80,7 +80,7 @@ export const useChannelsStore = create((set, get) => ({ } | null; }>>; channelDefaultAccountId?: Record; - }>('channels.status', { probe: true }); + }>('channels.status', { probe: false }); if (data) { const channels: Channel[] = []; diff --git a/src/stores/cron.ts b/src/stores/cron.ts index 686506fd2..cbae168ea 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -27,12 +27,19 @@ export const useCronStore = create((set) => ({ error: null, fetchJobs: async () => { - set({ loading: true, error: null }); + const currentJobs = useCronStore.getState().jobs; + // Only show loading spinner when there's no data yet (stale-while-revalidate). + if (currentJobs.length === 0) { + set({ loading: true, error: null }); + } else { + set({ error: null }); + } try { const result = await hostApiFetch('/api/cron/jobs'); set({ jobs: result, loading: false }); } catch (error) { + // Preserve previous jobs on error so the user sees stale data instead of nothing. set({ error: String(error), loading: false }); } }, diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 5c74ab33b..15be3ab0f 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -171,7 +171,8 @@ export const useSkillsStore = create((set, get) => ({ } catch (error) { console.error('Failed to fetch skills:', error); const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); - set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') }); + // Preserve previous skills on error (stale-while-revalidate). + set((prev) => ({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch'), skills: prev.skills })); } }, diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts index 349b3c47c..ee1259fa7 100644 --- a/tests/unit/channel-routes.test.ts +++ b/tests/unit/channel-routes.test.ts @@ -18,7 +18,9 @@ vi.mock('@electron/utils/channel-config', () => ({ deleteChannelConfig: vi.fn(), getChannelFormValues: vi.fn(), listConfiguredChannelAccounts: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args), + listConfiguredChannelAccountsFromConfig: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args), listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args), + listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args), readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), saveChannelConfig: vi.fn(), setChannelDefaultAccount: vi.fn(), @@ -32,6 +34,7 @@ vi.mock('@electron/utils/agent-config', () => ({ clearAllBindingsForChannel: vi.fn(), clearChannelBinding: vi.fn(), listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), + listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args), })); vi.mock('@electron/utils/plugin-install', () => ({ @@ -171,7 +174,7 @@ describe('handleChannelRoutes', () => { ); expect(handled).toBe(true); - expect(rpc).toHaveBeenCalledWith('channels.status', { probe: true }); + expect(rpc).toHaveBeenCalledWith('channels.status', { probe: false }, 8000); expect(sendJsonMock).toHaveBeenCalledWith( expect.anything(), 200,