feat(cron): add detail page, running status, realtime events, and pagination

- Add /cron/:id detail page with job info, payload, run history
- Make cron.run async: respond immediately, execute in background
- Set last_status="running" before execution, emit CronEvent via WS
- Add CronEvent (running/completed/error) broadcast to all WS clients
- Add server-side pagination to GetRunLog (offset + total count)
- Show loading spinner on Run button when job is running (list + detail)
- Enrich CronRunLogEntry with duration, input/output tokens
- Make job names clickable in overview card → /cron/:id
- Fix refresh button animation using isFetching instead of isPending
This commit is contained in:
viettranx committed 2026-03-09 09:44:57 +07:00
1 parent 08ca343972
commit 3c03d1f482
12 files changed
+463 -58

No files matched your search

+14 -2
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"github.com/google/uuid"
@@ -795,9 +796,17 @@ func runGateway() {
// Wire pairing approval notification → channel (matching TS notifyPairingApproved).
botName := cfg.ResolveDisplayName("default")
pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID string) {
pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) {
msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName)
if err := channelMgr.SendToChannel(ctx, channel, chatID, msg); err != nil {
// Group pairings need group_id metadata so channels (e.g. Zalo) route to group API.
if strings.HasPrefix(senderID, "group:") {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: msg,
Metadata: map[string]string{"group_id": chatID},
})
} else if err := channelMgr.SendToChannel(ctx, channel, chatID, msg); err != nil {
slog.Warn("failed to send pairing approval notification", "channel", channel, "chatID", chatID, "error", err)
}
})
@@ -892,6 +901,9 @@ func runGateway() {
// Start cron service with job handler (routes through scheduler's cron lane)
pgStores.Cron.SetOnJob(makeCronJobHandler(sched, msgBus, cfg, channelMgr))
pgStores.Cron.SetOnEvent(func(event store.CronEvent) {
server.BroadcastEvent(*protocol.NewEvent(protocol.EventCron, event))
})
if err := pgStores.Cron.Start(); err != nil {
slog.Warn("cron service failed to start", "error", err)
}
+22 -14
View File
@@ -3,6 +3,7 @@ package methods
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
@@ -188,27 +189,33 @@ func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *
}
force := params.Mode == "force"
ran, reason, err := m.service.RunJob(jobID, force)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
// Validate job exists before responding
_, ok := m.service.GetJob(jobID)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "job not found"))
return
}
resp := map[string]interface{}{
// Respond immediately — job execution happens in background
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"ok": true,
"ran": ran,
}
if !ran && reason != "" {
resp["reason"] = reason
}
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
"ran": true,
}))
go func() {
if _, _, err := m.service.RunJob(jobID, force); err != nil {
slog.Warn("cron.run background error", "jobId", jobID, "error", err)
}
}()
}
func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
JobID string `json:"jobId"`
ID string `json:"id"`
Limit int `json:"limit"`
JobID string `json:"jobId"`
ID string `json:"id"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -219,8 +226,9 @@ func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req
jobID = params.ID
}
entries := m.service.GetRunLog(jobID, params.Limit)
entries, total := m.service.GetRunLog(jobID, params.Limit, params.Offset)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"entries": entries,
"total": total,
}))
}
+19 -6
View File
@@ -46,11 +46,14 @@ type CronJobState struct {
// CronRunLogEntry records a job execution.
type CronRunLogEntry struct {
Ts int64 `json:"ts"`
JobID string `json:"jobId"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
Ts int64 `json:"ts"`
JobID string `json:"jobId"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
DurationMS int64 `json:"durationMs,omitempty"`
InputTokens int `json:"inputTokens,omitempty"`
OutputTokens int `json:"outputTokens,omitempty"`
}
// CronJobResult is the output of a cron job handler execution.
@@ -74,6 +77,15 @@ type CronJobPatch struct {
DeleteAfterRun *bool `json:"deleteAfterRun,omitempty"`
}
// CronEvent represents a job lifecycle event sent to subscribers.
type CronEvent struct {
Action string `json:"action"` // "running", "completed", "error"
JobID string `json:"jobId"`
JobName string `json:"jobName,omitempty"`
Status string `json:"status,omitempty"` // final status for completed/error
Error string `json:"error,omitempty"`
}
// CronStore manages scheduled jobs.
type CronStore interface {
AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error)
@@ -82,7 +94,7 @@ type CronStore interface {
RemoveJob(jobID string) error
UpdateJob(jobID string, patch CronJobPatch) (*CronJob, error)
EnableJob(jobID string, enabled bool) error
GetRunLog(jobID string, limit int) []CronRunLogEntry
GetRunLog(jobID string, limit, offset int) ([]CronRunLogEntry, int)
Status() map[string]interface{}
// Lifecycle
@@ -91,6 +103,7 @@ type CronStore interface {
// Job execution
SetOnJob(handler func(job *CronJob) (*CronJobResult, error))
SetOnEvent(handler func(event CronEvent))
RunJob(jobID string, force bool) (ran bool, reason string, err error)
// Due job detection (for scheduler)
+57 -18
View File
@@ -24,6 +24,7 @@ type PGCronStore struct {
db *sql.DB
mu sync.Mutex
onJob func(job *store.CronJob) (*store.CronJobResult, error)
onEvent func(event store.CronEvent)
running bool
stop chan struct{}
@@ -369,27 +370,35 @@ func (s *PGCronStore) EnableJob(jobID string, enabled bool) error {
return nil
}
func (s *PGCronStore) GetRunLog(jobID string, limit int) []store.CronRunLogEntry {
func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)"
var total int
var rows *sql.Rows
var err error
if jobID != "" {
id, parseErr := uuid.Parse(jobID)
if parseErr != nil {
return nil
return nil, 0
}
s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total)
rows, err = s.db.Query(
"SELECT job_id, status, error, summary, ran_at FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2",
id, limit)
"SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3",
id, limit, offset)
} else {
s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total)
rows, err = s.db.Query(
"SELECT job_id, status, error, summary, ran_at FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1", limit)
"SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset)
}
if err != nil {
return nil
return nil, 0
}
defer rows.Close()
@@ -399,18 +408,23 @@ func (s *PGCronStore) GetRunLog(jobID string, limit int) []store.CronRunLogEntry
var status string
var errStr, summary *string
var ranAt time.Time
if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt); err != nil {
var durationMS int64
var inputTokens, outputTokens int
if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil {
continue
}
result = append(result, store.CronRunLogEntry{
Ts: ranAt.UnixMilli(),
JobID: jobUUID.String(),
Status: status,
Error: derefStr(errStr),
Summary: derefStr(summary),
Ts: ranAt.UnixMilli(),
JobID: jobUUID.String(),
Status: status,
Error: derefStr(errStr),
Summary: derefStr(summary),
DurationMS: durationMS,
InputTokens: inputTokens,
OutputTokens: outputTokens,
})
}
return result
return result, total
}
func (s *PGCronStore) Status() map[string]interface{} {
@@ -454,6 +468,21 @@ func (s *PGCronStore) SetOnJob(handler func(job *store.CronJob) (*store.CronJobR
s.onJob = handler
}
func (s *PGCronStore) SetOnEvent(handler func(event store.CronEvent)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onEvent = handler
}
func (s *PGCronStore) emitEvent(event store.CronEvent) {
s.mu.Lock()
fn := s.onEvent
s.mu.Unlock()
if fn != nil {
fn(event)
}
}
func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) {
job, ok := s.GetJob(jobID)
if !ok {
@@ -468,11 +497,21 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) {
return false, "", fmt.Errorf("no job handler configured")
}
result, err := handler(job)
content := ""
if result != nil {
content = result.Content
// Mark job as running before execution
if id, parseErr := uuid.Parse(jobID); parseErr == nil {
s.db.Exec("UPDATE cron_jobs SET last_status = 'running', updated_at = $1 WHERE id = $2", time.Now(), id)
}
return true, content, err
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name})
// Use executeOneJob for proper state updates, run logging, and retry
s.executeOneJob(*job, handler)
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
return true, "", nil
}
+8
View File
@@ -242,4 +242,12 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C
now, status, lastError, next, now, id,
)
}
// Emit completion event
evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, Status: status}
if err != nil {
evt.Action = "error"
evt.Error = err.Error()
}
s.emitEvent(evt)
}
+2 -1
View File
@@ -366,11 +366,12 @@ func (t *CronTool) handleRuns(args map[string]interface{}, agentID, userID strin
limit = int(v)
}
entries := t.cronStore.GetRunLog(jobID, limit)
entries, total := t.cronStore.GetRunLog(jobID, limit, 0)
result := map[string]interface{}{
"entries": entries,
"count": len(entries),
"total": total,
}
data, _ := json.MarshalIndent(result, "", " ")
return NewResult(string(data))
+1
View File
@@ -10,6 +10,7 @@ export const ROUTES = {
SKILLS: "/skills",
SKILL_DETAIL: "/skills/:id",
CRON: "/cron",
CRON_DETAIL: "/cron/:id",
CONFIG: "/config",
TRACES: "/traces",
TRACE_DETAIL: "/traces/:id",
+284
View File
@@ -0,0 +1,284 @@
import { useState, useEffect, useCallback } from "react";
import { ArrowLeft, Play, Trash2, Power, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { Pagination } from "@/components/shared/pagination";
import { formatDate, formatTokens } from "@/lib/format";
import type { CronJob, CronRunLogEntry } from "./hooks/use-cron";
function formatScheduleDetail(job: CronJob): string {
const s = job.schedule;
if (s.kind === "every" && s.everyMs) {
const sec = s.everyMs / 1000;
if (sec < 60) return `Every ${sec} seconds`;
if (sec < 3600) return `Every ${Math.round(sec / 60)} minutes`;
return `Every ${(sec / 3600).toFixed(1)} hours`;
}
if (s.kind === "cron" && s.expr) return s.expr;
if (s.kind === "at" && s.atMs) return `Once at ${new Date(s.atMs).toLocaleString()}`;
return s.kind;
}
function formatDuration(ms?: number): string {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
interface CronDetailPageProps {
job: CronJob;
onBack: () => void;
onRun: (id: string) => Promise<void>;
onToggle: (id: string, enabled: boolean) => Promise<void>;
onDelete: (id: string) => Promise<void>;
getRunLog: (id: string, limit?: number, offset?: number) => Promise<{ entries: CronRunLogEntry[]; total: number }>;
onRefresh: () => void;
}
export function CronDetailPage({ job, onBack, onRun, onToggle, onDelete, getRunLog, onRefresh }: CronDetailPageProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmToggle, setConfirmToggle] = useState(false);
const [runLog, setRunLog] = useState<CronRunLogEntry[]>([]);
const [runLogTotal, setRunLogTotal] = useState(0);
const [runLogLoading, setRunLogLoading] = useState(true);
const [running, setRunning] = useState(false);
const isRunning = running || job.state?.lastStatus === "running";
const [runLogPage, setRunLogPage] = useState(1);
const [runLogPageSize, setRunLogPageSize] = useState(10);
const loadRunLog = useCallback(async (page?: number, pageSize?: number) => {
const p = page ?? runLogPage;
const ps = pageSize ?? runLogPageSize;
setRunLogLoading(true);
try {
const { entries, total } = await getRunLog(job.id, ps, (p - 1) * ps);
setRunLog(entries);
setRunLogTotal(total);
} finally {
setRunLogLoading(false);
}
}, [job.id, getRunLog, runLogPage, runLogPageSize]);
const runLogTotalPages = Math.ceil(runLogTotal / runLogPageSize);
useEffect(() => {
loadRunLog();
}, [loadRunLog]);
// Poll cron list while job is running to detect completion
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(onRefresh, 3000);
return () => clearInterval(interval);
}, [isRunning, onRefresh]);
// Clear local running state when backend status changes from running
useEffect(() => {
if (running && job.state?.lastStatus && job.state.lastStatus !== "running") {
setRunning(false);
loadRunLog(1);
}
}, [running, job.state?.lastStatus, loadRunLog]);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h3 className="flex items-center gap-2 font-medium">
{job.name}
<Badge variant={job.enabled ? "success" : "secondary"}>
{job.enabled ? "enabled" : "disabled"}
</Badge>
</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{job.schedule.kind}</Badge>
{job.agentId && <Badge variant="secondary">{job.agentId}</Badge>}
{job.state?.lastStatus && (
<Badge variant={
job.state.lastStatus === "ok" ? "success"
: job.state.lastStatus === "running" ? "outline"
: "destructive"
}>
{job.state.lastStatus === "running" ? "running..." : job.state.lastStatus}
</Badge>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isRunning}
onClick={async () => {
setRunning(true);
await onRun(job.id);
}}
>
{isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{isRunning ? "Running..." : "Run Now"}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setConfirmToggle(true)}
>
<Power className="h-3.5 w-3.5" /> {job.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="destructive" size="sm" className="gap-1" onClick={() => setConfirmDelete(true)}>
<Trash2 className="h-3.5 w-3.5" /> Delete
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-3xl space-y-6">
{/* Job Info */}
<div className="grid grid-cols-2 gap-4 rounded-md border p-4 text-sm">
<InfoRow label="Schedule" value={formatScheduleDetail(job)} />
{job.schedule.tz && <InfoRow label="Timezone" value={job.schedule.tz} />}
{job.state?.nextRunAtMs && (
<InfoRow label="Next Run" value={formatDate(new Date(job.state.nextRunAtMs))} />
)}
{job.state?.lastRunAtMs && (
<InfoRow label="Last Run" value={formatDate(new Date(job.state.lastRunAtMs))} />
)}
<InfoRow label="Created" value={formatDate(new Date(job.createdAtMs))} />
<InfoRow label="Updated" value={formatDate(new Date(job.updatedAtMs))} />
{job.deleteAfterRun && <InfoRow label="Auto-delete" value="Yes (one-time)" />}
</div>
{/* Payload */}
<div className="rounded-md border p-4 text-sm">
<h4 className="mb-2 font-medium">Payload</h4>
<div className="space-y-2">
<div className="rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap">
{job.payload?.message || "(empty)"}
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{job.payload?.deliver && <span>Deliver: direct</span>}
{job.payload?.channel && <span>Channel: {job.payload.channel}</span>}
{job.payload?.to && <span>To: {job.payload.to}</span>}
</div>
</div>
</div>
{/* Last Error */}
{job.state?.lastError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<h4 className="mb-1 font-medium text-destructive">Last Error</h4>
<p className="text-xs text-destructive/80">{job.state.lastError}</p>
</div>
)}
{/* Run History */}
<div>
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">Run History</h4>
<Button variant="ghost" size="sm" onClick={() => loadRunLog()} className="text-xs">
Refresh
</Button>
</div>
{runLogLoading && runLog.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : runLog.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No run history yet.</p>
) : (
<>
<div className="space-y-2">
{runLog.map((entry, i) => (
<div key={i} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{formatDate(new Date(entry.ts))}</span>
{entry.durationMs != null && entry.durationMs > 0 && (
<span className="text-xs text-muted-foreground">({formatDuration(entry.durationMs)})</span>
)}
</div>
<div className="flex items-center gap-2">
{(entry.inputTokens != null && entry.inputTokens > 0) && (
<span className="text-xs text-muted-foreground">
{formatTokens(entry.inputTokens)} in / {formatTokens(entry.outputTokens ?? 0)} out
</span>
)}
<Badge variant={entry.status === "ok" || entry.status === "success" ? "success" : "destructive"}>
{entry.status || "unknown"}
</Badge>
</div>
</div>
{entry.summary && (
<p className="mt-1 line-clamp-3 text-muted-foreground">{entry.summary}</p>
)}
{entry.error && (
<p className="mt-1 text-destructive">{entry.error}</p>
)}
</div>
))}
</div>
<Pagination
page={runLogPage}
pageSize={runLogPageSize}
total={runLogTotal}
totalPages={runLogTotalPages}
onPageChange={(p) => { setRunLogPage(p); loadRunLog(p); }}
onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }}
pageSizes={[10, 20, 50]}
/>
</>
)}
</div>
</div>
</div>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete Cron Job"
description={`Delete "${job.name}"? This cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
await onDelete(job.id);
setConfirmDelete(false);
}}
/>
<ConfirmDialog
open={confirmToggle}
onOpenChange={setConfirmToggle}
title={job.enabled ? "Disable Cron Job" : "Enable Cron Job"}
description={
job.enabled
? `Disable "${job.name}"? It will stop running until re-enabled.`
: `Enable "${job.name}"? It will start running on schedule.`
}
confirmLabel={job.enabled ? "Disable" : "Enable"}
variant={job.enabled ? "destructive" : "default"}
onConfirm={async () => {
await onToggle(job.id, !job.enabled);
setConfirmToggle(false);
}}
/>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground">{label}</span>
<div className="mt-0.5 font-medium">{value}</div>
</div>
);
}
+40 -7
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Clock, Plus, Play, Trash2, History, RefreshCw } from "lucide-react";
import { useParams, useNavigate } from "react-router";
import { Clock, Plus, Play, Trash2, History, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -11,6 +12,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useCron, type CronJob, type CronRunLogEntry } from "./hooks/use-cron";
import { CronFormDialog } from "./cron-form-dialog";
import { CronRunLogDialog } from "./cron-run-log-dialog";
import { CronDetailPage } from "./cron-detail-page";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
@@ -29,8 +31,10 @@ function formatSchedule(job: CronJob): string {
}
export function CronPage() {
const { jobs, loading, refresh, createJob, toggleJob, deleteJob, runJob, getRunLog } = useCron();
const spinning = useMinLoading(loading);
const { id: detailId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { jobs, loading, refreshing, refresh, createJob, toggleJob, deleteJob, runJob, getRunLog } = useCron();
const spinning = useMinLoading(refreshing);
const showSkeleton = useDeferredLoading(loading && jobs.length === 0);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null);
@@ -41,11 +45,29 @@ export function CronPage() {
const { pageItems, pagination, setPage, setPageSize } = usePagination(jobs);
const detailJob = detailId ? jobs.find((j) => j.id === detailId) : null;
if (detailJob) {
return (
<CronDetailPage
job={detailJob}
onBack={() => navigate("/cron")}
onRun={runJob}
onToggle={toggleJob}
onDelete={async (id) => {
await deleteJob(id);
navigate("/cron");
}}
getRunLog={getRunLog}
onRefresh={refresh}
/>
);
}
const handleShowRunLog = async (job: CronJob) => {
setRunLogTarget(job);
setRunLogLoading(true);
try {
const entries = await getRunLog(job.id);
const { entries } = await getRunLog(job.id);
setRunLogEntries(entries);
} finally {
setRunLogLoading(false);
@@ -105,7 +127,15 @@ export function CronPage() {
onCheckedChange={(checked: boolean) => setToggleTarget({ job, enabled: checked })}
/>
</td>
<td className="px-4 py-3 font-medium">{job.name}</td>
<td className="px-4 py-3">
<button
type="button"
className="cursor-pointer font-medium text-primary hover:underline"
onClick={() => navigate(`/cron/${job.id}`)}
>
{job.name}
</button>
</td>
<td className="px-4 py-3">
<Badge variant="outline">{formatSchedule(job)}</Badge>
</td>
@@ -124,10 +154,13 @@ export function CronPage() {
<Button
variant="ghost"
size="icon"
title="Run now"
title={job.state?.lastStatus === "running" ? "Running..." : "Run now"}
disabled={job.state?.lastStatus === "running"}
onClick={() => runJob(job.id)}
>
<Play className="h-3.5 w-3.5" />
{job.state?.lastStatus === "running"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Play className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
+10 -6
View File
@@ -46,6 +46,9 @@ export interface CronRunLogEntry {
status?: string;
error?: string;
summary?: string;
durationMs?: number;
inputTokens?: number;
outputTokens?: number;
}
export function useCron() {
@@ -53,7 +56,7 @@ export function useCron() {
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const { data: jobs = [], isPending: loading } = useQuery({
const { data: jobs = [], isPending: loading, isFetching: refreshing } = useQuery({
queryKey: queryKeys.cron.all,
queryFn: async () => {
const res = await ws.call<{ jobs: CronJob[] }>(Methods.CRON_LIST, {
@@ -133,16 +136,17 @@ export function useCron() {
);
const getRunLog = useCallback(
async (jobId: string, limit = 20): Promise<CronRunLogEntry[]> => {
if (!ws.isConnected) return [];
const res = await ws.call<{ entries: CronRunLogEntry[] }>(Methods.CRON_RUNS, {
async (jobId: string, limit = 20, offset = 0): Promise<{ entries: CronRunLogEntry[]; total: number }> => {
if (!ws.isConnected) return { entries: [], total: 0 };
const res = await ws.call<{ entries: CronRunLogEntry[]; total: number }>(Methods.CRON_RUNS, {
jobId,
limit,
offset,
});
return res.entries ?? [];
return { entries: res.entries ?? [], total: res.total ?? 0 };
},
[ws],
);
return { jobs, loading, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog };
return { jobs, loading, refreshing, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog };
}
+5 -4
View File
@@ -39,13 +39,14 @@ export function CronJobsCard({ jobs }: { jobs: CronJob[] }) {
: "bg-muted-foreground/40"
}`}
/>
<span
className={
<Link
to={`/cron/${job.id}`}
className={`hover:underline ${
job.enabled ? "" : "text-muted-foreground"
}
}`}
>
{job.name}
</span>
</Link>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{job.enabled && job.state.nextRunAtMs ? (
+1
View File
@@ -126,6 +126,7 @@ export function AppRoutes() {
<Route path={ROUTES.SKILLS} element={<SkillsPage key="list" />} />
<Route path={ROUTES.SKILL_DETAIL} element={<SkillsPage key="detail" />} />
<Route path={ROUTES.CRON} element={<CronPage />} />
<Route path={ROUTES.CRON_DETAIL} element={<CronPage />} />
<Route path={ROUTES.CONFIG} element={<ConfigPage />} />
<Route path={ROUTES.TRACES} element={<TracesPage key="list" />} />
<Route path={ROUTES.TRACE_DETAIL} element={<TracesPage key="detail" />} />