feat: Cron management API and frontend page (Phase 5)

Backend: CRON API with list/logs/run/pause/resume endpoints, Hermes CLI
integration, system cron jobs, audit logging for operations.
Frontend: CronPage with filter bar (status/profile), table with 30s polling,
log modal with level-colored messages, action buttons (run/pause/resume),
run confirmation dialog, 4-state coverage. Sidebar shows cron failure badge.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:19:11 +08:00
co-authored by Claude
parent 8bfefd199c
commit 670d222665
5 changed files with 866 additions and 5 deletions
+52
View File
@@ -0,0 +1,52 @@
import client from './client'
export interface CronJob {
id: string
job_name: string
profile: string
schedule: string
status: string
last_run: string | null
last_run_success: boolean | null
success_rate: number
has_logs: boolean
system: boolean
consecutive_failures: number
}
export interface CronListResponse {
jobs: CronJob[]
has_failures: boolean
}
export interface CronLogItem {
time: string
status: string
message: string
}
export interface CronLogsResponse {
logs: CronLogItem[]
}
export interface CronActionResponse {
success: boolean
action: string
job_id: string
detail: string
}
export const getCronJobs = (params?: { status?: string; profile?: string }) =>
client.get<CronListResponse>('/cron/jobs', { params })
export const getCronLogs = (jobId: string) =>
client.get<CronLogsResponse>(`/cron/jobs/${jobId}/logs`)
export const runCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/run`)
export const pauseCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/pause`)
export const resumeCronJob = (jobId: string) =>
client.post<CronActionResponse>(`/cron/jobs/${jobId}/resume`)
+18 -2
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../../hooks/useAuth'
import Badge from '../ui/Badge'
import { getDashboardSummary } from '../../api/dashboard'
interface NavItem {
label: string
@@ -77,6 +79,19 @@ function hasAccess(userRole: string | undefined, requiredRole?: string): boolean
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
const { user } = useAuth()
const [cronFailures, setCronFailures] = useState(0)
useEffect(() => {
const fetch = async () => {
try {
const res = await getDashboardSummary()
setCronFailures(res.data.cron_failures_today)
} catch { /* ignore */ }
}
fetch()
const id = setInterval(fetch, 60_000)
return () => clearInterval(id)
}, [])
return (
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
@@ -108,6 +123,7 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
</div>
{visible.map(item => {
const isActive = activePath === item.path
const badge = item.path === '/cron' ? cronFailures : item.badge
return (
<div
key={item.path}
@@ -129,8 +145,8 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
<span style={{ marginRight: 8 }}>{item.icon}</span>
{item.label}
</span>
{item.badge != null && item.badge > 0 && (
<Badge variant="danger">{item.badge}</Badge>
{badge != null && badge > 0 && (
<Badge variant="danger">{badge}</Badge>
)}
</div>
)
+464 -2
View File
@@ -1,3 +1,465 @@
export default function CronPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Cron</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Cron 任务管理</p></div>
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useAuth } from '../hooks/useAuth'
import Table, { type TableColumn } from '../components/ui/Table'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import Skeleton from '../components/ui/Skeleton'
import toast from '../components/ui/Toast'
import {
getCronJobs,
getCronLogs,
runCronJob,
pauseCronJob,
resumeCronJob,
type CronJob,
type CronLogItem,
} from '../api/cron'
/* ---------- helpers ---------- */
const isToday = (iso: string | null) => {
if (!iso) return false
return iso.startsWith(new Date().toISOString().slice(0, 10))
}
const canAct = (role: string | undefined) => role === 'owner' || role === 'operator'
/* ---------- component ---------- */
export default function CronPage() {
const { user } = useAuth()
/* data */
const [jobs, setJobs] = useState<CronJob[]>([])
const [hasFailures, setHasFailures] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [statusFilter, setStatusFilter] = useState<string>('all')
const [profileFilter, setProfileFilter] = useState<string>('all')
/* log modal */
const [logJob, setLogJob] = useState<CronJob | null>(null)
const [logs, setLogs] = useState<CronLogItem[]>([])
const [logsLoading, setLogsLoading] = useState(false)
/* confirm modal */
const [confirmJob, setConfirmJob] = useState<CronJob | null>(null)
const [confirmAction, setConfirmAction] = useState<string>('')
const [acting, setActing] = useState<string | null>(null)
/* ---------- fetch ---------- */
const fetchJobs = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (statusFilter !== 'all') params.status = statusFilter
if (profileFilter !== 'all') params.profile = profileFilter
const res = await getCronJobs(params)
setJobs(res.data.jobs)
setHasFailures(res.data.has_failures)
setError(null)
} catch {
setError('加载 Cron 列表失败')
} finally {
setLoading(false)
}
}, [statusFilter, profileFilter])
useEffect(() => {
setLoading(true)
fetchJobs()
const id = setInterval(fetchJobs, 30_000)
return () => clearInterval(id)
}, [fetchJobs])
/* ---------- log modal ---------- */
const openLog = async (job: CronJob) => {
setLogJob(job)
setLogsLoading(true)
try {
const res = await getCronLogs(job.id)
setLogs(res.data.logs)
} catch {
setLogs([])
toast.error('加载日志失败')
} finally {
setLogsLoading(false)
}
}
/* ---------- actions ---------- */
const handleRun = (job: CronJob) => {
setConfirmJob(job)
setConfirmAction('run')
}
const handlePause = async (job: CronJob) => {
setActing(job.id)
try {
await pauseCronJob(job.id)
toast.success(`已暂停 ${job.job_name}`)
fetchJobs()
} catch {
toast.error(`暂停 ${job.job_name} 失败`)
} finally {
setActing(null)
}
}
const handleResume = async (job: CronJob) => {
setActing(job.id)
try {
await resumeCronJob(job.id)
toast.success(`已恢复 ${job.job_name}`)
fetchJobs()
} catch {
toast.error(`恢复 ${job.job_name} 失败`)
} finally {
setActing(null)
}
}
const confirmRun = async () => {
if (!confirmJob) return
setActing(confirmJob.id)
setConfirmJob(null)
try {
await runCronJob(confirmJob.id)
toast.success(`已触发 ${confirmJob.job_name}`)
fetchJobs()
} catch {
toast.error(`触发 ${confirmJob.job_name} 失败`)
} finally {
setActing(null)
setConfirmAction('')
}
}
/* ---------- profile options ---------- */
const profileOptions = useMemo(() => {
const seen = new Set<string>()
const list: { value: string; label: string }[] = []
for (const j of jobs) {
const p = j.system ? 'system' : j.profile
if (!seen.has(p)) {
seen.add(p)
list.push({ value: p, label: j.system ? '系统 Cron' : p })
}
}
list.sort((a, b) => a.label.localeCompare(b.label))
return list
}, [jobs])
/* ---------- derived ---------- */
const filteredJobs = useMemo(() => {
if (statusFilter !== 'today-fail') return jobs
return jobs.filter((j) => isToday(j.last_run) && j.last_run_success === false)
}, [jobs, statusFilter])
/* ---------- render: loading ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
<Skeleton variant="card" count={4} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
<div
className="flex flex-col items-center justify-center py-12 gap-3"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchJobs}>
重试
</Button>
</div>
</div>
)
}
/* ---------- render ---------- */
const columns: TableColumn[] = [
{ key: 'job_name', label: 'Job 名' },
{ key: 'profile', label: 'Profile' },
{ key: 'schedule', label: 'Cron 表达式' },
{ key: 'status', label: '状态' },
{ key: 'last_run', label: '上次运行' },
{ key: 'success_rate', label: '成功率' },
{ key: 'actions', label: '操作' },
]
const dangerKeys = useMemo(() => {
const s = new Set<string>()
for (const j of filteredJobs) {
if (j.consecutive_failures >= 2) s.add(j.id)
}
return s
}, [filteredJobs])
const rowClassName = (record: Record<string, unknown>) => {
if (dangerKeys.has(record.key as string)) return 'row-danger'
return ''
}
const tableData = filteredJobs.map((j) => {
const lastRunStr = j.last_run
? (() => {
const d = new Date(j.last_run)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
})()
: '-'
const successPct = `${j.success_rate}%`
const successRateStyle =
j.success_rate < 80 ? { color: 'var(--yellow)', fontWeight: 600 } : undefined
const rowBg = j.system ? { background: 'var(--bg-hover)' } : undefined
return {
key: j.id,
job_name: (
<span className="flex items-center gap-2" style={rowBg}>
{j.job_name}
{j.system && (
<Badge variant="outline" className="!text-[10px]">system</Badge>
)}
</span>
),
profile: (
<span style={rowBg}>
{j.system ? (
<Badge variant="info" className="!text-[10px]">系统 Cron</Badge>
) : (
j.profile
)}
</span>
),
schedule: (
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, ...rowBg }}>
{j.schedule}
</code>
),
status: (
<span style={rowBg}>
<Badge variant={j.status === 'active' ? 'success' : 'warning'}>
{j.status === 'active' ? '活跃' : '暂停'}
</Badge>
</span>
),
last_run: (
<span className="flex items-center gap-1" style={rowBg}>
{lastRunStr}
{j.last_run_success === true && <span style={{ fontSize: 12 }}>✅</span>}
{j.last_run_success === false && (
<span style={{ fontSize: 12, color: 'var(--red)' }}>❌</span>
)}
</span>
),
success_rate: (
<span style={{ ...successRateStyle, ...rowBg }}>{successPct}</span>
),
actions: (
<span className="flex items-center gap-1" style={rowBg}>
{canAct(user?.role) && (
<>
{j.status === 'active' ? (
<Button
size="sm"
variant="outline"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handlePause(j) }}
>
暂停
</Button>
) : (
<Button
size="sm"
variant="primary"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handleResume(j) }}
>
恢复
</Button>
)}
<Button
size="sm"
variant="ghost"
loading={acting === j.id}
onClick={(e) => { e.stopPropagation(); handleRun(j) }}
>
运行
</Button>
</>
)}
{j.has_logs && (
<Button
size="sm"
variant="ghost"
onClick={(e) => { e.stopPropagation(); openLog(j) }}
>
📋 日志
</Button>
)}
</span>
),
}
})
const statusTabs = [
{ value: 'all', label: '全部' },
{ value: 'active', label: '活跃' },
{ value: 'paused', label: '暂停' },
{ value: 'today-fail', label: '今日失败' },
]
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
Cron 任务管理
</h2>
{/* Filter bar */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{statusTabs.map((t) => (
<button
key={t.value}
onClick={() => setStatusFilter(t.value)}
className="cursor-pointer bg-transparent border-none text-[13px] font-medium px-3 py-1.5 rounded-[var(--radius-sm)] transition-colors"
style={{
color: statusFilter === t.value ? 'var(--honey)' : 'var(--text-dim)',
background: statusFilter === t.value ? 'var(--honey-glow)' : undefined,
}}
>
{t.label}
</button>
))}
<div className="w-[1px] h-5 bg-[var(--border)] mx-1" />
<select
value={profileFilter}
onChange={(e) => setProfileFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
<option value="all">全部 Profile</option>
{profileOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
<option value="system">仅系统 Cron</option>
</select>
</div>
{/* Table */}
{tableData.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-12"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, marginBottom: 8 }}>📭</span>
<p>暂无 Cron 任务</p>
</div>
) : (
<Table
columns={columns}
data={tableData}
rowKey="key"
rowClassName={rowClassName}
/>
)}
{/* Log modal */}
<Modal
open={!!logJob}
onClose={() => setLogJob(null)}
title={logJob ? `${logJob.job_name} — 最近运行日志` : ''}
width={640}
>
{logsLoading ? (
<Skeleton variant="card" count={3} />
) : logs.length === 0 ? (
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: '24px 0' }}>
暂无日志
</p>
) : (
<div className="space-y-2">
{logs.map((log, i) => {
const isErr = log.status === 'failure'
const levelColor = (msg: string) => {
if (msg.startsWith('ERROR')) return 'var(--red)'
if (msg.startsWith('WARN')) return 'var(--yellow)'
return 'var(--green)'
}
return (
<div
key={i}
className="p-3 rounded-[var(--radius-sm)] text-[13px] leading-relaxed"
style={{
background: isErr ? 'var(--bg-error)' : 'var(--bg-card)',
borderLeft: `3px solid ${isErr ? 'var(--red)' : 'var(--border)'}`,
}}
>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}>
{log.time}
</span>
<span>{log.status === 'success' ? '✅' : '❌'}</span>
</div>
<div style={{ color: levelColor(log.message), whiteSpace: 'pre-wrap' }}>
{log.message}
</div>
</div>
)
})}
</div>
)}
</Modal>
{/* Run confirmation modal */}
<Modal
open={!!confirmJob}
onClose={() => { setConfirmJob(null); setConfirmAction('') }}
title="确认执行"
confirmText="确认运行"
confirmDanger
onConfirm={confirmRun}
>
<p style={{ color: 'var(--text-dim)' }}>
确认要手动触发 <strong style={{ color: 'var(--text)' }}>{confirmJob?.job_name}</strong> 执行吗?
</p>
{confirmJob && (
<div className="mt-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
<div>Profile: {confirmJob.system ? '系统 Cron' : confirmJob.profile}</div>
<div>Cron 表达式: <code style={{ fontFamily: 'var(--font-mono)' }}>{confirmJob.schedule}</code></div>
</div>
)}
</Modal>
</div>
)
}