feat(cron): show command payloads in UI (#1286)

This commit is contained in:
Zezae Oh authored and GitHub committed 2026-06-27 11:24:55 +07:00
1 parent 226e33ddfb
commit 99e2b7f851
8 files changed
+192 -34

No files matched your search

+20 -1
View File
@@ -128,7 +128,22 @@
"deleteAfterRun": "Delete after run",
"deleteAfterRunDesc": "Auto-delete this job after first successful run",
"timezone": "Timezone",
"timezoneDesc": "Timezone for cron expressions"
"timezoneDesc": "Timezone for cron expressions",
"commandSection": "Command",
"commandArgv": "Command",
"noCommand": "No command configured",
"commandCwd": "Working directory",
"defaultWorkingDirectory": "Gateway default",
"commandTimeout": "Timeout",
"commandOutputLimit": "Output limit",
"commandEnv": "Environment",
"commandInput": "Input",
"commandNoOutputTimeout": "No-output timeout",
"seconds": "{{count}}s",
"bytes": "{{count}} bytes",
"defaultValue": "Default",
"none": "None",
"envVars": "{{count}} vars"
},
"stateless": "Stateless",
"statelessHelp": "Skip session persistence. Each run starts fresh without loading previous messages. Saves tokens for crons that don't need past context.",
@@ -145,5 +160,9 @@
"failedRun": "Failed to run cron job",
"updated": "Job updated",
"failedUpdate": "Failed to update job"
},
"payload": {
"agent": "Agent",
"command": "Command"
}
}
+20 -1
View File
@@ -128,7 +128,22 @@
"deleteAfterRun": "실행 후 삭제",
"deleteAfterRunDesc": "첫 번째 성공적인 실행 후 이 작업 자동 삭제",
"timezone": "시간대",
"timezoneDesc": "Cron 표현식의 시간대"
"timezoneDesc": "Cron 표현식의 시간대",
"commandSection": "명령",
"commandArgv": "명령",
"noCommand": "설정된 명령 없음",
"commandCwd": "작업 디렉터리",
"defaultWorkingDirectory": "게이트웨이 기본값",
"commandTimeout": "타임아웃",
"commandOutputLimit": "출력 제한",
"commandEnv": "환경 변수",
"commandInput": "입력",
"commandNoOutputTimeout": "무출력 타임아웃",
"seconds": "{{count}}초",
"bytes": "{{count}} bytes",
"defaultValue": "기본값",
"none": "없음",
"envVars": "{{count}}개"
},
"stateless": "상태 없음",
"statelessHelp": "세션 지속성 건너뜁니다. 각 실행은 이전 메시지 없이 새로 시작됩니다. 이전 컨텍스트가 필요 없는 cron의 토큰을 절약합니다.",
@@ -145,5 +160,9 @@
"failedRun": "Cron 작업 실행 실패",
"updated": "작업 업데이트됨",
"failedUpdate": "작업 업데이트 실패"
},
"payload": {
"agent": "에이전트",
"command": "명령"
}
}
+20 -1
View File
@@ -128,7 +128,22 @@
"deleteAfterRun": "Xóa sau khi chạy",
"deleteAfterRunDesc": "Tự động xóa công việc sau lần chạy thành công đầu tiên",
"timezone": "Múi giờ",
"timezoneDesc": "Múi giờ cho biểu thức cron"
"timezoneDesc": "Múi giờ cho biểu thức cron",
"commandSection": "Lệnh",
"commandArgv": "Lệnh",
"noCommand": "Chưa cấu hình lệnh",
"commandCwd": "Thư mục làm việc",
"defaultWorkingDirectory": "Mặc định gateway",
"commandTimeout": "Thời gian chờ",
"commandOutputLimit": "Giới hạn output",
"commandEnv": "Môi trường",
"commandInput": "Input",
"commandNoOutputTimeout": "Timeout khi không có output",
"seconds": "{{count}}s",
"bytes": "{{count}} bytes",
"defaultValue": "Mặc định",
"none": "Không có",
"envVars": "{{count}} biến"
},
"stateless": "Không trạng thái",
"statelessHelp": "Bỏ qua lưu session. Mỗi lần chạy bắt đầu mới. Tiết kiệm token cho các cron không cần context cũ.",
@@ -145,5 +160,9 @@
"failedRun": "Không thể chạy tác vụ định kỳ",
"updated": "Đã cập nhật job",
"failedUpdate": "Cập nhật job thất bại"
},
"payload": {
"agent": "Agent",
"command": "Lệnh"
}
}
+20 -1
View File
@@ -128,7 +128,22 @@
"deleteAfterRun": "运行后删除",
"deleteAfterRunDesc": "首次成功运行后自动删除此任务",
"timezone": "时区",
"timezoneDesc": "Cron表达式的时区"
"timezoneDesc": "Cron表达式的时区",
"commandSection": "命令",
"commandArgv": "命令",
"noCommand": "未配置命令",
"commandCwd": "工作目录",
"defaultWorkingDirectory": "网关默认值",
"commandTimeout": "超时",
"commandOutputLimit": "输出限制",
"commandEnv": "环境变量",
"commandInput": "输入",
"commandNoOutputTimeout": "无输出超时",
"seconds": "{{count}}秒",
"bytes": "{{count}} bytes",
"defaultValue": "默认值",
"none": "无",
"envVars": "{{count}}个变量"
},
"stateless": "无状态",
"statelessHelp": "跳过会话持久化。每次运行从零开始。为不需要历史上下文的定时任务节省令牌。",
@@ -145,5 +160,9 @@
"failedRun": "运行定时任务失败",
"updated": "任务已更新",
"failedUpdate": "更新任务失败"
},
"payload": {
"agent": "Agent",
"command": "命令"
}
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Calendar, Clock, AlertTriangle, Pencil } from "lucide-react";
import { Calendar, Clock, AlertTriangle, Pencil, Terminal } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
@@ -15,7 +15,7 @@ import { useChannels } from "@/pages/channels/hooks/use-channels";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { CronJob, CronJobPatch } from "../hooks/use-cron";
import { CronStatusBadge } from "../cron-utils";
import { CronStatusBadge, formatCommand, isCommandCron } from "../cron-utils";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { CronScheduleSection } from "./cron-schedule-section";
import { CronDeliverySection } from "./cron-delivery-section";
@@ -66,6 +66,8 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
const [stateless, setStateless] = useState(job.stateless ?? false);
const [saving, setSaving] = useState(false);
const command = job.payload?.command;
const commandEnvCount = command?.env ? Object.keys(command.env).length : 0;
// Fetch delivery targets on mount
const fetchTargets = useCallback(async () => {
@@ -98,7 +100,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
}
const patch: import("../hooks/use-cron").CronJobPatch = {
schedule,
message: message.trim(),
message: isCommandCron(job) ? undefined : message.trim(),
agentId: agentId.trim() || "",
deliver,
deliverChannel: deliver ? channel.trim() || undefined : undefined,
@@ -134,31 +136,88 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
readonly={readonly}
/>
{/* Message section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t("detail.messageSection")}</h3>
{!readonly && (
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs text-muted-foreground"
onClick={() => setEditingMessage(!editingMessage)}>
<Pencil className="h-3 w-3" />
{editingMessage ? t("detail.preview") : t("detail.edit")}
</Button>
)}
</div>
{editingMessage ? (
<Textarea value={message} onChange={(e) => setMessage(e.target.value)}
rows={6} placeholder={t("create.messagePlaceholder")} className="text-base md:text-sm resize-none" />
) : (
<div className="rounded-md border bg-muted/30 p-3 sm:p-4">
{message ? (
<MarkdownRenderer content={message} className="prose-sm max-w-none" />
) : (
<p className="text-sm italic text-muted-foreground">{t("detail.noMessage")}</p>
{isCommandCron(job) ? (
<section className="space-y-3 rounded-lg border border-amber-200 bg-amber-50/40 p-3 sm:p-4 overflow-hidden dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<h3 className="text-sm font-medium">{t("detail.commandSection")}</h3>
</div>
<span className="rounded-full border border-amber-300 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:border-amber-800 dark:text-amber-300">
{t("payload.command")}
</span>
</div>
<div className="space-y-3 rounded-md border bg-background/80 p-3 sm:p-4">
<div>
<div className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.commandArgv")}</div>
{formatCommand(job) ? (
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 text-xs"><code>{formatCommand(job)}</code></pre>
) : (
<p className="text-sm italic text-muted-foreground">{t("detail.noCommand")}</p>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandCwd")}</div>
<div className="truncate text-sm">{command?.cwd || t("detail.defaultWorkingDirectory")}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandTimeout")}</div>
<div className="text-sm">{command?.timeoutSeconds ? t("detail.seconds", { count: command.timeoutSeconds }) : t("detail.defaultValue")}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandOutputLimit")}</div>
<div className="text-sm">{command?.outputMaxBytes ? t("detail.bytes", { count: command.outputMaxBytes }) : t("detail.defaultValue")}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandEnv")}</div>
<div className="text-sm">{commandEnvCount ? t("detail.envVars", { count: commandEnvCount }) : t("detail.none")}</div>
</div>
</div>
{(command?.input || command?.noOutputTimeoutSeconds) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{command.input && (
<div>
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandInput")}</div>
<pre className="max-h-32 overflow-auto rounded-md bg-muted px-3 py-2 text-xs"><code>{command.input}</code></pre>
</div>
)}
{command.noOutputTimeoutSeconds ? (
<div>
<div className="text-xs font-medium text-muted-foreground">{t("detail.commandNoOutputTimeout")}</div>
<div className="text-sm">{t("detail.seconds", { count: command.noOutputTimeoutSeconds })}</div>
</div>
) : null}
</div>
)}
</div>
)}
</section>
</section>
) : (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t("detail.messageSection")}</h3>
{!readonly && (
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs text-muted-foreground"
onClick={() => setEditingMessage(!editingMessage)}>
<Pencil className="h-3 w-3" />
{editingMessage ? t("detail.preview") : t("detail.edit")}
</Button>
)}
</div>
{editingMessage ? (
<Textarea value={message} onChange={(e) => setMessage(e.target.value)}
rows={6} placeholder={t("create.messagePlaceholder")} className="text-base md:text-sm resize-none" />
) : (
<div className="rounded-md border bg-muted/30 p-3 sm:p-4">
{message ? (
<MarkdownRenderer content={message} className="prose-sm max-w-none" />
) : (
<p className="text-sm italic text-muted-foreground">{t("detail.noMessage")}</p>
)}
</div>
)}
</section>
)}
{/* Delivery section */}
<CronDeliverySection
+6 -3
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { CronJob } from "./hooks/use-cron";
import { formatSchedule, CronStatusBadge } from "./cron-utils";
import { formatSchedule, CronStatusBadge, formatCommand, isCommandCron } from "./cron-utils";
import { useAgents } from "@/pages/agents/hooks/use-agents";
interface CronListRowProps {
@@ -38,6 +38,9 @@ export function CronListRow({ job, onClick, onRun, onDelete }: CronListRowProps)
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Badge variant="outline" className="text-2xs px-1 py-0">{job.schedule.kind}</Badge>
<Badge variant={isCommandCron(job) ? "secondary" : "outline"} className="text-2xs px-1 py-0">
{isCommandCron(job) ? t("payload.command") : t("payload.agent")}
</Badge>
<span className="truncate">{formatSchedule(job)}</span>
</div>
</div>
@@ -52,9 +55,9 @@ export function CronListRow({ job, onClick, onRun, onDelete }: CronListRowProps)
{agent?.display_name || agent?.agent_key || job.agentId || t("card.defaultAgent")}
</div>
{/* Message preview */}
{/* Message / command preview */}
<div className="hidden shrink-0 text-xs text-muted-foreground/60 lg:block lg:w-40 lg:truncate">
{job.payload?.message}
{isCommandCron(job) ? formatCommand(job) : job.payload?.message}
</div>
{/* Actions */}
+10
View File
@@ -1,6 +1,16 @@
import { Badge } from "@/components/ui/badge";
import type { CronJob } from "./hooks/use-cron";
export function isCommandCron(job: CronJob): boolean {
return job.payload?.kind === "command";
}
export function formatCommand(job: CronJob): string {
const argv = job.payload?.command?.argv;
if (argv && argv.length > 0) return argv.join(" ");
return "";
}
export function formatSchedule(job: CronJob): string {
const s = job.schedule;
if (s.kind === "every" && s.everyMs) {
+11 -1
View File
@@ -15,10 +15,20 @@ export interface CronSchedule {
tz?: string;
}
export interface CronCommandSpec {
argv?: string[];
cwd?: string;
env?: Record<string, string>;
input?: string;
timeoutSeconds?: number;
noOutputTimeoutSeconds?: number;
outputMaxBytes?: number;
}
export interface CronPayload {
kind: string;
message: string;
command?: string;
command?: CronCommandSpec;
}
export interface CronJobPatch {