feat(cron-ui): move advanced settings inline to overview tab

Refactor cron detail page to consolidate settings in overview tab.
Remove separate advanced dialog, simplifying navigation and reducing layout complexity.
This commit is contained in:
viettranx committed 2026-04-02 08:23:14 +07:00
1 parent ab0e2051a6
commit 7291f31364
4 files changed
+188 -89

No files matched your search

@@ -157,7 +157,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
</div>
{deliver && (
<div className="grid grid-cols-1 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
<div className="space-y-2 min-w-0">
<Label>{t("detail.channelLabel")}</Label>
{channelNames.length > 0 ? (
@@ -6,7 +6,6 @@ import type { CronJob, CronJobPatch, CronRunLogEntry } from "../hooks/use-cron";
import { CronHeader } from "./cron-header";
import { CronOverviewTab } from "./cron-overview-tab";
import { CronRunHistoryTab } from "./cron-run-history-tab";
import { CronAdvancedDialog } from "./cron-advanced-dialog";
interface CronDetailPageProps {
job: CronJob;
@@ -31,7 +30,6 @@ export function CronDetailPage({
}: CronDetailPageProps) {
const { t } = useTranslation("cron");
const [activeTab, setActiveTab] = useState("overview");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmToggle, setConfirmToggle] = useState(false);
const [running, setRunning] = useState(false);
@@ -64,7 +62,6 @@ export function CronDetailPage({
isRunning={isRunning}
onBack={onBack}
onRun={handleRun}
onAdvanced={() => setAdvancedOpen(true)}
onToggle={() => setConfirmToggle(true)}
onDelete={() => setConfirmDelete(true)}
/>
@@ -88,14 +85,6 @@ export function CronDetailPage({
</div>
</div>
<CronAdvancedDialog
key={job.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
job={job}
onUpdate={onUpdate}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { ArrowLeft, Clock, Loader2, Play, Power, Settings, Trash2 } from "lucide-react";
import { ArrowLeft, Clock, Loader2, Play, Power, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -13,13 +13,12 @@ interface CronHeaderProps {
isRunning: boolean;
onBack: () => void;
onRun: () => void;
onAdvanced: () => void;
onToggle: () => void;
onDelete: () => void;
}
export function CronHeader({ job, isRunning, onBack, onRun, onAdvanced, onToggle, onDelete }: CronHeaderProps) {
export function CronHeader({ job, isRunning, onBack, onRun, onToggle, onDelete }: CronHeaderProps) {
const { t } = useTranslation("cron");
const { agents } = useAgents();
const agent = job.agentId ? agents.find((a) => a.id === job.agentId) : null;
@@ -100,17 +99,6 @@ export function CronHeader({ job, isRunning, onBack, onRun, onAdvanced, onToggle
<Power className="h-4 w-4 sm:hidden" />
</Button>
{/* Advanced */}
<Button
variant="ghost"
size="sm"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
{/* Delete */}
<Button
variant="ghost"
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Calendar, Clock, AlertTriangle, Globe, Pencil } from "lucide-react";
import { Calendar, Clock, AlertTriangle, Pencil, Send, Settings } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -9,11 +9,24 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { Combobox } from "@/components/ui/combobox";
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
import { formatDate } from "@/lib/format";
import { toast } from "@/stores/use-toast-store";
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 { useAgents } from "@/pages/agents/hooks/use-agents";
interface DeliveryTarget {
channel: string;
chatId: string;
title?: string;
kind: string;
}
interface CronOverviewTabProps {
job: CronJob;
onUpdate?: (id: string, params: CronJobPatch) => Promise<void>;
@@ -31,34 +44,74 @@ function getEverySeconds(job: CronJob): string {
export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
const { t } = useTranslation("cron");
const { agents } = useAgents();
const ws = useWs();
const { channels: availableChannels } = useChannels();
const channelNames = Object.keys(availableChannels);
const readonly = !onUpdate;
// Schedule fields
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>(job.schedule.kind as ScheduleKind);
const [everySeconds, setEverySeconds] = useState(getEverySeconds(job));
const [cronExpr, setCronExpr] = useState(job.schedule.expr ?? "0 * * * *");
const [timezone, setTimezone] = useState(job.schedule.tz ?? "UTC");
const [message, setMessage] = useState(job.payload?.message ?? "");
const [agentId, setAgentId] = useState(job.agentId ?? "");
const [enabled, setEnabled] = useState(job.enabled);
const [editingMessage, setEditingMessage] = useState(false);
// Delivery fields
const [deliver, setDeliver] = useState(job.deliver ?? false);
const [channel, setChannel] = useState(job.deliverChannel ?? "");
const [to, setTo] = useState(job.deliverTo ?? "");
const [wakeHeartbeat, setWakeHeartbeat] = useState(job.wakeHeartbeat ?? false);
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
// Lifecycle fields
const [deleteAfterRun, setDeleteAfterRun] = useState(job.deleteAfterRun ?? false);
const [stateless, setStateless] = useState(job.stateless ?? false);
const [saving, setSaving] = useState(false);
// Fetch delivery targets on mount
const fetchTargets = useCallback(async () => {
if (!ws.isConnected) return;
try {
const res = await ws.call<{ targets: DeliveryTarget[] }>(
Methods.HEARTBEAT_TARGETS, { agentId: job.agentId || "" },
);
setTargets(res.targets ?? []);
} catch { /* fallback to Input */ }
}, [ws, job.agentId]);
useEffect(() => { fetchTargets(); }, [fetchTargets]);
const handleSave = async () => {
if (!onUpdate) return;
if (timezone && timezone !== "UTC" && !isValidIanaTimezone(timezone)) {
toast.error(t("detail.invalidTimezone", "Invalid timezone"));
return;
}
setSaving(true);
try {
let schedule;
if (scheduleKind === "every") {
schedule = { kind: "every" as const, everyMs: Number(everySeconds) * 1000 };
schedule = { kind: "every" as const, everyMs: Number(everySeconds) * 1000, tz: timezone !== "UTC" ? timezone : "" };
} else if (scheduleKind === "cron") {
schedule = { kind: "cron" as const, expr: cronExpr };
schedule = { kind: "cron" as const, expr: cronExpr, tz: timezone !== "UTC" ? timezone : "" };
} else {
schedule = { kind: "at" as const, atMs: job.schedule.atMs ?? Date.now() + 60000 };
schedule = { kind: "at" as const, atMs: job.schedule.atMs ?? Date.now() + 60000, tz: timezone !== "UTC" ? timezone : "" };
}
await onUpdate(job.id, {
schedule,
message: message.trim(),
agentId: agentId.trim() || "",
enabled,
deliver,
deliverChannel: deliver ? channel.trim() || undefined : undefined,
deliverTo: deliver ? to.trim() || undefined : undefined,
wakeHeartbeat,
deleteAfterRun,
stateless,
});
setEditingMessage(false);
} catch {
@@ -76,11 +129,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<div className="space-y-2">
<Label>{t("create.name")}</Label>
<Input
value={job.name}
disabled
className="text-base md:text-sm"
/>
<Input value={job.name} disabled className="text-base md:text-sm" />
</div>
<div className="space-y-2">
@@ -105,12 +154,9 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<div className="space-y-2">
<Label>{t("create.intervalSeconds")}</Label>
<Input
type="number"
min={1}
value={everySeconds}
type="number" min={1} value={everySeconds}
onChange={(e) => setEverySeconds(e.target.value)}
disabled={readonly}
className="text-base md:text-sm"
disabled={readonly} className="text-base md:text-sm"
/>
</div>
)}
@@ -119,11 +165,8 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<div className="space-y-2">
<Label>{t("create.cronExpression")}</Label>
<Input
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
disabled={readonly}
placeholder="0 * * * *"
className="text-base md:text-sm"
value={cronExpr} onChange={(e) => setCronExpr(e.target.value)}
disabled={readonly} placeholder="0 * * * *" className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("create.cronHint")}</p>
</div>
@@ -132,33 +175,35 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
{scheduleKind === "at" && (
<p className="text-sm text-muted-foreground">{t("create.onceDesc")}</p>
)}
{/* Timezone */}
<div className="space-y-2">
<Label>{t("detail.timezone")}</Label>
<Combobox
value={timezone} onChange={setTimezone}
options={getAllIanaTimezones()}
placeholder={t("detail.timezone")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("detail.timezoneDesc")}</p>
</div>
</section>
{/* Message section — markdown preview or textarea */}
{/* 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)}
>
<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"
/>
<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 ? (
@@ -170,13 +215,90 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
)}
</section>
{/* Delivery section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-blue-500" />
<h3 className="text-sm font-medium">{t("detail.delivery")}</h3>
</div>
<p className="text-xs text-muted-foreground">{t("detail.deliveryDesc")}</p>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<p className="text-sm font-medium">{t("detail.deliverToChannel")}</p>
<Switch checked={deliver} onCheckedChange={setDeliver} disabled={readonly} />
</div>
{deliver && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr]">
<div className="space-y-2 min-w-0">
<Label>{t("detail.channelLabel")}</Label>
{channelNames.length > 0 ? (
<Select value={channel || "__none__"}
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setTo(""); }}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("detail.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("detail.channelPlaceholder")}</SelectItem>
{channelNames.map((ch) => (
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input value={channel} onChange={(e) => setChannel(e.target.value)}
placeholder={t("detail.channelPlaceholder")} className="text-base md:text-sm" />
)}
</div>
<div className="space-y-2 min-w-0">
<Label>{t("detail.toLabel")}</Label>
{(() => {
if (!channel) {
return <Input placeholder={t("detail.channelPlaceholder")} disabled className="text-base md:text-sm" />;
}
const filtered = targets.filter((tgt) => tgt.channel === channel);
if (filtered.length > 0) {
return (
<Select value={to || "__none__"} onValueChange={(v) => setTo(v === "__none__" ? "" : v)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("detail.toPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("detail.toPlaceholder")}</SelectItem>
{filtered.map((tgt) => (
<SelectItem key={tgt.chatId} value={tgt.chatId}
title={tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}>
<span className="truncate">{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return <Input value={to} onChange={(e) => setTo(e.target.value)}
placeholder={t("detail.toPlaceholder")} className="text-base md:text-sm" />;
})()}
</div>
</div>
)}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div>
<p className="text-sm font-medium">{t("detail.wakeHeartbeat")}</p>
<p className="text-xs text-muted-foreground">{t("detail.wakeHeartbeatDesc")}</p>
</div>
<Switch checked={wakeHeartbeat} onCheckedChange={setWakeHeartbeat} disabled={readonly} />
</div>
</section>
{/* Agent & Status section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.agentStatus")}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("create.agentId")}</Label>
<Select name="agentId" value={agentId || "__default__"} onValueChange={(v) => setAgentId(v === "__default__" ? "" : v)} disabled={readonly}>
<Select name="agentId" value={agentId || "__default__"}
onValueChange={(v) => setAgentId(v === "__default__" ? "" : v)} disabled={readonly}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("create.agentIdPlaceholder")} />
</SelectTrigger>
@@ -194,11 +316,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<Label>{t("columns.enabled")}</Label>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<span className="text-sm">{enabled ? t("detail.enabled") : t("detail.disabled")}</span>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
disabled={readonly}
/>
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={readonly} />
</div>
</div>
</div>
@@ -208,8 +326,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
{job.state?.nextRunAtMs && (
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{t("detail.infoRows.nextRun")}
<Calendar className="h-3 w-3" />{t("detail.infoRows.nextRun")}
</div>
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.state.nextRunAtMs))}</div>
</div>
@@ -217,8 +334,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
{job.state?.lastRunAtMs && (
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{t("detail.infoRows.lastRun")}
<Clock className="h-3 w-3" />{t("detail.infoRows.lastRun")}
</div>
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.state.lastRunAtMs))}</div>
</div>
@@ -233,19 +349,30 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
<div className="text-xs text-muted-foreground">{t("detail.infoRows.created")}</div>
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.createdAtMs))}</div>
</div>
<div className="rounded-md bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">{t("detail.infoRows.updated")}</div>
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.updatedAtMs))}</div>
</div>
</section>
{/* Lifecycle section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t("detail.lifecycle")}</h3>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div>
<p className="text-sm font-medium">{t("detail.deleteAfterRun")}</p>
<p className="text-xs text-muted-foreground">{t("detail.deleteAfterRunDesc")}</p>
</div>
{job.schedule.tz && (
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="h-3 w-3" />
{t("detail.infoRows.timezone")}
</div>
<div className="mt-1 text-sm font-medium">{job.schedule.tz}</div>
</div>
)}
<Switch checked={deleteAfterRun} onCheckedChange={setDeleteAfterRun} disabled={readonly} />
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div>
<p className="text-sm font-medium">{t("stateless")}</p>
<p className="text-xs text-muted-foreground">{t("statelessHelp")}</p>
</div>
<Switch checked={stateless} onCheckedChange={setStateless} disabled={readonly} />
</div>
</section>
@@ -262,12 +389,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
</section>
)}
{!readonly && (
<StickySaveBar
onSave={handleSave}
saving={saving}
/>
)}
{!readonly && <StickySaveBar onSave={handleSave} saving={saving} />}
</div>
);
}