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,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
@@ -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")
|
||||
|
||||
@@ -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