Stabilize channels UX, reload flow, and i18n consistency
This commit is contained in:
@@ -220,6 +220,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}> = new Map();
|
}> = new Map();
|
||||||
private deviceIdentity: DeviceIdentity | null = null;
|
private deviceIdentity: DeviceIdentity | null = null;
|
||||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
private lifecycleEpoch = 0;
|
private lifecycleEpoch = 0;
|
||||||
private deferredRestartPending = false;
|
private deferredRestartPending = false;
|
||||||
private restartInFlight: Promise<void> | null = null;
|
private restartInFlight: Promise<void> | null = null;
|
||||||
@@ -640,6 +641,71 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Gateway process to reload config in-place when possible.
|
||||||
|
* Falls back to restart on unsupported platforms or signaling failures.
|
||||||
|
*/
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
if (this.isRestartDeferred()) {
|
||||||
|
this.markDeferredRestart('reload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.process?.pid || this.status.state !== 'running') {
|
||||||
|
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||||
|
await this.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
logger.debug('Windows detected, falling back to Gateway restart for reload');
|
||||||
|
await this.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedForMs = this.status.connectedAt
|
||||||
|
? Date.now() - this.status.connectedAt
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
// Avoid signaling a process that just came up; it will already read latest config.
|
||||||
|
if (connectedForMs < 8000) {
|
||||||
|
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(this.process.pid, 'SIGUSR1');
|
||||||
|
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||||
|
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||||
|
// If process state doesn't recover quickly, fall back to restart.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||||
|
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||||
|
await this.restart();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||||
|
await this.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||||
|
* in-process reload when possible.
|
||||||
|
*/
|
||||||
|
debouncedReload(delayMs = 1200): void {
|
||||||
|
if (this.reloadDebounceTimer) {
|
||||||
|
clearTimeout(this.reloadDebounceTimer);
|
||||||
|
}
|
||||||
|
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
|
||||||
|
this.reloadDebounceTimer = setTimeout(() => {
|
||||||
|
this.reloadDebounceTimer = null;
|
||||||
|
void this.reload().catch((err) => {
|
||||||
|
logger.warn('Debounced Gateway reload failed:', err);
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all active timers
|
* Clear all active timers
|
||||||
*/
|
*/
|
||||||
@@ -660,6 +726,10 @@ export class GatewayManager extends EventEmitter {
|
|||||||
clearTimeout(this.restartDebounceTimer);
|
clearTimeout(this.restartDebounceTimer);
|
||||||
this.restartDebounceTimer = null;
|
this.restartDebounceTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.reloadDebounceTimer) {
|
||||||
|
clearTimeout(this.reloadDebounceTimer);
|
||||||
|
this.reloadDebounceTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function registerIpcHandlers(
|
|||||||
registerClawHubHandlers(clawHubService);
|
registerClawHubHandlers(clawHubService);
|
||||||
|
|
||||||
// OpenClaw handlers
|
// OpenClaw handlers
|
||||||
registerOpenClawHandlers();
|
registerOpenClawHandlers(gatewayManager);
|
||||||
|
|
||||||
// Provider handlers
|
// Provider handlers
|
||||||
registerProviderHandlers(gatewayManager);
|
registerProviderHandlers(gatewayManager);
|
||||||
@@ -1486,7 +1486,7 @@ function registerGatewayHandlers(
|
|||||||
* OpenClaw-related IPC handlers
|
* OpenClaw-related IPC handlers
|
||||||
* For checking package status and channel configuration
|
* For checking package status and channel configuration
|
||||||
*/
|
*/
|
||||||
function registerOpenClawHandlers(): void {
|
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||||
@@ -1599,9 +1599,12 @@ function registerOpenClawHandlers(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
await saveChannelConfig(channelType, config);
|
await saveChannelConfig(channelType, config);
|
||||||
logger.info(
|
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
|
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
|
||||||
);
|
gatewayManager.debouncedReload();
|
||||||
|
} else {
|
||||||
|
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pluginInstalled: installResult.installed,
|
pluginInstalled: installResult.installed,
|
||||||
@@ -1609,12 +1612,12 @@ function registerOpenClawHandlers(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
await saveChannelConfig(channelType, config);
|
await saveChannelConfig(channelType, config);
|
||||||
// Do not force stop/start here. Recent Gateway builds detect channel config
|
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||||
// changes and perform an internal service restart; forcing another restart
|
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
|
||||||
// from Electron can race with reconnect and kill the newly spawned process.
|
gatewayManager.debouncedReload();
|
||||||
logger.info(
|
} else {
|
||||||
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
|
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
|
||||||
);
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save channel config:', error);
|
console.error('Failed to save channel config:', error);
|
||||||
@@ -1648,6 +1651,12 @@ function registerOpenClawHandlers(): void {
|
|||||||
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteChannelConfig(channelType);
|
await deleteChannelConfig(channelType);
|
||||||
|
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||||
|
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
|
||||||
|
gatewayManager.debouncedReload();
|
||||||
|
} else {
|
||||||
|
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete channel config:', error);
|
console.error('Failed to delete channel config:', error);
|
||||||
@@ -1670,6 +1679,12 @@ function registerOpenClawHandlers(): void {
|
|||||||
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await setChannelEnabled(channelType, enabled);
|
await setChannelEnabled(channelType, enabled);
|
||||||
|
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||||
|
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
|
||||||
|
gatewayManager.debouncedReload();
|
||||||
|
} else {
|
||||||
|
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set channel enabled:', error);
|
console.error('Failed to set channel enabled:', error);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface PluginsConfig {
|
|||||||
export interface OpenClawConfig {
|
export interface OpenClawConfig {
|
||||||
channels?: Record<string, ChannelConfigData>;
|
channels?: Record<string, ChannelConfigData>;
|
||||||
plugins?: PluginsConfig;
|
plugins?: PluginsConfig;
|
||||||
|
commands?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
|
|||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Enable graceful in-process reload authorization for SIGUSR1 flows.
|
||||||
|
const commands =
|
||||||
|
config.commands && typeof config.commands === 'object'
|
||||||
|
? { ...(config.commands as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
commands.restart = true;
|
||||||
|
config.commands = commands;
|
||||||
|
|
||||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to write OpenClaw config', error);
|
logger.error('Failed to write OpenClaw config', error);
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||||
|
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||||
|
const commands = (
|
||||||
|
config.commands && typeof config.commands === 'object'
|
||||||
|
? { ...(config.commands as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
commands.restart = true;
|
||||||
|
config.commands = commands;
|
||||||
|
|
||||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +790,20 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── commands section ───────────────────────────────────────────
|
||||||
|
// Required for SIGUSR1 in-process reload authorization.
|
||||||
|
const commands = (
|
||||||
|
config.commands && typeof config.commands === 'object'
|
||||||
|
? { ...(config.commands as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
if (commands.restart !== true) {
|
||||||
|
commands.restart = true;
|
||||||
|
config.commands = commands;
|
||||||
|
modified = true;
|
||||||
|
console.log('[sanitize] Enabling commands.restart for graceful reload support');
|
||||||
|
}
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
await writeOpenClawJson(config);
|
await writeOpenClawJson(config);
|
||||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||||
|
|||||||
45
refactor.md
45
refactor.md
@@ -66,3 +66,48 @@ This branch captures local refactors focused on frontend UX polish, IPC call con
|
|||||||
## Notes
|
## Notes
|
||||||
- Navigation order in sidebar is kept aligned with `main` ordering.
|
- Navigation order in sidebar is kept aligned with `main` ordering.
|
||||||
- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work.
|
- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work.
|
||||||
|
|
||||||
|
## Incremental Updates (2026-03-08)
|
||||||
|
|
||||||
|
### 9. Channel i18n fixes
|
||||||
|
- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback:
|
||||||
|
- `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm`
|
||||||
|
- Fixed confirm dialog namespace usage on Channels page:
|
||||||
|
- `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel`
|
||||||
|
|
||||||
|
### 10. Channel save/delete behavior aligned to reload-first strategy
|
||||||
|
- Added Gateway reload capability in `GatewayManager`:
|
||||||
|
- `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms)
|
||||||
|
- `debouncedReload()` for coalesced config-change reloads
|
||||||
|
- Wired channel config operations to reload pipeline:
|
||||||
|
- `channel:saveConfig`
|
||||||
|
- `channel:deleteConfig`
|
||||||
|
- `channel:setEnabled`
|
||||||
|
- Removed redundant renderer-side forced restart call after WhatsApp configuration.
|
||||||
|
|
||||||
|
### 11. OpenClaw config compatibility for graceful reload
|
||||||
|
- Ensured `commands.restart = true` is persisted in OpenClaw config write paths:
|
||||||
|
- `electron/utils/channel-config.ts`
|
||||||
|
- `electron/utils/openclaw-auth.ts`
|
||||||
|
- Added sanitize fallback that auto-enables `commands.restart` before Gateway start.
|
||||||
|
|
||||||
|
### 12. Channels page data consistency fixes
|
||||||
|
- Unified configured state derivation so the following sections share one source:
|
||||||
|
- stats cards
|
||||||
|
- configured channels list
|
||||||
|
- available channel configured badge
|
||||||
|
- Fixed post-delete refresh by explicitly refetching both:
|
||||||
|
- configured channel types
|
||||||
|
- channel status list
|
||||||
|
|
||||||
|
### 13. Channels UX resilience during Gateway restart/reconnect
|
||||||
|
- Added delayed gateway warning display to reduce transient false alarms.
|
||||||
|
- Added "running snapshot" rendering strategy:
|
||||||
|
- keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty
|
||||||
|
- avoids UI flashing to zero counts / empty configured state
|
||||||
|
- Added automatic refresh once Gateway transitions back to `running`.
|
||||||
|
|
||||||
|
### 14. Configure-but-disable support
|
||||||
|
- Added enable toggle in channel setup dialog (`Enable Channel`).
|
||||||
|
- Save flow now persists `enabled` with configuration payload.
|
||||||
|
- Existing config load now reads `enabled` state and pre-fills toggle accordingly.
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"gatewayWarning": "Gateway service is not running. Channels cannot connect.",
|
"gatewayWarning": "Gateway service is not running. Channels cannot connect.",
|
||||||
"available": "Available Channels",
|
"available": "Available Channels",
|
||||||
"availableDesc": "Connect a new channel",
|
"availableDesc": "Connect a new channel",
|
||||||
|
"configured": "Configured Channels",
|
||||||
|
"configuredDesc": "Manage channels that are already configured",
|
||||||
|
"configuredBadge": "Configured",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this channel?",
|
||||||
"showAll": "Show All",
|
"showAll": "Show All",
|
||||||
"pluginBadge": "Plugin",
|
"pluginBadge": "Plugin",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -37,6 +41,8 @@
|
|||||||
"viewDocs": "View Documentation",
|
"viewDocs": "View Documentation",
|
||||||
"channelName": "Channel Name",
|
"channelName": "Channel Name",
|
||||||
"channelNamePlaceholder": "My {{name}}",
|
"channelNamePlaceholder": "My {{name}}",
|
||||||
|
"enableChannel": "Enable Channel",
|
||||||
|
"enableChannelDesc": "When off, config is saved but the channel stays disabled",
|
||||||
"credentialsVerified": "Credentials Verified",
|
"credentialsVerified": "Credentials Verified",
|
||||||
"validationFailed": "Validation Failed",
|
"validationFailed": "Validation Failed",
|
||||||
"warnings": "Warnings",
|
"warnings": "Warnings",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
|
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
|
||||||
"available": "利用可能なチャンネル",
|
"available": "利用可能なチャンネル",
|
||||||
"availableDesc": "新しいチャンネルを接続",
|
"availableDesc": "新しいチャンネルを接続",
|
||||||
|
"configured": "設定済みチャンネル",
|
||||||
|
"configuredDesc": "すでに設定済みのチャンネルを管理",
|
||||||
|
"configuredBadge": "設定済み",
|
||||||
|
"deleteConfirm": "このチャンネルを削除してもよろしいですか?",
|
||||||
"showAll": "すべて表示",
|
"showAll": "すべて表示",
|
||||||
"pluginBadge": "プラグイン",
|
"pluginBadge": "プラグイン",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -37,6 +41,8 @@
|
|||||||
"viewDocs": "ドキュメントを表示",
|
"viewDocs": "ドキュメントを表示",
|
||||||
"channelName": "チャンネル名",
|
"channelName": "チャンネル名",
|
||||||
"channelNamePlaceholder": "マイ {{name}}",
|
"channelNamePlaceholder": "マイ {{name}}",
|
||||||
|
"enableChannel": "チャンネルを有効化",
|
||||||
|
"enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません",
|
||||||
"credentialsVerified": "認証情報が確認されました",
|
"credentialsVerified": "認証情報が確認されました",
|
||||||
"validationFailed": "検証に失敗しました",
|
"validationFailed": "検証に失敗しました",
|
||||||
"warnings": "警告",
|
"warnings": "警告",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"gatewayWarning": "网关服务未运行,频道无法连接。",
|
"gatewayWarning": "网关服务未运行,频道无法连接。",
|
||||||
"available": "可用频道",
|
"available": "可用频道",
|
||||||
"availableDesc": "连接一个新的频道",
|
"availableDesc": "连接一个新的频道",
|
||||||
|
"configured": "已配置频道",
|
||||||
|
"configuredDesc": "管理已完成配置的频道",
|
||||||
|
"configuredBadge": "已配置",
|
||||||
|
"deleteConfirm": "确定要删除此频道吗?",
|
||||||
"showAll": "显示全部",
|
"showAll": "显示全部",
|
||||||
"pluginBadge": "插件",
|
"pluginBadge": "插件",
|
||||||
"toast": {
|
"toast": {
|
||||||
@@ -37,6 +41,8 @@
|
|||||||
"viewDocs": "查看文档",
|
"viewDocs": "查看文档",
|
||||||
"channelName": "频道名称",
|
"channelName": "频道名称",
|
||||||
"channelNamePlaceholder": "我的 {{name}}",
|
"channelNamePlaceholder": "我的 {{name}}",
|
||||||
|
"enableChannel": "启用频道",
|
||||||
|
"enableChannelDesc": "关闭后会保存配置,但不会启动该频道",
|
||||||
"credentialsVerified": "凭证已验证",
|
"credentialsVerified": "凭证已验证",
|
||||||
"validationFailed": "验证失败",
|
"validationFailed": "验证失败",
|
||||||
"warnings": "警告",
|
"warnings": "警告",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Channels Page
|
* Channels Page
|
||||||
* Manage messaging channel connections with configuration UI
|
* Manage messaging channel connections with configuration UI
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Radio,
|
Radio,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -55,9 +56,13 @@ export function Channels() {
|
|||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
||||||
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
|
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
|
||||||
|
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
|
||||||
|
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
|
||||||
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
|
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
|
||||||
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||||
|
|
||||||
// Fetch channels on mount
|
// Fetch channels on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,11 +109,60 @@ export function Channels() {
|
|||||||
};
|
};
|
||||||
}, [fetchChannels, fetchConfiguredTypes]);
|
}, [fetchChannels, fetchConfiguredTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gatewayStatus.state === 'running') {
|
||||||
|
setChannelSnapshot(channels);
|
||||||
|
setConfiguredTypesSnapshot(configuredTypes);
|
||||||
|
}
|
||||||
|
}, [gatewayStatus.state, channels, configuredTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousState = lastGatewayStateRef.current;
|
||||||
|
const currentState = gatewayStatus.state;
|
||||||
|
const justReconnected =
|
||||||
|
currentState === 'running' &&
|
||||||
|
previousState !== 'running';
|
||||||
|
lastGatewayStateRef.current = currentState;
|
||||||
|
|
||||||
|
if (!justReconnected) return;
|
||||||
|
void fetchChannels({ probe: false, silent: true });
|
||||||
|
void fetchConfiguredTypes();
|
||||||
|
}, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]);
|
||||||
|
|
||||||
|
// Delay warning to avoid flicker during expected short reload/restart windows.
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowGatewayWarning(shouldWarn);
|
||||||
|
}, shouldWarn ? 1800 : 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [gatewayStatus.state]);
|
||||||
|
|
||||||
// Get channel types to display
|
// Get channel types to display
|
||||||
const displayedChannelTypes = getPrimaryChannels();
|
const displayedChannelTypes = getPrimaryChannels();
|
||||||
|
const isGatewayTransitioning =
|
||||||
|
gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting';
|
||||||
|
const channelsForView =
|
||||||
|
isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels;
|
||||||
|
const configuredTypesForView =
|
||||||
|
isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes;
|
||||||
|
|
||||||
|
// Single source of truth for configured status across cards, stats and badges.
|
||||||
|
const configuredTypeSet = useMemo(() => {
|
||||||
|
const set = new Set<string>(configuredTypesForView);
|
||||||
|
if (set.size === 0 && channelsForView.length > 0) {
|
||||||
|
channelsForView.forEach((channel) => set.add(channel.type));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [configuredTypesForView, channelsForView]);
|
||||||
|
|
||||||
|
const configuredChannels = useMemo(
|
||||||
|
() => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)),
|
||||||
|
[channelsForView, configuredTypeSet]
|
||||||
|
);
|
||||||
|
|
||||||
// Connected/disconnected channel counts
|
// Connected/disconnected channel counts
|
||||||
const connectedCount = channels.filter((c) => c.status === 'connected').length;
|
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
|
||||||
|
|
||||||
if (loading && channels.length === 0) {
|
if (loading && channels.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +215,7 @@ export function Channels() {
|
|||||||
<Radio className="h-6 w-6 text-primary" />
|
<Radio className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold">{channels.length}</p>
|
<p className="text-2xl font-bold">{configuredChannels.length}</p>
|
||||||
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +241,7 @@ export function Channels() {
|
|||||||
<PowerOff className="h-6 w-6 text-slate-600" />
|
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
|
||||||
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +250,7 @@ export function Channels() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gateway Warning */}
|
{/* Gateway Warning */}
|
||||||
{gatewayStatus.state !== 'running' && (
|
{showGatewayWarning && (
|
||||||
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
||||||
<CardContent className="py-4 flex items-center gap-3">
|
<CardContent className="py-4 flex items-center gap-3">
|
||||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||||
@@ -217,7 +271,7 @@ export function Channels() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Configured Channels */}
|
{/* Configured Channels */}
|
||||||
{channels.length > 0 && (
|
{configuredChannels.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('configured')}</CardTitle>
|
<CardTitle>{t('configured')}</CardTitle>
|
||||||
@@ -225,7 +279,7 @@ export function Channels() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((channel) => (
|
{configuredChannels.map((channel) => (
|
||||||
<ChannelCard
|
<ChannelCard
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
@@ -253,7 +307,7 @@ export function Channels() {
|
|||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
{displayedChannelTypes.map((type) => {
|
{displayedChannelTypes.map((type) => {
|
||||||
const meta = CHANNEL_META[type];
|
const meta = CHANNEL_META[type];
|
||||||
const isConfigured = configuredTypes.includes(type);
|
const isConfigured = configuredTypeSet.has(type);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
@@ -297,6 +351,10 @@ export function Channels() {
|
|||||||
onChannelAdded={() => {
|
onChannelAdded={() => {
|
||||||
void fetchChannels({ probe: false, silent: true });
|
void fetchChannels({ probe: false, silent: true });
|
||||||
void fetchConfiguredTypes();
|
void fetchConfiguredTypes();
|
||||||
|
setTimeout(() => {
|
||||||
|
void fetchChannels({ probe: false, silent: true });
|
||||||
|
void fetchConfiguredTypes();
|
||||||
|
}, 2200);
|
||||||
setShowAddDialog(false);
|
setShowAddDialog(false);
|
||||||
setSelectedChannelType(null);
|
setSelectedChannelType(null);
|
||||||
}}
|
}}
|
||||||
@@ -305,14 +363,16 @@ export function Channels() {
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!channelToDelete}
|
open={!!channelToDelete}
|
||||||
title={t('common.confirm', 'Confirm')}
|
title={t('common:actions.confirm', 'Confirm')}
|
||||||
message={t('deleteConfirm')}
|
message={t('deleteConfirm')}
|
||||||
confirmLabel={t('common.delete', 'Delete')}
|
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||||
cancelLabel={t('common.cancel', 'Cancel')}
|
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (channelToDelete) {
|
if (channelToDelete) {
|
||||||
await deleteChannel(channelToDelete.id);
|
await deleteChannel(channelToDelete.id);
|
||||||
|
await fetchConfiguredTypes();
|
||||||
|
await fetchChannels({ probe: false, silent: true });
|
||||||
setChannelToDelete(null);
|
setChannelToDelete(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -378,9 +438,9 @@ interface AddChannelDialogProps {
|
|||||||
|
|
||||||
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
|
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
|
||||||
const { t } = useTranslation('channels');
|
const { t } = useTranslation('channels');
|
||||||
const { addChannel } = useChannelsStore();
|
|
||||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||||
const [channelName, setChannelName] = useState('');
|
const [channelName, setChannelName] = useState('');
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||||
@@ -402,6 +462,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
setConfigValues({});
|
setConfigValues({});
|
||||||
setChannelName('');
|
setChannelName('');
|
||||||
setIsExistingConfig(false);
|
setIsExistingConfig(false);
|
||||||
|
setEnabled(true);
|
||||||
setChannelName('');
|
setChannelName('');
|
||||||
setIsExistingConfig(false);
|
setIsExistingConfig(false);
|
||||||
// Ensure we clean up any pending QR session if switching away
|
// Ensure we clean up any pending QR session if switching away
|
||||||
@@ -414,24 +475,45 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await invokeIpc(
|
const [result, configResult] = await Promise.all([
|
||||||
|
invokeIpc(
|
||||||
'channel:getFormValues',
|
'channel:getFormValues',
|
||||||
selectedType
|
selectedType
|
||||||
) as { success: boolean; values?: Record<string, string> };
|
) as Promise<{ success: boolean; values?: Record<string, string> }>,
|
||||||
|
invokeIpc(
|
||||||
|
'channel:getConfig',
|
||||||
|
selectedType
|
||||||
|
) as Promise<{ success: boolean; config?: Record<string, unknown> }>,
|
||||||
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (result.success && result.values && Object.keys(result.values).length > 0) {
|
if (result.success && result.values && Object.keys(result.values).length > 0) {
|
||||||
setConfigValues(result.values);
|
setConfigValues(result.values);
|
||||||
setIsExistingConfig(true);
|
|
||||||
} else {
|
} else {
|
||||||
setConfigValues({});
|
setConfigValues({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingConfig = configResult.success ? configResult.config : undefined;
|
||||||
|
if (existingConfig && typeof existingConfig.enabled === 'boolean') {
|
||||||
|
setEnabled(existingConfig.enabled);
|
||||||
|
} else {
|
||||||
|
setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(result.success && result.values && Object.keys(result.values).length > 0) ||
|
||||||
|
Boolean(existingConfig)
|
||||||
|
) {
|
||||||
|
setIsExistingConfig(true);
|
||||||
|
} else {
|
||||||
setIsExistingConfig(false);
|
setIsExistingConfig(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setConfigValues({});
|
setConfigValues({});
|
||||||
setIsExistingConfig(false);
|
setIsExistingConfig(false);
|
||||||
|
setEnabled(true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingConfig(false);
|
if (!cancelled) setLoadingConfig(false);
|
||||||
@@ -465,7 +547,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
const saveResult = await invokeIpc(
|
const saveResult = await invokeIpc(
|
||||||
'channel:saveConfig',
|
'channel:saveConfig',
|
||||||
'whatsapp',
|
'whatsapp',
|
||||||
{ enabled: true }
|
{ enabled }
|
||||||
) as { success?: boolean; error?: string };
|
) as { success?: boolean; error?: string };
|
||||||
if (!saveResult?.success) {
|
if (!saveResult?.success) {
|
||||||
console.error('Failed to save WhatsApp config:', saveResult?.error);
|
console.error('Failed to save WhatsApp config:', saveResult?.error);
|
||||||
@@ -475,15 +557,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save WhatsApp config:', error);
|
console.error('Failed to save WhatsApp config:', error);
|
||||||
}
|
}
|
||||||
// Register the channel locally so it shows up immediately
|
// channel:saveConfig triggers main-process reload/restart handling.
|
||||||
addChannel({
|
// UI state refresh is handled by parent onChannelAdded().
|
||||||
type: 'whatsapp',
|
|
||||||
name: channelName || 'WhatsApp',
|
|
||||||
}).then(() => {
|
|
||||||
// Restart gateway to pick up the new session
|
|
||||||
invokeIpc('gateway:restart').catch(console.error);
|
|
||||||
onChannelAdded();
|
onChannelAdded();
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (...args: unknown[]) => {
|
const onError = (...args: unknown[]) => {
|
||||||
@@ -505,7 +581,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
// Cancel when unmounting or switching types
|
// Cancel when unmounting or switching types
|
||||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||||
};
|
};
|
||||||
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
|
}, [selectedType, channelName, enabled, onChannelAdded, t]);
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (!selectedType) return;
|
if (!selectedType) return;
|
||||||
@@ -614,7 +690,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Save channel configuration via IPC
|
// Step 2: Save channel configuration via IPC
|
||||||
const config: Record<string, unknown> = { ...configValues };
|
const config: Record<string, unknown> = { ...configValues, enabled };
|
||||||
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -628,20 +704,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
toast.warning(saveResult.warning);
|
toast.warning(saveResult.warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Add a local channel entry for the UI
|
// Step 3: Do not call channels.add from renderer; this races with
|
||||||
await addChannel({
|
// gateway reload/restart windows and can create stale local entries.
|
||||||
type: selectedType,
|
|
||||||
name: channelName || CHANNEL_NAMES[selectedType],
|
|
||||||
token: configValues[meta.configFields[0]?.key] || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||||
|
|
||||||
// Gateway restart is now handled server-side via debouncedRestart()
|
// Gateway reload/restart is handled in the main-process save handler.
|
||||||
// inside the channel:saveConfig IPC handler, so we don't need to
|
// Renderer should only persist config and refresh local UI state.
|
||||||
// trigger it explicitly here. This avoids cascading restarts when
|
|
||||||
// multiple config changes happen in quick succession (e.g. during
|
|
||||||
// the setup wizard).
|
|
||||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||||
|
|
||||||
// Brief delay so user can see the success state before dialog closes
|
// Brief delay so user can see the success state before dialog closes
|
||||||
@@ -806,6 +875,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{t('dialog.enableChannel')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('dialog.enableChannelDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Configuration fields */}
|
{/* Configuration fields */}
|
||||||
{meta?.configFields.map((field) => (
|
{meta?.configFields.map((field) => (
|
||||||
<ConfigField
|
<ConfigField
|
||||||
|
|||||||
Reference in New Issue
Block a user