mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
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:
1 parent
08ca343972
commit
3c03d1f482
12 files changed
+463
-58
No files matched your search
+14
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¶ms)
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />} />
|
||||
|
||||
Reference in new issue
Block a user