From 59fa5cbbb975c34f5320b0668a5c2ca0b0755144 Mon Sep 17 00:00:00 2001 From: ashione Date: Sun, 8 Mar 2026 00:53:19 +0800 Subject: [PATCH] Improve cron i18n coverage and reduce websocket stderr noise --- electron/gateway/manager.ts | 24 +++++++++- src/i18n/locales/en/cron.json | 14 +++++- src/i18n/locales/ja/cron.json | 14 +++++- src/i18n/locales/zh/cron.json | 14 +++++- src/pages/Cron/index.tsx | 84 ++++++++++++++++------------------- 5 files changed, 99 insertions(+), 51 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 438c3d1..7761779 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -224,6 +224,7 @@ export class GatewayManager extends EventEmitter { private lifecycleEpoch = 0; private deferredRestartPending = false; private restartInFlight: Promise | null = null; + private externalShutdownSupported: boolean | null = null; constructor(config?: Partial) { super(); @@ -252,6 +253,11 @@ export class GatewayManager extends EventEmitter { return sanitized; } + private isUnsupportedShutdownError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /unknown method:\s*shutdown/i.test(message); + } + private formatExit(code: number | null, signal: NodeJS.Signals | null): string { if (code !== null) return `code=${code}`; if (signal) return `signal=${signal}`; @@ -265,6 +271,14 @@ export class GatewayManager extends EventEmitter { // Known noisy lines that are not actionable for Gateway lifecycle debugging. if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg }; if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg }; + // During renderer refresh / transport switching, loopback websocket probes can time out + // while the gateway is reloading. This is expected and not actionable. + if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } + if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) { + return { level: 'debug', normalized: msg }; + } // Downgrade frequent non-fatal noise. if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; @@ -529,11 +543,17 @@ export class GatewayManager extends EventEmitter { // If this manager is attached to an external gateway process, ask it to shut down // over protocol before closing the socket. - if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) { + if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN && this.externalShutdownSupported !== false) { try { await this.rpc('shutdown', undefined, 5000); + this.externalShutdownSupported = true; } catch (error) { - logger.warn('Failed to request shutdown for externally managed Gateway:', error); + if (this.isUnsupportedShutdownError(error)) { + this.externalShutdownSupported = false; + logger.info('External Gateway does not support "shutdown"; skipping shutdown RPC for future stops'); + } else { + logger.warn('Failed to request shutdown for externally managed Gateway:', error); + } } } diff --git a/src/i18n/locales/en/cron.json b/src/i18n/locales/en/cron.json index 210135a..01c58f2 100644 --- a/src/i18n/locales/en/cron.json +++ b/src/i18n/locales/en/cron.json @@ -59,6 +59,7 @@ "paused": "Task paused", "deleted": "Task deleted", "triggered": "Task triggered successfully", + "failedTrigger": "Failed to trigger task: {{error}}", "failedUpdate": "Failed to update task", "failedDelete": "Failed to delete task", "nameRequired": "Please enter a task name", @@ -66,5 +67,16 @@ "channelRequired": "Please select a channel", "discordIdRequired": "Please enter a Discord Channel ID", "scheduleRequired": "Please select or enter a schedule" + }, + "schedule": { + "everySeconds": "Every {{count}}s", + "everyMinutes": "Every {{count}} minutes", + "everyHours": "Every {{count}} hours", + "everyDays": "Every {{count}} days", + "onceAt": "Once at {{time}}", + "weeklyAt": "Weekly on {{day}} at {{time}}", + "monthlyAtDay": "Monthly on day {{day}} at {{time}}", + "dailyAt": "Daily at {{time}}", + "unknown": "Unknown" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/cron.json b/src/i18n/locales/ja/cron.json index bc5cd8c..401d2ed 100644 --- a/src/i18n/locales/ja/cron.json +++ b/src/i18n/locales/ja/cron.json @@ -59,6 +59,7 @@ "paused": "タスクを停止しました", "deleted": "タスクを削除しました", "triggered": "タスクを正常にトリガーしました", + "failedTrigger": "タスクの実行に失敗しました: {{error}}", "failedUpdate": "タスクの更新に失敗しました", "failedDelete": "タスクの削除に失敗しました", "nameRequired": "タスク名を入力してください", @@ -66,5 +67,16 @@ "channelRequired": "チャンネルを選択してください", "discordIdRequired": "DiscordチャンネルIDを入力してください", "scheduleRequired": "スケジュールを選択または入力してください" + }, + "schedule": { + "everySeconds": "{{count}}秒ごと", + "everyMinutes": "{{count}}分ごと", + "everyHours": "{{count}}時間ごと", + "everyDays": "{{count}}日ごと", + "onceAt": "{{time}} に1回実行", + "weeklyAt": "毎週 {{day}} {{time}}", + "monthlyAtDay": "毎月 {{day}}日 {{time}}", + "dailyAt": "毎日 {{time}}", + "unknown": "不明" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index efbd5b7..571f7fe 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -59,6 +59,7 @@ "paused": "任务已暂停", "deleted": "任务已删除", "triggered": "任务已成功触发", + "failedTrigger": "触发任务失败: {{error}}", "failedUpdate": "更新任务失败", "failedDelete": "删除任务失败", "nameRequired": "请输入任务名称", @@ -66,5 +67,16 @@ "channelRequired": "请选择频道", "discordIdRequired": "请输入 Discord 频道 ID", "scheduleRequired": "请选择或输入调度计划" + }, + "schedule": { + "everySeconds": "每 {{count}} 秒", + "everyMinutes": "每 {{count}} 分钟", + "everyHours": "每 {{count}} 小时", + "everyDays": "每 {{count}} 天", + "onceAt": "执行一次,时间:{{time}}", + "weeklyAt": "每周 {{day}} {{time}}", + "monthlyAtDay": "每月 {{day}} 日 {{time}}", + "dailyAt": "每天 {{time}}", + "unknown": "未知" } -} \ No newline at end of file +} diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 8be9a53..9da1e36 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -37,17 +37,18 @@ import { toast } from 'sonner'; import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron'; import { CHANNEL_ICONS, type ChannelType } from '@/types/channel'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; // Common cron schedule presets -const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [ - { label: 'Every minute', value: '* * * * *', type: 'interval' }, - { label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' }, - { label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' }, - { label: 'Every hour', value: '0 * * * *', type: 'interval' }, - { label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' }, - { label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' }, - { label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' }, - { label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' }, +const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [ + { key: 'everyMinute', value: '* * * * *', type: 'interval' }, + { key: 'every5Min', value: '*/5 * * * *', type: 'interval' }, + { key: 'every15Min', value: '*/15 * * * *', type: 'interval' }, + { key: 'everyHour', value: '0 * * * *', type: 'interval' }, + { key: 'daily9am', value: '0 9 * * *', type: 'daily' }, + { key: 'daily6pm', value: '0 18 * * *', type: 'daily' }, + { key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' }, + { key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' }, ]; // Parse cron schedule to human-readable format @@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] = // { kind: "cron", expr: "...", tz?: "..." } // { kind: "every", everyMs: number } // { kind: "at", at: "..." } -function parseCronSchedule(schedule: unknown): string { +function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string { // Handle Gateway CronSchedule object format if (schedule && typeof schedule === 'object') { const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string }; if (s.kind === 'cron' && typeof s.expr === 'string') { - return parseCronExpr(s.expr); + return parseCronExpr(s.expr, t); } if (s.kind === 'every' && typeof s.everyMs === 'number') { const ms = s.everyMs; - if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`; - if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`; - if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`; - return `Every ${Math.round(ms / 86_400_000)} days`; + if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) }); + if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) }); + if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) }); + return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) }); } if (s.kind === 'at' && typeof s.at === 'string') { try { - return `Once at ${new Date(s.at).toLocaleString()}`; + return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() }); } catch { - return `Once at ${s.at}`; + return t('schedule.onceAt', { time: s.at }); } } return String(schedule); @@ -81,34 +82,33 @@ function parseCronSchedule(schedule: unknown): string { // Handle plain cron string if (typeof schedule === 'string') { - return parseCronExpr(schedule); + return parseCronExpr(schedule, t); } - return String(schedule ?? 'Unknown'); + return String(schedule ?? t('schedule.unknown')); } // Parse a plain cron expression string to human-readable text -function parseCronExpr(cron: string): string { +function parseCronExpr(cron: string, t: TFunction<'cron'>): string { const preset = schedulePresets.find((p) => p.value === cron); - if (preset) return preset.label; + if (preset) return t(`presets.${preset.key}` as const); const parts = cron.split(' '); if (parts.length !== 5) return cron; const [minute, hour, dayOfMonth, , dayOfWeek] = parts; - if (minute === '*' && hour === '*') return 'Every minute'; - if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`; - if (hour === '*' && minute === '0') return 'Every hour'; + if (minute === '*' && hour === '*') return t('presets.everyMinute'); + if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) }); + if (hour === '*' && minute === '0') return t('presets.everyHour'); if (dayOfWeek !== '*' && dayOfMonth === '*') { - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` }); } if (dayOfMonth !== '*') { - return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` }); } if (hour !== '*') { - return `Daily at ${hour}:${minute.padStart(2, '0')}`; + return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` }); } return cron; @@ -285,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { className="justify-start" > - {preset.label === 'Every minute' ? t('presets.everyMinute') : - preset.label === 'Every 5 minutes' ? t('presets.every5Min') : - preset.label === 'Every 15 minutes' ? t('presets.every15Min') : - preset.label === 'Every hour' ? t('presets.everyHour') : - preset.label === 'Daily at 9am' ? t('presets.daily9am') : - preset.label === 'Daily at 6pm' ? t('presets.daily6pm') : - preset.label === 'Weekly (Mon 9am)' ? t('presets.weeklyMon') : - preset.label === 'Monthly (1st at 9am)' ? t('presets.monthly1st') : - preset.label} + {t(`presets.${preset.key}` as const)} ))} @@ -332,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { {/* Actions */}
@@ -485,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard @@ -705,10 +697,10 @@ export function Cron() { { if (jobToDelete) {