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:
@@ -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`)
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user