Improve cron i18n coverage and reduce websocket stderr noise
This commit is contained in:
@@ -224,6 +224,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private lifecycleEpoch = 0;
|
private lifecycleEpoch = 0;
|
||||||
private deferredRestartPending = false;
|
private deferredRestartPending = false;
|
||||||
private restartInFlight: Promise<void> | null = null;
|
private restartInFlight: Promise<void> | null = null;
|
||||||
|
private externalShutdownSupported: boolean | null = null;
|
||||||
|
|
||||||
constructor(config?: Partial<ReconnectConfig>) {
|
constructor(config?: Partial<ReconnectConfig>) {
|
||||||
super();
|
super();
|
||||||
@@ -252,6 +253,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
return sanitized;
|
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 {
|
private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||||
if (code !== null) return `code=${code}`;
|
if (code !== null) return `code=${code}`;
|
||||||
if (signal) return `signal=${signal}`;
|
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.
|
// 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('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 };
|
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.
|
// Downgrade frequent non-fatal noise.
|
||||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
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
|
// If this manager is attached to an external gateway process, ask it to shut down
|
||||||
// over protocol before closing the socket.
|
// 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 {
|
try {
|
||||||
await this.rpc('shutdown', undefined, 5000);
|
await this.rpc('shutdown', undefined, 5000);
|
||||||
|
this.externalShutdownSupported = true;
|
||||||
} catch (error) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"paused": "Task paused",
|
"paused": "Task paused",
|
||||||
"deleted": "Task deleted",
|
"deleted": "Task deleted",
|
||||||
"triggered": "Task triggered successfully",
|
"triggered": "Task triggered successfully",
|
||||||
|
"failedTrigger": "Failed to trigger task: {{error}}",
|
||||||
"failedUpdate": "Failed to update task",
|
"failedUpdate": "Failed to update task",
|
||||||
"failedDelete": "Failed to delete task",
|
"failedDelete": "Failed to delete task",
|
||||||
"nameRequired": "Please enter a task name",
|
"nameRequired": "Please enter a task name",
|
||||||
@@ -66,5 +67,16 @@
|
|||||||
"channelRequired": "Please select a channel",
|
"channelRequired": "Please select a channel",
|
||||||
"discordIdRequired": "Please enter a Discord Channel ID",
|
"discordIdRequired": "Please enter a Discord Channel ID",
|
||||||
"scheduleRequired": "Please select or enter a schedule"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"paused": "タスクを停止しました",
|
"paused": "タスクを停止しました",
|
||||||
"deleted": "タスクを削除しました",
|
"deleted": "タスクを削除しました",
|
||||||
"triggered": "タスクを正常にトリガーしました",
|
"triggered": "タスクを正常にトリガーしました",
|
||||||
|
"failedTrigger": "タスクの実行に失敗しました: {{error}}",
|
||||||
"failedUpdate": "タスクの更新に失敗しました",
|
"failedUpdate": "タスクの更新に失敗しました",
|
||||||
"failedDelete": "タスクの削除に失敗しました",
|
"failedDelete": "タスクの削除に失敗しました",
|
||||||
"nameRequired": "タスク名を入力してください",
|
"nameRequired": "タスク名を入力してください",
|
||||||
@@ -66,5 +67,16 @@
|
|||||||
"channelRequired": "チャンネルを選択してください",
|
"channelRequired": "チャンネルを選択してください",
|
||||||
"discordIdRequired": "DiscordチャンネルIDを入力してください",
|
"discordIdRequired": "DiscordチャンネルIDを入力してください",
|
||||||
"scheduleRequired": "スケジュールを選択または入力してください"
|
"scheduleRequired": "スケジュールを選択または入力してください"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"everySeconds": "{{count}}秒ごと",
|
||||||
|
"everyMinutes": "{{count}}分ごと",
|
||||||
|
"everyHours": "{{count}}時間ごと",
|
||||||
|
"everyDays": "{{count}}日ごと",
|
||||||
|
"onceAt": "{{time}} に1回実行",
|
||||||
|
"weeklyAt": "毎週 {{day}} {{time}}",
|
||||||
|
"monthlyAtDay": "毎月 {{day}}日 {{time}}",
|
||||||
|
"dailyAt": "毎日 {{time}}",
|
||||||
|
"unknown": "不明"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"paused": "任务已暂停",
|
"paused": "任务已暂停",
|
||||||
"deleted": "任务已删除",
|
"deleted": "任务已删除",
|
||||||
"triggered": "任务已成功触发",
|
"triggered": "任务已成功触发",
|
||||||
|
"failedTrigger": "触发任务失败: {{error}}",
|
||||||
"failedUpdate": "更新任务失败",
|
"failedUpdate": "更新任务失败",
|
||||||
"failedDelete": "删除任务失败",
|
"failedDelete": "删除任务失败",
|
||||||
"nameRequired": "请输入任务名称",
|
"nameRequired": "请输入任务名称",
|
||||||
@@ -66,5 +67,16 @@
|
|||||||
"channelRequired": "请选择频道",
|
"channelRequired": "请选择频道",
|
||||||
"discordIdRequired": "请输入 Discord 频道 ID",
|
"discordIdRequired": "请输入 Discord 频道 ID",
|
||||||
"scheduleRequired": "请选择或输入调度计划"
|
"scheduleRequired": "请选择或输入调度计划"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"everySeconds": "每 {{count}} 秒",
|
||||||
|
"everyMinutes": "每 {{count}} 分钟",
|
||||||
|
"everyHours": "每 {{count}} 小时",
|
||||||
|
"everyDays": "每 {{count}} 天",
|
||||||
|
"onceAt": "执行一次,时间:{{time}}",
|
||||||
|
"weeklyAt": "每周 {{day}} {{time}}",
|
||||||
|
"monthlyAtDay": "每月 {{day}} 日 {{time}}",
|
||||||
|
"dailyAt": "每天 {{time}}",
|
||||||
|
"unknown": "未知"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,17 +37,18 @@ import { toast } from 'sonner';
|
|||||||
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
|
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
|
||||||
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
|
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
// Common cron schedule presets
|
// Common cron schedule presets
|
||||||
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
|
const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
|
||||||
{ label: 'Every minute', value: '* * * * *', type: 'interval' },
|
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
|
||||||
{ label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' },
|
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
|
||||||
{ label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' },
|
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
|
||||||
{ label: 'Every hour', value: '0 * * * *', type: 'interval' },
|
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
|
||||||
{ label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' },
|
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
|
||||||
{ label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' },
|
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
|
||||||
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' },
|
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
|
||||||
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
|
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Parse cron schedule to human-readable format
|
// Parse cron schedule to human-readable format
|
||||||
@@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] =
|
|||||||
// { kind: "cron", expr: "...", tz?: "..." }
|
// { kind: "cron", expr: "...", tz?: "..." }
|
||||||
// { kind: "every", everyMs: number }
|
// { kind: "every", everyMs: number }
|
||||||
// { kind: "at", at: "..." }
|
// { kind: "at", at: "..." }
|
||||||
function parseCronSchedule(schedule: unknown): string {
|
function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string {
|
||||||
// Handle Gateway CronSchedule object format
|
// Handle Gateway CronSchedule object format
|
||||||
if (schedule && typeof schedule === 'object') {
|
if (schedule && typeof schedule === 'object') {
|
||||||
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
|
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
|
||||||
if (s.kind === 'cron' && typeof s.expr === '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') {
|
if (s.kind === 'every' && typeof s.everyMs === 'number') {
|
||||||
const ms = s.everyMs;
|
const ms = s.everyMs;
|
||||||
if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`;
|
if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) });
|
||||||
if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`;
|
if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) });
|
||||||
if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`;
|
if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) });
|
||||||
return `Every ${Math.round(ms / 86_400_000)} days`;
|
return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) });
|
||||||
}
|
}
|
||||||
if (s.kind === 'at' && typeof s.at === 'string') {
|
if (s.kind === 'at' && typeof s.at === 'string') {
|
||||||
try {
|
try {
|
||||||
return `Once at ${new Date(s.at).toLocaleString()}`;
|
return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() });
|
||||||
} catch {
|
} catch {
|
||||||
return `Once at ${s.at}`;
|
return t('schedule.onceAt', { time: s.at });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(schedule);
|
return String(schedule);
|
||||||
@@ -81,34 +82,33 @@ function parseCronSchedule(schedule: unknown): string {
|
|||||||
|
|
||||||
// Handle plain cron string
|
// Handle plain cron string
|
||||||
if (typeof schedule === '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
|
// 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);
|
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(' ');
|
const parts = cron.split(' ');
|
||||||
if (parts.length !== 5) return cron;
|
if (parts.length !== 5) return cron;
|
||||||
|
|
||||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
||||||
|
|
||||||
if (minute === '*' && hour === '*') return 'Every minute';
|
if (minute === '*' && hour === '*') return t('presets.everyMinute');
|
||||||
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
|
if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) });
|
||||||
if (hour === '*' && minute === '0') return 'Every hour';
|
if (hour === '*' && minute === '0') return t('presets.everyHour');
|
||||||
if (dayOfWeek !== '*' && dayOfMonth === '*') {
|
if (dayOfWeek !== '*' && dayOfMonth === '*') {
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
|
||||||
return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
if (dayOfMonth !== '*') {
|
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 !== '*') {
|
if (hour !== '*') {
|
||||||
return `Daily at ${hour}:${minute.padStart(2, '0')}`;
|
return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return cron;
|
return cron;
|
||||||
@@ -285,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
|||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<Timer className="h-4 w-4 mr-2" />
|
<Timer className="h-4 w-4 mr-2" />
|
||||||
{preset.label === 'Every minute' ? t('presets.everyMinute') :
|
{t(`presets.${preset.key}` as const)}
|
||||||
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}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -332,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{t('common:actions.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={saving}>
|
<Button onClick={handleSubmit} disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Saving...
|
{t('common:status.saving', 'Saving...')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -374,7 +366,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
|||||||
toast.success(t('toast.triggered'));
|
toast.success(t('toast.triggered'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to trigger cron job:', error);
|
console.error('Failed to trigger cron job:', error);
|
||||||
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
|
toast.error(t('toast.failedTrigger', { error: error instanceof Error ? error.message : String(error) }));
|
||||||
} finally {
|
} finally {
|
||||||
setTriggering(false);
|
setTriggering(false);
|
||||||
}
|
}
|
||||||
@@ -407,7 +399,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
|||||||
<CardTitle className="text-lg">{job.name}</CardTitle>
|
<CardTitle className="text-lg">{job.name}</CardTitle>
|
||||||
<CardDescription className="flex items-center gap-2">
|
<CardDescription className="flex items-center gap-2">
|
||||||
<Timer className="h-3 w-3" />
|
<Timer className="h-3 w-3" />
|
||||||
{parseCronSchedule(job.schedule)}
|
{parseCronSchedule(job.schedule, t)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="ml-1">Edit</span>
|
<span className="ml-1">{t('common:actions.edit', 'Edit')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
<span className="ml-1 text-destructive">Delete</span>
|
<span className="ml-1 text-destructive">{t('common:actions.delete', 'Delete')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -705,10 +697,10 @@ export function Cron() {
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!jobToDelete}
|
open={!!jobToDelete}
|
||||||
title={t('common.confirm', 'Confirm')}
|
title={t('common:actions.confirm', 'Confirm')}
|
||||||
message={t('card.deleteConfirm')}
|
message={t('card.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 (jobToDelete) {
|
if (jobToDelete) {
|
||||||
|
|||||||
Reference in New Issue
Block a user