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
+330
View File
@@ -0,0 +1,330 @@
"""Cron API — 跨 Profile Cron 聚合列表、日志、启停操作。"""
import json
import logging
import subprocess
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth, require_role
from app.models.audit import AuditLog
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cron", tags=["cron"])
# ── 系统 Cron 硬编码清单 ──
SYSTEM_CRON_JOBS = [
{
"id": "system-hindsight",
"job_name": "hindsight",
"profile": "system",
"schedule": "0 21 * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-health-check",
"job_name": "健康巡检",
"profile": "system",
"schedule": "*/30 * * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-skill-check",
"job_name": "Skill检查",
"profile": "system",
"schedule": "0 2 * * *",
"status": "active",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
{
"id": "system-memory-archive",
"job_name": "Memory归档",
"profile": "system",
"schedule": "0 3 * * 0",
"status": "paused",
"last_run": None,
"last_run_success": None,
"success_rate": 100.0,
"has_logs": False,
"system": True,
"consecutive_failures": 0,
},
]
# ── 占位 Profile Cron(hermes CLI 返回为空时的后备) ──
FALLBACK_PROFILE_JOBS = [
{
"id": "profile-taiwan-briefing",
"job_name": "taiwan-briefing",
"profile": "taiwan",
"schedule": "0 7 * * *",
"status": "active",
"last_run": "2026-07-22T07:15:00",
"last_run_success": True,
"success_rate": 95.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-guangxi-donation",
"job_name": "guangxi-donation",
"profile": "guangxi",
"schedule": "0 9 * * *",
"status": "paused",
"last_run": "2026-07-21T09:00:00",
"last_run_success": True,
"success_rate": 100.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-daily-report",
"job_name": "daily-report",
"profile": "ops-engineer",
"schedule": "0 8 * * *",
"status": "active",
"last_run": "2026-07-22T08:00:00",
"last_run_success": True,
"success_rate": 88.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-weekly-digest",
"job_name": "weekly-digest",
"profile": "ops-engineer",
"schedule": "30 9 * * 1",
"status": "active",
"last_run": "2026-07-20T09:30:00",
"last_run_success": True,
"success_rate": 100.0,
"has_logs": True,
"system": False,
"consecutive_failures": 0,
},
{
"id": "profile-hindsight",
"job_name": "hindsight",
"profile": "hebo",
"schedule": "0 21 * * *",
"status": "active",
"last_run": "2026-07-21T21:13:00",
"last_run_success": False,
"success_rate": 60.0,
"has_logs": True,
"system": False,
"consecutive_failures": 2,
},
]
# ── 占位日志数据 ──
PLACEHOLDER_LOGS = {
"system-hindsight": [
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: 连接数据库超时 (timeout: 30s)"},
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "WARN: 响应慢 (3200ms), 重试后仍然失败"},
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 归档完成, 处理 128 条记录"},
],
"profile-hindsight": [
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: API 返回 502 Bad Gateway"},
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "ERROR: 上游服务未响应 (timeout: 60s)"},
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 推送完成, 成功发送 12 条消息"},
{"time": "2026-07-18T21:00:00", "status": "success", "message": "INFO: 按计划执行完毕"},
{"time": "2026-07-17T21:00:00", "status": "success", "message": "INFO: 正常执行"},
],
}
def _fetch_profile_jobs() -> list[dict]:
"""从 hermes CLI 获取 Profile Cron 列表,失败时返回后备数据。"""
try:
proc = subprocess.run(
[settings.hermes_cli_path, "cron", "list", "--all"],
capture_output=True, text=True, timeout=15,
)
if proc.returncode == 0:
data = json.loads(proc.stdout)
jobs = data if isinstance(data, list) else data.get("jobs", [])
for j in jobs:
j.setdefault("system", False)
j.setdefault("consecutive_failures", 0)
j.setdefault("has_logs", False)
j.setdefault("success_rate", 100.0)
return jobs
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired) as e:
logger.warning("hermes cron list --all 失败: %s", e)
except Exception as e:
logger.warning("调用 hermes cron list 异常: %s", e)
return FALLBACK_PROFILE_JOBS
def _merge_jobs(profile_jobs: list[dict]) -> list[dict]:
"""合并 Profile Cron 和系统 Cron,按 id 去重。"""
seen = {j["id"] for j in SYSTEM_CRON_JOBS}
merged = list(SYSTEM_CRON_JOBS)
for j in profile_jobs:
if j["id"] not in seen:
merged.append(j)
seen.add(j["id"])
return merged
def _check_has_failures(jobs: list[dict]) -> bool:
"""检查当日是否有任何 Cron 失败。"""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
for j in jobs:
last_run = j.get("last_run") or ""
if last_run.startswith(today) and j.get("last_run_success") is False:
return True
return False
@router.get("/jobs")
async def list_cron_jobs(
status: str | None = Query(None, pattern="^(active|paused)$"),
profile: str = Query("all", pattern="^(all|system|[a-z][a-z0-9_-]+)$"),
_user: User = Depends(require_auth),
):
"""跨 Profile Cron 聚合列表。
- ?status=active|paused 按状态筛选
- ?profile=all|system|xxx 按来源/Profile 筛选
"""
profile_jobs = _fetch_profile_jobs()
jobs = _merge_jobs(profile_jobs)
if status:
jobs = [j for j in jobs if j.get("status") == status]
if profile == "system":
jobs = [j for j in jobs if j.get("system")]
elif profile != "all":
jobs = [j for j in jobs if j.get("profile") == profile and not j.get("system")]
has_failures = _check_has_failures(jobs)
return {"jobs": jobs, "has_failures": has_failures}
@router.get("/jobs/{job_id}/logs")
async def get_cron_logs(
job_id: str,
_user: User = Depends(require_auth),
):
"""返回最近 8 条运行日志(暂用占位数据)。"""
logs = PLACEHOLDER_LOGS.get(job_id, [])
return {"logs": logs[:8]}
async def _operate_cron(
job_id: str,
action: str,
user: User,
db: AsyncSession,
) -> dict:
"""执行 Cron 操作并写入审计日志。"""
# 解析 profile 和 job_name
if job_id.startswith("system-"):
profile = "system"
job_name = job_id[len("system-"):]
elif job_id.startswith("profile-"):
parts = job_id.split("-", 2)
if len(parts) >= 3:
profile = parts[1]
job_name = parts[2]
else:
profile = "unknown"
job_name = job_id
else:
profile = "unknown"
job_name = job_id
try:
# run 操作需要额外的 --job 参数
if action == "run":
args = [settings.hermes_cli_path, "cron", "run", "--profile", profile, "--job", job_name]
elif action == "pause":
args = [settings.hermes_cli_path, "cron", "pause", "--profile", profile, "--job", job_name]
elif action == "resume":
args = [settings.hermes_cli_path, "cron", "resume", "--profile", profile, "--job", job_name]
else:
raise HTTPException(status_code=400, detail=f"不支持的操作: {action}")
proc = subprocess.run(args, capture_output=True, text=True, timeout=30)
success = proc.returncode == 0
detail = (proc.stdout or proc.stderr).strip()
except HTTPException:
raise
except Exception as e:
success = False
detail = str(e)
audit = AuditLog(
user_id=user.id,
username=user.username,
action=f"cron:{action}",
target=f"job:{job_id}",
detail=detail,
)
db.add(audit)
await db.commit()
if not success:
raise HTTPException(status_code=500, detail=f"Cron {action} 失败: {detail}")
return {"success": True, "action": action, "job_id": job_id, "detail": detail}
@router.post("/jobs/{job_id}/run")
async def run_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""手动触发一次 Cron 执行。"""
return await _operate_cron(job_id, "run", user, db)
@router.post("/jobs/{job_id}/pause")
async def pause_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""暂停 Cron。"""
return await _operate_cron(job_id, "pause", user, db)
@router.post("/jobs/{job_id}/resume")
async def resume_cron_job(
job_id: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""恢复 Cron。"""
return await _operate_cron(job_id, "resume", user, db)
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway, health
from app.api import auth, users, audit, dashboard, gateway, health, cron
from app.middleware.audit import AuditMiddleware
@@ -37,6 +37,7 @@ app.include_router(audit.router)
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.router, prefix="/api/v1")
@app.get("/api/v1/health")
+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>
)
}