feat: init sproutclaw-cron 定时任务管理框架
- 每任务一目录结构,统一开关与日志 - cronctl CLI:enable/disable/toggle/status/run/sync-cron - 公共库 lib/shumengya_cron:runner/manager/notify/ssh - _template 示例任务(hello world + 复制模板) - webui 管理面板:FastAPI 后端 + 文档式响应式前端
This commit is contained in:
161
webui/backend/main.py
Normal file
161
webui/backend/main.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""SproutClaw Cron 定时任务管理面板 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
CRON_ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
sys.path.insert(0, str(CRON_ROOT / "lib"))
|
||||
|
||||
from shumengya_cron.manager import ( # noqa: E402
|
||||
build_task_info,
|
||||
list_tasks,
|
||||
read_log_tail,
|
||||
set_task_state,
|
||||
sync_cron_d,
|
||||
toggle_task,
|
||||
)
|
||||
from shumengya_cron.manager import get_context # noqa: E402
|
||||
from shumengya_cron.runner import task_is_enabled # noqa: E402
|
||||
|
||||
app = FastAPI(title="SproutClaw Cron", version="1.0.0")
|
||||
|
||||
_run_jobs: dict[str, subprocess.Popen[bytes]] = {}
|
||||
_run_lock = threading.Lock()
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
pid: int | None = None
|
||||
|
||||
|
||||
def _task_not_found(exc: FileNotFoundError) -> HTTPException:
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
def _cleanup_run_job(task_id: str, proc: subprocess.Popen[bytes]) -> None:
|
||||
proc.wait()
|
||||
with _run_lock:
|
||||
current = _run_jobs.get(task_id)
|
||||
if current is proc:
|
||||
_run_jobs.pop(task_id, None)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def api_list_tasks() -> list[dict[str, object]]:
|
||||
return [task.to_dict() for task in list_tasks(CRON_ROOT)]
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
def api_get_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
return build_task_info(task_id, CRON_ROOT).to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/enable")
|
||||
def api_enable_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = set_task_state(task_id, True, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/disable")
|
||||
def api_disable_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = set_task_state(task_id, False, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/toggle")
|
||||
def api_toggle_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = toggle_task(task_id, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/sync-cron", response_model=MessageResponse)
|
||||
def api_sync_cron(task_id: str) -> MessageResponse:
|
||||
try:
|
||||
ctx = get_context(task_id, CRON_ROOT)
|
||||
message = sync_cron_d(ctx, task_is_enabled(ctx)) or "已同步"
|
||||
return MessageResponse(message=message)
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run", response_model=RunResponse)
|
||||
def api_run_task(task_id: str) -> RunResponse:
|
||||
try:
|
||||
get_context(task_id, CRON_ROOT)
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
with _run_lock:
|
||||
existing = _run_jobs.get(task_id)
|
||||
if existing is not None and existing.poll() is None:
|
||||
return RunResponse(
|
||||
ok=False,
|
||||
message="任务已在后台运行",
|
||||
pid=existing.pid,
|
||||
)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(CRON_ROOT / "cronctl.py"), "run", task_id],
|
||||
cwd=str(CRON_ROOT),
|
||||
)
|
||||
_run_jobs[task_id] = proc
|
||||
threading.Thread(
|
||||
target=_cleanup_run_job,
|
||||
args=(task_id, proc),
|
||||
daemon=True,
|
||||
).start()
|
||||
return RunResponse(message="已在后台启动", pid=proc.pid)
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/log")
|
||||
def api_task_log(
|
||||
task_id: str,
|
||||
lines: int = Query(default=200, ge=1, le=2000),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
content = read_log_tail(task_id, lines, CRON_ROOT)
|
||||
return {"task_id": task_id, "content": content}
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
2
webui/backend/requirements.txt
Normal file
2
webui/backend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
Reference in New Issue
Block a user