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:
2026-06-24 16:19:53 +08:00
commit 6c2db2dfa3
23 changed files with 2667 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# 虚拟环境
.venv/
# 运行时产物
logs/
*.lock
*.log
# 禁用任务目录(含本机专属脚本/凭据,不上传)
.disabled/
# 本地任务运行产物
smallmengya-gitea-repo-sync/__pycache__/
smallmengya-gitea-repo-sync/logs/
smallmengya-gitea-repo-sync/*.lock
# 编辑器 / IDE / 工具
.claude/
.idea/
.vscode/
*.swp
.DS_Store

49
AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# sproutclaw-cron 开发规则
## 新建定时任务
**必须**从 `_template/` 复制(`_template` 本身是可运行的 hello world 示例任务)。
```bash
TASK_ID="<主机名>-<功能描述>"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
```
然后:
1. 修改 `run.py`:保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构,替换 `hello world` 为实际逻辑
2. 修改 `schedule.cron`:调整 cron 表达式与注释说明
3. 可选:添加任务专属 JSON 配置(如 `targets.json`
4.`python3 cronctl.py run <task-id>` 试跑,确认日志正常
5. 默认保持关闭;用户要求启用时再 `cronctl enable <task-id>`
## 不要修改
- `_template/` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为
- 不要删除或移动已有任务的 `schedule.cron` 里对 `cronctl.py run` 的调用方式
## 任务目录约定
```
<task-id>/
├── run.py
├── schedule.cron
├── switch.sh # 可选
├── logs/<task-id>.log # 运行时自动创建
└── *.json # 可选配置
```
禁用任务位于 `.disabled/<task-id>/`,开关用 `cronctl enable|disable|toggle`
## 管理命令
```bash
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <task-id>
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable <task-id>
```
WebUI`/shumengya/project/agent/sproutclaw-cron/webui/start.sh`

255
README.md Normal file
View File

@@ -0,0 +1,255 @@
# sproutclaw-cron — 定时任务系统
树萌芽的定时任务管理框架。每个任务一目录,共享公共库,统一管理开关与日志。
---
## 目录结构
```
/shumengya/project/agent/sproutclaw-cron/
├── cronctl.py # 统一管理 CLIenable/disable/toggle/status
├── AGENTS.md # AI 新建任务规范(必读)
├── _template/ # 示例任务 + 复制模板(每天 08:00 hello world
├── lib/
│ └── shumengya_cron/
│ ├── runner.py # TaskContext、日志轮转、flock 互斥锁
│ ├── notify.py # 飞书 Markdown 通知 + 邮件降级
│ └── ssh.py # 远端 SSH 辅助bash -lc
├── <task-id>/ # 任务目录(启用状态)
│ ├── run.py # 任务入口(唯一必须文件)
│ ├── schedule.cron # 系统 cron 配置(复制到 /etc/cron.d/
│ ├── switch.sh # 一键开关(可用 cronctl 替代)
│ ├── targets.json # 可选任务自定义配置JSON
│ ├── logs/<task-id>.log # 运行日志(自动创建)
│ └── <task-id>.lock # flock 互斥锁文件(自动创建)
└── .disabled/ # 禁用任务统一存放目录
└── <task-id>/ # 关闭的任务移入此处
```
**任务 ID 命名约定**`<主机名>-<功能描述>`,例如 `bigmengya-docker-image-update`
---
## 已有任务
| 任务 ID | 状态 | 说明 |
|---|---|---|
| `_template` | 开启 | 示例任务:每天 08:00 输出 hello world也是新任务复制模板 |
| `bigmengya-docker-container-restart` | disabled | SSH 重启 bigmengya 上的数据库类容器 |
| `bigmengya-docker-image-update` | disabled | SSH 拉取并重建 bigmengya 上的 compose 服务 |
| `smallmengya-ai-cli-update` | disabled | 更新本机 codex / claude / opencode |
| `smallmengya-ai-memory-export` | disabled | 导出 AI 记忆数据 |
| `smallmengya-gitea-repo-sync` | 开启 | 同步 Gitea 仓库到本地 |
---
## 快速上手
### 查看状态
```bash
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status
```
### 开关任务
```bash
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable <任务名>
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py disable <任务名>
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py toggle <任务名>
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable all # 全部开启
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py disable all # 全部关闭
```
### 手动运行任务
```bash
# 推荐:通过 cronctl自动识别 .disabled/ 下的任务)
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <任务名>
# 也可直接运行(启用或 .disabled/<任务名> 目录均可)
python3 /shumengya/project/agent/sproutclaw-cron/.disabled/<任务名>/run.py
```
### 安装到系统 cron
`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/<task-id>``disable` **只**把任务目录移入 `.disabled/<task-id>/`**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但 `run.py` 检测到禁用后会直接跳过。
```bash
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py sync-cron all # 仅同步 cron.d不改开关
```
---
## 开关机制
- **启用**:从 `.disabled/<task-id>/` 移回 `<task-id>/`
- **关闭**:从 `<task-id>/` 移入 `.disabled/<task-id>/`
- `run.py` 在入口检查 `task_is_disabled(ctx)`,若关闭直接 `return 0`,不写日志、不发通知、不获取锁
- `/etc/cron.d/<task-id>` 可一直保留;`schedule.cron` 通过 `cronctl.py run <task-id>` 调度
---
## 新建任务(模板)
**从 `_template/` 复制**`_template` 本身是可管理的示例任务(每天 08:00 输出 hello world
```bash
TASK_ID="smallmengya-my-new-task"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
# 编辑 run.py 与 schedule.cron 后试跑
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
```
详细约定见 `_template/README.md`AI 代理见根目录 `AGENTS.md`
---
## 公共库 API
### `runner.py`
| 接口 | 说明 |
|---|---|
| `TaskContext.from_task_id(task_id, *, log_mode, cron_root)` | 构造任务上下文(自动识别 `.disabled/<task-id>/` |
| `task_is_disabled(ctx)` | 任务目录位于 `.disabled/` 下则返回 True |
| `task_is_enabled(ctx)` | 同上取反 |
| `set_task_enabled(ctx, enabled)` | 重命名目录切换状态 |
| `task_logging(ctx)` | 上下文管理器,返回 `log(msg)` 函数轮转日志、tee 到终端 |
| `acquire_cron_lock(lock_file, *, log)` | flock 互斥锁,已占用时 yield False |
| `cron_log_rotate(log_file, max_bytes)` | 超过阈值时轮转日志(默认 10MB |
| `append_raw_to_log(log_file, text)` | 将命令原始输出追加到日志 |
| `task_output_prefix(task_id)` | 返回 `[smallmengya][AI-CLI-Update]` 格式前缀 |
| `LogMode.REDIRECT_STD` | stdout/stderr 重定向到日志(默认) |
| `LogMode.DIRECT_FILE` | 仅写文件,不重定向标准流 |
### `notify.py`
| 接口 | 说明 |
|---|---|
| `TaskResult` | 任务结果容器,`add_ok/add_skip/add_fail(item)` 累积条目,`.success` / `.status_cn` / `.total` 供汇总使用 |
| `send_task_summary(ctx, result, start_time, *, log, extra_fields)` | 发送标准 ok/skip/fail 汇总通知(飞书 + 邮件降级) |
| `send_feishu_markdown(title, markdown, *, log)` | 发飞书通知;失败自动降级邮件 |
| `markdown_list(items)` | `["a","b"]``"- a\n- b"` |
| `markdown_fields(items)` | `[("key","val")]``"- **key**val\n..."` |
### `ssh.py`
| 接口 | 说明 |
|---|---|
| `ssh_bash_lc(host, remote_cmd)` | 用 `bash -lc` 执行远程命令PATH 与登录 shell 一致 |
---
## 环境变量
### 公共(所有任务)
| 变量 | 默认 | 说明 |
|---|---|---|
| `CRON_LOG_MAX_BYTES` | `10485760`10MB| 日志轮转阈值 |
| `CRON_LOCK_BUSY_MSG` | 已有任务在运行… | 锁冲突时的日志消息 |
| `LARK_NOTICE_WEBHOOK` | 内置默认 webhook | 飞书机器人 webhook |
| `LARK_NOTICE_API_SRC` | `/shumengya/project/python/lark-notice-api/src` | lark-notice-api 源码路径 |
| `CRON_MAIL_ENABLED` | `0` | 设为 `1` 开启邮件降级通知 |
| `MAIL_API_SCRIPT` | mengya-mail-api 路径 | 邮件发送脚本路径 |
| `MAIL_TO` | `mail@smyhub.com` | 收件人 |
### bigmengya-docker-container-restart
| 变量 | 默认 | 说明 |
|---|---|---|
| `REMOTE_HOST` | `bigmengya` | SSH 目标主机 |
| `REMOTE_DOCKER_BIN` | `docker` | 远端 docker 命令路径 |
| `DB_CONTAINERS` | mysql-8 redis-7 … | 目标容器名(空格分隔) |
| `DB_PATTERNS` | mysql redis mongo … | 镜像名匹配模式(空格分隔) |
### bigmengya-docker-image-update
| 变量 | 默认 | 说明 |
|---|---|---|
| `REMOTE_HOST` | `bigmengya` | SSH 目标主机 |
| `PULL_RETRIES` | `3` | pull 失败重试次数 |
| `PULL_RETRY_DELAY_SECONDS` | `15` | 重试间隔秒数 |
### smallmengya-ai-cli-update
| 变量 | 默认 | 说明 |
|---|---|---|
| `CRON_TASK_PATH` | `/root/bin:/root/.opencode/bin:…` | 查找 CLI 工具的 PATH |
| `UPDATE_TIMEOUT_SECONDS` | `500` | 单个工具更新超时秒数 |
| `CLI_TARGETS` | 全部 | 逗号分隔,限制只更新指定工具 |
| `HTTP_PROXY` / `HTTPS_PROXY` | `socks5://192.168.1.1:7891` | 代理地址 |
| `NPM_REGISTRY_URL` | `https://registry.npmmirror.com` | npm 镜像 |
### smallmengya-gitea-repo-sync
| 变量 | 默认 | 说明 |
|---|---|---|
| `BASE_DIR` | `/shumengya/project/cloudflare` | 本地仓库基础目录 |
| `SYNC_DELAY_SECONDS` | `2` | 每个仓库同步间隔秒数 |
| `GIT_SSH_COMMAND` | BatchMode=yes … | git SSH 参数 |
---
## 剩余可优化方向
以下问题有优化价值,但影响面较小,暂未实施:
### 1. lib 路径注入样板
每个 `run.py` 顶部都有 3 行 `sys.path.insert` 代码,是为了支持 `schedule.cron` 直接调用 `python3 run.py`
若将 `schedule.cron` 改为调用 `cronctl run <task-id>`,则 `run.py` 里的路径注入可以全部删掉。
### 2. `switch.sh` 冗余
每个任务目录都有一份 `switch.sh`,其内部已经是代理调用 `cronctl.py`,新任务可以不再创建它,直接用 `cronctl enable/disable`
---
## 任务配置文件格式JSON
任务自定义配置统一使用 JSON不存在时任务会自动生成默认内容。
### `targets.json`bigmengya-docker-image-update
```json
[
{"label": "myapp", "workdir": "/shumengya/docker/myapp", "service": "myapp"},
{"label": "another", "workdir": "/shumengya/docker/another", "service": "web"}
]
```
| 字段 | 说明 |
|---|---|
| `label` | 显示名(日志/通知中使用) |
| `workdir` | compose 项目目录(远端路径) |
| `service` | compose service 名 |
### `repos.json`smallmengya-gitea-repo-sync
```json
[
{"repo": "shumengya/my-repo"},
{"repo": "shumengya/another-repo", "local": "/custom/local/path"}
]
```
| 字段 | 说明 |
|---|---|
| `repo` | `owner/repo`(省略 owner 默认 `shumengya` |
| `local` | 本地同步路径(省略时用 `BASE_DIR/repo名` |
---
## 依赖说明
这套系统**只依赖 Python 3 标准库**`fcntl``subprocess``pathlib` 等),无需安装任何 PyPI 包。
通知功能依赖两个本地项目(非 PyPI 依赖,可缺失时降级):
- `lark-notice-api`:飞书通知,路径 `/shumengya/project/python/lark-notice-api/`
- `mengya-mail-api`:邮件降级,路径 `/shumengya/project/skills/mengya-mail-skills/`
SSH 功能依赖系统 `ssh` 命令和已配置的 SSH 密钥对。

49
_template/README.md Normal file
View File

@@ -0,0 +1,49 @@
# 定时任务模板 / 示例任务
`_template` 本身是可管理的真实任务:每天 08:00 输出 `hello world`
新建任务时复制本目录,改任务名与业务逻辑即可。
## 新建步骤
```bash
TASK_ID="smallmengya-my-new-task"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
# 编辑 run.py替换 hello world 为实际业务逻辑
# 编辑 schedule.cron调整 cron 表达式与注释
python3 "$CRON_ROOT/cronctl.py" status "$TASK_ID"
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" # 手动试跑
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/cron.d/
```
## 必须文件
| 文件 | 说明 |
|---|---|
| `run.py` | 任务入口,导出 `run(ctx) -> int` |
| `schedule.cron` | 系统 cron 配置cron 行须调用 `cronctl.py run <task-id>` |
| `switch.sh` | 可选,代理 `cronctl` 开关 |
## run.py 约定
1. 入口第一行检查 `task_is_disabled(ctx)`,关闭时直接 `return 0`
2. 使用 `task_logging(ctx)` 写日志到 `logs/<task-id>.log`
3. 使用 `acquire_cron_lock(ctx.lock_file, log=log)` 防止并发重入
4. 业务逻辑写在 `log("start")``log("end")` 之间
5. 需要汇总通知时使用 `shumengya_cron.notify.send_task_summary`
## schedule.cron 约定
- 保留 `SHELL` / `PATH` / `HOME` / `CRON_MAIL_ENABLED` 头部
-`#` 注释写一句任务说明WebUI 会读取展示)
- cron 行格式:`分 时 日 月 周 root python3 .../cronctl.py run <task-id> >/dev/null 2>&1`
- 本模板默认 **每天 08:00**`0 8 * * *`
## 任务 ID 命名
`<主机名>-<功能描述>`,例如 `smallmengya-gitea-repo-sync`

46
_template/run.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Hello World 示例任务:每天 08:00 输出一句 hello world。
新建任务时复制整个 _template/ 目录为 <task-id>/,更新 schedule.cron 中的
任务名,再替换下方业务逻辑。命名约定:<主机名>-<功能描述>。
"""
from __future__ import annotations
import sys as _sys, pathlib as _pl
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
del _sys, _pl
from shumengya_cron.runner import (
TaskContext,
acquire_cron_lock,
task_is_disabled,
task_logging,
)
def run(ctx: TaskContext) -> int:
if task_is_disabled(ctx):
return 0
with task_logging(ctx) as log:
with acquire_cron_lock(ctx.lock_file, log=log) as locked:
if not locked:
return 0
log("start")
log("hello world")
log("end")
return 0
def main() -> int:
import pathlib
task_id = pathlib.Path(__file__).parent.name
ctx = TaskContext.from_task_id(task_id)
return run(ctx)
if __name__ == "__main__":
raise SystemExit(main())

9
_template/schedule.cron Normal file
View File

@@ -0,0 +1,9 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 08:00 输出 hello world示例任务复制本目录创建新任务
# 将本文件复制到 /etc/cron.d/_template 即可生效cronctl enable 会自动同步)
# 开关python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template >/dev/null 2>&1

8
_template/switch.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
task_id="$(basename "$(dirname "$0")")"
action="${1:-toggle}"
shift || true
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"

109
cronctl.py Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""shumengya cron 任务开关与运行管理。"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from shumengya_cron.manager import (
cron_root,
get_context,
iter_task_ids,
migrate_legacy_disabled_layout,
run_task,
set_task_state,
sync_cron_d,
)
from shumengya_cron.runner import task_enabled_text, task_is_enabled
CRON_ROOT = cron_root()
def _resolve_task_ids(values: list[str]) -> list[str]:
if not values:
return iter_task_ids(CRON_ROOT)
if len(values) == 1 and values[0] == "all":
return iter_task_ids(CRON_ROOT)
return values
def _print_status(task_id: str) -> None:
ctx = get_context(task_id, CRON_ROOT)
print(f"{ctx.task_id}: {task_enabled_text(ctx)}")
def _set_state(task_id: str, enabled: bool) -> None:
_info, message = set_task_state(task_id, enabled, CRON_ROOT)
print(f"{task_id}: {'开启' if enabled else '关闭'}")
if message:
print(f"{message}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="cronctl.py",
description="管理 /shumengya/project/agent/sproutclaw-cron 下每个定时任务的开关状态,或手动执行任务。",
)
parser.add_argument(
"action",
nargs="?",
default="status",
choices=["status", "enable", "disable", "toggle", "on", "off", "run", "sync-cron"],
help="status / enable / disable / toggle / run / sync-cron",
)
parser.add_argument("tasks", nargs="*", help="任务名status 时默认列出全部;也可传 all")
args = parser.parse_args(argv)
migrate_legacy_disabled_layout(CRON_ROOT)
action = args.action
if action == "on":
action = "enable"
elif action == "off":
action = "disable"
task_ids = _resolve_task_ids(list(args.tasks))
if not task_ids:
print("未找到可管理的任务。", file=sys.stderr)
return 1
if action in {"enable", "disable", "toggle", "run", "sync-cron"} and not args.tasks:
print("请至少指定一个任务名,或传 all。", file=sys.stderr)
return 1
rc = 0
for task_id in task_ids:
try:
if action == "run":
task_rc = run_task(task_id, CRON_ROOT)
print(f"{task_id} (exit={task_rc})")
if task_rc != 0:
rc = task_rc
continue
if action == "status":
_print_status(task_id)
elif action == "sync-cron":
ctx = get_context(task_id, CRON_ROOT)
message = sync_cron_d(ctx, task_is_enabled(ctx))
if message:
print(f"{message}")
elif action == "enable":
_set_state(task_id, True)
elif action == "disable":
_set_state(task_id, False)
else:
_set_state(task_id, not task_is_enabled(get_context(task_id, CRON_ROOT)))
except FileNotFoundError as e:
print(str(e), file=sys.stderr)
rc = 1
return rc
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,5 @@
"""shumengya-cron共用库日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,204 @@
"""sproutclaw-cron 任务管理 API供 cronctl 与 WebUI 共用。"""
from __future__ import annotations
import fcntl
import importlib.util
import re
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from shumengya_cron.runner import (
DISABLED_DIR_NAME,
LogMode,
TaskContext,
set_task_enabled,
task_is_enabled,
)
CRON_D = Path("/etc/cron.d")
_CRON_LINE = re.compile(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
)
def cron_root() -> Path:
return Path(__file__).resolve().parents[2]
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
"""将旧的 <task-id>.disabled/ 目录迁入 .disabled/<task-id>/。"""
base = root or cron_root()
disabled_root = base / DISABLED_DIR_NAME
disabled_root.mkdir(exist_ok=True)
for path in sorted(base.iterdir()):
if not path.is_dir() or not path.name.endswith(".disabled"):
continue
task_id = path.name.removesuffix(".disabled")
target = disabled_root / task_id
if target.exists():
continue
path.rename(target)
def iter_task_ids(root: Path | None = None) -> list[str]:
"""遍历根目录与 .disabled/ 下的任务,返回 task_id 列表。"""
base = root or cron_root()
skip = {"lib", "__pycache__", DISABLED_DIR_NAME, ".claude", "webui"}
task_ids: set[str] = set()
for path in sorted(base.iterdir()):
if not path.is_dir() or path.name in skip or path.name.startswith("."):
continue
if path.name.endswith(".disabled"):
task_ids.add(path.name.removesuffix(".disabled"))
continue
if (path / "run.py").is_file():
task_ids.add(path.name)
disabled_root = base / DISABLED_DIR_NAME
if disabled_root.is_dir():
for path in sorted(disabled_root.iterdir()):
if path.is_dir() and (path / "run.py").is_file():
task_ids.add(path.name)
return sorted(task_ids)
def get_context(task_id: str, root: Path | None = None) -> TaskContext:
ctx = TaskContext.from_task_id(task_id, cron_root=root)
if not ctx.task_dir.is_dir():
raise FileNotFoundError(f"任务不存在: {task_id}")
if not (ctx.task_dir / "run.py").is_file():
raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}")
return ctx
def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
schedule = task_dir / "schedule.cron"
if not schedule.is_file():
return None, None
description: str | None = None
expression: str | None = None
for raw in schedule.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#"):
if description is None:
text = line.lstrip("#").strip()
if text and not text.startswith("开关") and "cronctl" not in text:
description = text
continue
match = _CRON_LINE.match(line)
if match:
expression = match.group(1)
break
return expression, description
def task_is_running(ctx: TaskContext) -> bool:
lock_file = ctx.lock_file
if not lock_file.is_file():
return False
fd = lock_file.open("r")
try:
try:
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return False
except BlockingIOError:
return True
finally:
fd.close()
@dataclass
class TaskInfo:
task_id: str
enabled: bool
running: bool
schedule: str | None
description: str | None
log_size: int
log_updated: str | None
task_dir: str
def to_dict(self) -> dict[str, object]:
return asdict(self)
def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
ctx = get_context(task_id, root)
schedule, description = _parse_schedule(ctx.task_dir)
log_size = 0
log_updated: str | None = None
if ctx.log_file.is_file():
stat = ctx.log_file.stat()
log_size = stat.st_size
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return TaskInfo(
task_id=task_id,
enabled=task_is_enabled(ctx),
running=task_is_running(ctx),
schedule=schedule,
description=description,
log_size=log_size,
log_updated=log_updated,
task_dir=str(ctx.task_dir),
)
def list_tasks(root: Path | None = None) -> list[TaskInfo]:
migrate_legacy_disabled_layout(root)
return [build_task_info(task_id, root) for task_id in iter_task_ids(root)]
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None。"""
cron_d_file = CRON_D / ctx.task_id
schedule = ctx.task_dir / "schedule.cron"
if not schedule.is_file():
return "未找到 schedule.cron跳过安装"
shutil.copy2(schedule, cron_d_file)
state = "开启" if enabled else "关闭cron 仍触发run.py 自动跳过)"
return f"已同步到 {cron_d_file}{state}"
def set_task_state(task_id: str, enabled: bool, root: Path | None = None) -> tuple[TaskInfo, str | None]:
ctx = get_context(task_id, root)
set_task_enabled(ctx, enabled)
ctx = TaskContext.from_task_id(task_id, cron_root=root)
message = sync_cron_d(ctx, enabled)
return build_task_info(task_id, root), message
def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str | None]:
ctx = get_context(task_id, root)
return set_task_state(task_id, not task_is_enabled(ctx), root)
def run_task(task_id: str, root: Path | None = None) -> int:
"""动态加载任务 run.py调用其 run(ctx) 并返回退出码。"""
ctx = get_context(task_id, root)
run_py = ctx.task_dir / "run.py"
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(mod) # type: ignore[union-attr]
log_mode = getattr(mod, "LOG_MODE", LogMode.REDIRECT_STD)
ctx = TaskContext.from_task_id(task_id, log_mode=log_mode, cron_root=root)
return mod.run(ctx)
def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> str:
ctx = get_context(task_id, root)
if not ctx.log_file.is_file():
return ""
content = ctx.log_file.read_text(encoding="utf-8", errors="replace")
parts = content.splitlines()
if lines <= 0 or len(parts) <= lines:
return content
return "\n".join(parts[-lines:]) + "\n"

View File

@@ -0,0 +1,208 @@
"""
飞书 / Lark Markdown 通知 + 任务结果汇总。
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from shumengya_cron.runner import TaskContext
CRON_FEISHU_WEBHOOK_DEFAULT = (
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
)
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
@dataclass
class TaskResult:
"""任务执行结果,汇总 ok / skip / fail 三类条目。"""
ok: list[str] = field(default_factory=list)
skip: list[str] = field(default_factory=list)
fail: list[str] = field(default_factory=list)
def add_ok(self, item: str) -> None:
self.ok.append(item)
def add_skip(self, item: str) -> None:
self.skip.append(item)
def add_fail(self, item: str) -> None:
self.fail.append(item)
@property
def success(self) -> bool:
return not self.fail
@property
def status_cn(self) -> str:
return "成功" if self.success else "失败"
@property
def total(self) -> int:
return len(self.ok) + len(self.skip) + len(self.fail)
def markdown_list(items: list[str]) -> str:
"""将多行条目格式化为 Markdown 列表。"""
if not items:
return "- 无"
return "\n".join(f"- {x}" for x in items if x)
def markdown_fields(items: list[tuple[str, object]]) -> str:
"""将键值对格式化为更简洁的 Markdown 列表。"""
if not items:
return "- 无"
lines: list[str] = []
for key, value in items:
text = "-" if value is None or value == "" else str(value)
lines.append(f"- **{key}**{text}")
return "\n".join(lines)
def send_task_summary(
ctx: "TaskContext",
result: TaskResult,
start_time: str,
*,
log: Callable[[str], None],
extra_fields: list[tuple[str, object]] | None = None,
) -> None:
"""发送标准任务汇总通知(飞书 + 邮件降级)。
extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。
"""
from shumengya_cron.runner import task_output_prefix
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
fields: list[tuple[str, object]] = [
("任务", ctx.task_id),
("开始", start_time),
("结束", end_time),
]
if extra_fields:
fields.extend(extra_fields)
fields.extend([
("总数", result.total),
("成功", len(result.ok)),
("跳过", len(result.skip)),
("失败", len(result.fail)),
])
md = "\n".join([
markdown_fields(fields),
"", "**成功列表**", markdown_list(result.ok),
"", "**跳过列表**", markdown_list(result.skip),
"", "**失败列表**", markdown_list(result.fail),
])
send_feishu_markdown(subject, md, log=log)
def send_feishu_markdown(
title: str,
markdown: str,
*,
log: Callable[[str], None],
) -> None:
"""发送飞书 Markdown失败时自动降级为邮件通知邮件也失败则结束"""
if not title or not markdown:
log("飞书通知内容为空,跳过。")
return
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
if not webhook:
log("未配置飞书 webhook跳过飞书通知。")
return
env = os.environ.copy()
pp = api_src
if env.get("PYTHONPATH"):
pp = f"{api_src}:{env['PYTHONPATH']}"
env["PYTHONPATH"] = pp
feishu_ok = False
try:
r = subprocess.run(
[
sys.executable,
"-m",
"lark_notice_api",
"--webhook",
webhook,
"send-markdown",
"--title",
title,
"--markdown",
markdown,
],
cwd=api_src if os.path.isdir(api_src) else None,
env=env,
check=False,
capture_output=True,
text=True,
)
if r.returncode == 0:
feishu_ok = True
else:
detail = (r.stderr or r.stdout or "").strip()
if detail:
log(f"发送飞书通知失败(退出码 {r.returncode}{detail}")
else:
log(f"发送飞书通知失败(退出码 {r.returncode})。")
except OSError:
log("发送飞书通知失败lark-notice-api 调用异常。")
if feishu_ok:
return
log("飞书通知失败,尝试通过邮件发送…")
_send_mail_fallback(title, markdown, log)
# ── 邮件降级 ──────────────────────────────────────────────
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
"""通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。"""
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
if enabled != "1":
log("邮件通知未开启export CRON_MAIL_ENABLED=1 可启用),跳过。")
return
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
if not os.path.isfile(script):
log(f"邮件脚本不存在:{script},跳过邮件通知。")
return
try:
r = subprocess.run(
[sys.executable, script, "--env-file", env_file, "--format", "json",
"send-email", "--to", mail_to, "--subject", title,
"--from-name", from_name, "--html-body", markdown],
capture_output=True, text=True, check=False,
)
if r.returncode == 0:
log("邮件通知发送成功。")
else:
detail = (r.stderr or r.stdout or "").strip()
log(f"邮件通知发送失败(退出码 {r.returncode}{detail or '无详细信息'}")
except OSError as exc:
log(f"邮件通知发送异常:{exc}")

View File

@@ -0,0 +1,282 @@
"""
任务运行骨架路径、日志轮转、互斥锁、stdout 重定向。
按大小轮转日志、flock 互斥、
锁占用时退出码 0避免 cron 误报)。
"""
from __future__ import annotations
import fcntl
import os
import sys
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Callable, Iterator, TextIO
class LogMode(str, Enum):
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
REDIRECT_STD = "redirect_std" # 等价于 bash: exec >>LOG 2>&1
DIRECT_FILE = "direct_file" # 等价于 cron_log_file
def _cron_root() -> Path:
# .../lib/shumengya_cron/runner.py -> cron 根目录
return Path(__file__).resolve().parents[2]
DISABLED_DIR_NAME = ".disabled"
def disabled_tasks_root(cron_root: Path | None = None) -> Path:
root = cron_root or _cron_root()
return root / DISABLED_DIR_NAME
def active_task_dir(cron_root: Path, task_id: str) -> Path:
return cron_root / task_id
def disabled_task_dir(cron_root: Path, task_id: str) -> Path:
return disabled_tasks_root(cron_root) / task_id
def _legacy_disabled_task_dir(cron_root: Path, task_id: str) -> Path:
return cron_root / f"{task_id}.disabled"
# 固定缩写,遇到这些词时全大写而非首字母大写
_ACRONYMS = frozenset({"ai", "cli", "ssh", "api", "db", "url"})
def task_output_prefix(task_id: str) -> str:
"""将 task_id 格式化为统一输出前缀:[smallmengya][AI-CLI-Update]。"""
family, suffix = (task_id.split("-", 1) + [task_id])[:2]
display = "-".join(
p.upper() if p.lower() in _ACRONYMS else p.capitalize()
for p in suffix.split("-")
)
return f"[{family}][{display}]"
@dataclass
class TaskContext:
"""单次任务运行的上下文(每个任务目录对应一个 task_id"""
task_id: str
cron_root: Path
task_dir: Path
log_dir: Path
log_file: Path
lock_file: Path
log_mode: LogMode
@classmethod
def from_task_id(
cls,
task_id: str,
*,
log_mode: LogMode = LogMode.REDIRECT_STD,
cron_root: Path | None = None,
) -> TaskContext:
"""根据 task_id 创建上下文,自动识别 .disabled/<task-id>/ 目录。"""
root = cron_root or _cron_root()
disabled_dir = disabled_task_dir(root, task_id).resolve()
active_dir = active_task_dir(root, task_id).resolve()
legacy_disabled_dir = _legacy_disabled_task_dir(root, task_id).resolve()
if disabled_dir.is_dir():
task_dir = disabled_dir
elif legacy_disabled_dir.is_dir():
task_dir = legacy_disabled_dir
elif active_dir.is_dir():
task_dir = active_dir
else:
task_dir = active_dir
log_dir = task_dir / "logs"
log_file = log_dir / f"{task_id}.log"
lock_file = task_dir / f"{task_id}.lock"
return cls(
task_id=task_id,
cron_root=root,
task_dir=task_dir,
log_dir=log_dir,
log_file=log_file,
lock_file=lock_file,
log_mode=log_mode,
)
def task_is_enabled(ctx: TaskContext) -> bool:
"""任务位于 cron 根目录下为开启,位于 .disabled/ 或 *.disabled 下为关闭。"""
if ctx.task_dir.name.endswith(".disabled"):
return False
disabled_root = disabled_tasks_root(ctx.cron_root).resolve()
try:
return not ctx.task_dir.resolve().is_relative_to(disabled_root)
except AttributeError:
return ctx.task_dir.resolve().parent != disabled_root
def task_is_disabled(ctx: TaskContext) -> bool:
return not task_is_enabled(ctx)
def set_task_enabled(ctx: TaskContext, enabled: bool) -> None:
"""通过移动目录切换任务状态:启用时在根目录,禁用时在 .disabled/ 下。"""
root = ctx.cron_root
active_dir = active_task_dir(root, ctx.task_id)
disabled_dir = disabled_task_dir(root, ctx.task_id)
legacy_disabled_dir = _legacy_disabled_task_dir(root, ctx.task_id)
if legacy_disabled_dir.is_dir() and not disabled_dir.is_dir():
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_disabled_dir.rename(disabled_dir)
if enabled:
if disabled_dir.is_dir() and not active_dir.is_dir():
active_dir.parent.mkdir(parents=True, exist_ok=True)
disabled_dir.rename(active_dir)
ctx.task_dir = active_dir
elif active_dir.is_dir():
ctx.task_dir = active_dir
return
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
if active_dir.is_dir() and not disabled_dir.is_dir():
active_dir.rename(disabled_dir)
ctx.task_dir = disabled_dir
elif disabled_dir.is_dir():
ctx.task_dir = disabled_dir
def task_enabled_text(ctx: TaskContext) -> str:
return "开启" if task_is_enabled(ctx) else "关闭"
def cron_log_rotate(log_file: Path, max_bytes: int | None = None) -> None:
"""单日志超过阈值则轮转(默认 10MB与 legacy cron_log_rotate 一致。"""
mb = max_bytes if max_bytes is not None else int(
os.environ.get("CRON_LOG_MAX_BYTES", str(10 * 1024 * 1024))
)
try:
if not log_file.is_file():
return
if log_file.stat().st_size <= mb:
return
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
log_file.rename(log_file.with_name(f"{log_file.name}.{stamp}"))
except OSError:
pass
@contextmanager
def acquire_cron_lock(
lock_file: Path,
*,
busy_message: str | None = None,
log: Callable[[str], None],
) -> Iterator[bool]:
"""
获取互斥锁;若已被占用则记录日志并 yield False调用方应 sys.exit(0))。
使用 flock与 bash 版一致)。
"""
msg = busy_message or os.environ.get(
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
)
lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log(msg)
yield False
return
yield True
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
def _tee_streams(log_fp: TextIO) -> tuple[TextIO, TextIO]:
"""将 stdout/stderr 同时写到日志与原始 fd便于调试时仍可在终端看到"""
class Tee(TextIO):
def __init__(self, *streams: TextIO) -> None:
self._streams = streams
def write(self, s: str) -> int:
n = 0
for st in self._streams:
n = st.write(s)
st.flush()
return n
def flush(self) -> None:
for st in self._streams:
st.flush()
# 保留原 stdout/stderr 供 Tee
orig_out = sys.__stdout__
orig_err = sys.__stderr__
out = Tee(log_fp, orig_out)
err = Tee(log_fp, orig_err)
return out, err
@contextmanager
def task_logging(ctx: TaskContext) -> Iterator[Callable[[str], None]]:
"""
按 log_mode 配置日志。
- REDIRECT_STD轮转后重定向 stdout/stderr 到日志文件(并 tee 到原终端)。
- DIRECT_FILE不重定向返回 log_line() 仅写文件。
"""
ctx.log_dir.mkdir(parents=True, exist_ok=True)
cron_log_rotate(ctx.log_file)
prefix = task_output_prefix(ctx.task_id)
if ctx.log_mode == LogMode.DIRECT_FILE:
def log_line(message: str) -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with ctx.log_file.open("a", encoding="utf-8") as fp:
fp.write(f"[{ts}] {prefix} {message}\n")
yield log_line
return
log_fp = ctx.log_file.open("a", encoding="utf-8")
try:
out, err = _tee_streams(log_fp)
sys.stdout, sys.stderr = out, err
def log_line(message: str) -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] {prefix} {message}", flush=True)
yield log_line
finally:
if ctx.log_mode == LogMode.REDIRECT_STD:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
log_fp.close()
def append_raw_to_log(log_file: Path, text: str) -> None:
"""将多行原文追加到日志(命令输出等)。"""
if not text:
return
with log_file.open("a", encoding="utf-8") as fp:
fp.write(text)
if not text.endswith("\n"):
fp.write("\n")

25
lib/shumengya_cron/ssh.py Normal file
View File

@@ -0,0 +1,25 @@
"""
远端 SSH用 bash -lc 执行命令,使 PATH 与登录 shell 一致(非交互 ssh 常缺 /usr/local/bin 等,导致找不到 docker
"""
from __future__ import annotations
import subprocess
def ssh_bash_lc(host: str, remote_cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
host,
"bash",
"-lc",
remote_cmd,
],
capture_output=True,
text=True,
)

View File

@@ -0,0 +1,21 @@
[
{"repo": "shumengya/ai-translate", "local": "/shumengya/project/cloudflare/ai-translate"},
{"repo": "shumengya/cf-doh", "local": "/shumengya/project/cloudflare/cf-doh"},
{"repo": "shumengya/cf-favicon", "local": "/shumengya/project/cloudflare/cf-favicon"},
{"repo": "shumengya/cf-ip-geo", "local": "/shumengya/project/cloudflare/cf-ip-geo"},
{"repo": "shumengya/mengya-nav", "local": "/shumengya/project/cloudflare/mengya-nav"},
{"repo": "shumengya/InfoGenie", "local": "/shumengya/project/frontend-backend/InfoGenie"},
{"repo": "shumengya/mengyaconnect", "local": "/shumengya/project/frontend-backend/mengyaconnect"},
{"repo": "shumengya/mengyadriftbottle", "local": "/shumengya/project/frontend-backend/mengyadriftbottle"},
{"repo": "shumengya/mengyakeyvault", "local": "/shumengya/project/frontend-backend/mengyakeyvault"},
{"repo": "shumengya/mengyalinkfly", "local": "/shumengya/project/frontend-backend/mengyalinkfly"},
{"repo": "shumengya/mengyamonitor", "local": "/shumengya/project/frontend-backend/mengyamonitor"},
{"repo": "shumengya/mengyanote", "local": "/shumengya/project/frontend-backend/mengyanote"},
{"repo": "shumengya/mengpost", "local": "/shumengya/project/frontend-backend/mengpost"},
{"repo": "shumengya/mengyaping", "local": "/shumengya/project/frontend-backend/mengyaping"},
{"repo": "shumengya/mengyaprofile", "local": "/shumengya/project/frontend-backend/mengyaprofile"},
{"repo": "shumengya/mengyastore", "local": "/shumengya/project/frontend-backend/mengyastore"},
{"repo": "shumengya/SproutGate", "local": "/shumengya/project/frontend-backend/SproutGate"},
{"repo": "shumengya/SmyWorkCollect", "local": "/shumengya/project/frontend-backend/SproutWorkCollect"},
{"repo": "shumengya/random-background-api","local": "/shumengya/project/frontend/random-background-api"}
]

View File

@@ -0,0 +1,202 @@
"""
Gitea 仓库同步:配置见本目录 repos.json。
同步逻辑:先 fetch 检查远端是否有新提交,有则 fast-forward merge无则跳过
不会无谓拉取;仓库依次串行处理,每条间隔 SYNC_DELAY_SECONDS 秒。
env 变量:
BASE_DIR 已废弃local 字段现在必填)
SYNC_DELAY_SECONDS 每个仓库处理间隔秒数(默认 2
GIT_SSH_COMMAND git SSH 参数
"""
from __future__ import annotations
import sys as _sys, pathlib as _pl
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
del _sys, _pl
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from shumengya_cron.notify import TaskResult, send_task_summary
from shumengya_cron.runner import (
TaskContext,
acquire_cron_lock,
append_raw_to_log,
task_is_disabled,
task_logging,
)
def _load_repos(path: Path) -> list[tuple[str, Path]]:
"""加载 repos.jsonlocal 字段必填,缺失的条目直接跳过。"""
if not path.is_file():
return []
data = json.loads(path.read_text(encoding="utf-8"))
result: list[tuple[str, Path]] = []
for item in data:
owner_repo = item.get("repo", "").strip()
local_s = item.get("local", "").strip()
if not owner_repo or not local_s:
continue
if "/" not in owner_repo:
owner_repo = f"shumengya/{owner_repo}"
result.append((owner_repo, Path(local_s)))
return result
def _git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str]:
r = subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
def run(ctx: TaskContext) -> int:
if task_is_disabled(ctx):
return 0
os.environ.setdefault("HOME", "/root")
os.environ.setdefault(
"GIT_SSH_COMMAND",
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
)
repos_file = ctx.task_dir / "repos.json"
sync_delay = int(os.environ.get("SYNC_DELAY_SECONDS", "2"))
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
result = TaskResult()
def sync_one(owner_repo: str, dir_path: Path, log) -> None:
parts = owner_repo.split("/")
owner, repo = parts[0], parts[-1]
remote_url = f"ssh://git@git.shumengya.top:8022/{owner}/{repo}.git"
# ── 仓库不存在:直接 clone ───────────────────────────
if not (dir_path / ".git").is_dir():
if dir_path.exists():
log(f"[{owner_repo}] 路径已存在但不是 git 仓库,跳过:{dir_path}")
result.add_skip(f"{owner_repo}:not_git")
return
dir_path.parent.mkdir(parents=True, exist_ok=True)
log(f"[{owner_repo}] clone -> {dir_path}")
rc, out = _git(["clone", remote_url, str(dir_path)])
if out:
append_raw_to_log(ctx.log_file, out)
if rc == 0:
log(f"[{owner_repo}] clone OK")
result.add_ok(f"{owner_repo}:clone")
else:
log(f"[{owner_repo}] clone 失败,请检查 SSH/权限/网络。")
result.add_fail(f"{owner_repo}:clone_failed")
return
# ── 仓库已存在fetch → 检查差异 → merge ─────────────
# 确认当前分支
rc, branch = _git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=dir_path)
branch = branch.splitlines()[0].strip() if branch else ""
if rc != 0 or not branch:
log(f"[{owner_repo}] detached HEAD跳过。")
result.add_skip(f"{owner_repo}:detached")
return
# 找远端名并更新 URL
rc, remote_name = _git(["config", "--get", f"branch.{branch}.remote"], cwd=dir_path)
remote_name = remote_name.splitlines()[0].strip() if remote_name else ""
if not remote_name:
rc2, rn = _git(["remote"], cwd=dir_path)
remote_name = rn.splitlines()[0].strip() if rc2 == 0 and rn else ""
if not remote_name:
log(f"[{owner_repo}] 未找到远端名,跳过。")
result.add_fail(f"{owner_repo}:no_remote")
return
_git(["remote", "set-url", remote_name, remote_url], cwd=dir_path)
# 工作区必须干净
rc, dirty = _git(["status", "--porcelain"], cwd=dir_path)
if dirty:
log(f"[{owner_repo}] 工作区有改动,跳过(避免覆盖本地改动)。")
result.add_skip(f"{owner_repo}:dirty")
return
# fetch 远端最新
rc, fetch_out = _git(["fetch", "--prune", remote_name, branch], cwd=dir_path)
if fetch_out:
append_raw_to_log(ctx.log_file, fetch_out)
if rc != 0:
log(f"[{owner_repo}] fetch 失败,跳过。")
result.add_fail(f"{owner_repo}:fetch_failed")
return
# 检查是否有新提交
rc, count = _git(["rev-list", "HEAD..FETCH_HEAD", "--count"], cwd=dir_path)
if rc == 0 and count.strip() == "0":
log(f"[{owner_repo}] 已是最新,跳过。")
result.add_skip(f"{owner_repo}:already_latest")
return
# 有更新fast-forward merge
log(f"[{owner_repo}] 发现 {count.strip()} 个新提交,合并中…")
rc, merge_out = _git(["merge", "--ff-only", "FETCH_HEAD"], cwd=dir_path)
if merge_out:
append_raw_to_log(ctx.log_file, merge_out)
if rc == 0:
log(f"[{owner_repo}] OK")
result.add_ok(f"{owner_repo}:updated")
else:
log(f"[{owner_repo}] merge 失败请手动检查git -C {dir_path} merge --ff-only FETCH_HEAD")
result.add_fail(f"{owner_repo}:merge_failed")
with task_logging(ctx) as log:
with acquire_cron_lock(
ctx.lock_file,
busy_message=os.environ.get("CRON_LOCK_BUSY_MSG", "已有同步任务在运行,跳过本次。"),
log=log,
) as locked:
if not locked:
return 0
log("start")
log(f"repos_file={repos_file}")
log(f"sync_delay_seconds={sync_delay}")
if not repos_file.is_file():
log(f"仓库列表文件不存在:{repos_file}")
result.add_fail("repos_file:missing")
send_task_summary(ctx, result, start_time, log=log)
return 1
repo_items = _load_repos(repos_file)
log(f"{len(repo_items)} 个仓库,依次处理")
for idx, (owner_repo, dir_path) in enumerate(repo_items):
sync_one(owner_repo, dir_path, log)
if sync_delay > 0 and idx < len(repo_items) - 1:
time.sleep(sync_delay)
log("end")
send_task_summary(ctx, result, start_time, log=log)
return 0 if result.success else 1
def main() -> int:
import pathlib
task_id = pathlib.Path(__file__).parent.name
ctx = TaskContext.from_task_id(task_id)
return run(ctx)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,10 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 00:00 按 repos.json 同步 Gitea 仓库到本地,仅在有新提交时 fast-forward 合并
# 将本文件复制到 /etc/cron.d/smallmengya-gitea-repo-sync 即可生效
# 开关bash /shumengya/project/agent/sproutclaw-cron/smallmengya-gitea-repo-sync/switch.sh on|off|toggle
# 统一管理python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status|enable|disable|toggle smallmengya-gitea-repo-sync|all
0 0 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run smallmengya-gitea-repo-sync >/dev/null 2>&1

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
task_id="$(basename "$(dirname "$0")")"
action="${1:-toggle}"
shift || true
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"

161
webui/backend/main.py Normal file
View 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")

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0

303
webui/frontend/app.js Normal file
View File

@@ -0,0 +1,303 @@
const API = "/api";
const REFRESH_INTERVAL = 15000;
const els = {
taskBody: document.getElementById("task-body"),
taskCards: document.getElementById("task-cards"),
stats: document.getElementById("stats"),
summaryText: document.getElementById("summary-text"),
lastRefresh: document.getElementById("last-refresh"),
filter: document.getElementById("filter"),
toast: document.getElementById("toast"),
logDialog: document.getElementById("log-dialog"),
logTitle: document.getElementById("log-title"),
logSubtitle: document.getElementById("log-subtitle"),
logContent: document.getElementById("log-content"),
sidebar: document.getElementById("sidebar"),
overlay: document.getElementById("overlay"),
};
let allTasks = [];
let currentLogTaskId = null;
let refreshTimer = null;
const escapeHtml = (str) =>
String(str ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function nowText() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function showToast(message, isError = false) {
els.toast.hidden = false;
els.toast.textContent = message;
els.toast.classList.toggle("is-error", isError);
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
els.toast.hidden = true;
}, 2800);
}
async function api(path, options = {}) {
const res = await fetch(`${API}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `请求失败 (${res.status})`);
}
return data;
}
function statusMarkup(task) {
if (task.running) {
return '<span class="status run"><span class="dot"></span>运行中</span>';
}
if (task.enabled) {
return '<span class="status on"><span class="dot"></span>已开启</span>';
}
return '<span class="status off"><span class="dot"></span>已关闭</span>';
}
function scheduleMarkup(task) {
return task.schedule
? `<span class="schedule-code">${escapeHtml(task.schedule)}</span>`
: '<span class="mono">—</span>';
}
function logMarkup(task) {
const size = formatBytes(task.log_size);
const updated = task.log_updated
? `<span class="muted">${escapeHtml(task.log_updated)}</span>`
: '<span class="muted">无记录</span>';
return `<div class="log-meta">${size}</div>${updated}`;
}
function actionButtons(task) {
const toggleLabel = task.enabled ? "关闭" : "开启";
const toggleClass = task.enabled ? "btn-danger" : "btn-success";
return `
<div class="actions">
<button type="button" class="btn ${toggleClass}" data-action="toggle" data-id="${escapeHtml(task.task_id)}">${toggleLabel}</button>
<button type="button" class="btn" data-action="run" data-id="${escapeHtml(task.task_id)}" ${task.running ? "disabled" : ""}>运行</button>
<button type="button" class="btn" data-action="sync" data-id="${escapeHtml(task.task_id)}">同步</button>
<button type="button" class="btn" data-action="log" data-id="${escapeHtml(task.task_id)}">日志</button>
</div>
`;
}
function nameMarkup(task) {
return `
<div class="task-name">${escapeHtml(task.task_id)}</div>
${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ""}
`;
}
function renderStats(tasks) {
const enabled = tasks.filter((t) => t.enabled).length;
const running = tasks.filter((t) => t.running).length;
const off = tasks.length - enabled;
els.stats.innerHTML = `
<div class="meta-item">
<span class="label">任务总数</span>
<span class="value">${tasks.length}</span>
</div>
<div class="meta-item is-accent">
<span class="label">已开启</span>
<span class="value">${enabled}</span>
</div>
<div class="meta-item">
<span class="label">已关闭</span>
<span class="value">${off}</span>
</div>
<div class="meta-item is-run">
<span class="label">运行中</span>
<span class="value">${running}</span>
</div>
`;
els.summaryText.textContent = running
? `${tasks.length} 个任务,${enabled} 个已开启,${running} 个正在运行`
: `${tasks.length} 个任务,${enabled} 个已开启`;
}
function renderTasks(tasks) {
if (!tasks.length) {
els.taskBody.innerHTML =
'<tr><td colspan="5" class="empty">没有匹配的任务</td></tr>';
els.taskCards.innerHTML = '<div class="empty">没有匹配的任务</div>';
return;
}
els.taskBody.innerHTML = tasks
.map(
(task) => `
<tr>
<td>${statusMarkup(task)}</td>
<td>${nameMarkup(task)}</td>
<td>${scheduleMarkup(task)}</td>
<td>${logMarkup(task)}</td>
<td>${actionButtons(task)}</td>
</tr>
`
)
.join("");
els.taskCards.innerHTML = tasks
.map(
(task) => `
<article class="task-card">
<div class="task-card-head">
<div class="name">${nameMarkup(task)}</div>
${statusMarkup(task)}
</div>
<div class="task-card-body">
<div>
<span class="field-label">调度</span>
${scheduleMarkup(task)}
</div>
<div>
<span class="field-label">日志</span>
<span class="log-meta">${formatBytes(task.log_size)}</span>
<span class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</span>
</div>
</div>
${actionButtons(task)}
</article>
`
)
.join("");
}
function applyFilter() {
const q = els.filter.value.trim().toLowerCase();
const filtered = q
? allTasks.filter((t) =>
t.task_id.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q)
)
: allTasks;
renderTasks(filtered);
}
async function loadTasks() {
try {
const tasks = await api("/tasks");
allTasks = tasks;
renderStats(tasks);
applyFilter();
els.lastRefresh.textContent = `已刷新 ${nowText()}`;
} catch (err) {
els.taskBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(err.message)}</td></tr>`;
els.taskCards.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
els.lastRefresh.textContent = `刷新失败 ${nowText()}`;
showToast(err.message, true);
}
}
async function handleAction(action, taskId) {
try {
if (action === "log") {
await openLog(taskId);
return;
}
if (action === "run") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" });
showToast(res.ok === false ? res.message : `已启动 ${taskId}`);
} else if (action === "sync") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/sync-cron`, { method: "POST" });
showToast(res.message || `已同步 ${taskId} 的 cron`);
} else if (action === "toggle") {
await api(`/tasks/${encodeURIComponent(taskId)}/toggle`, { method: "POST" });
showToast(`已切换 ${taskId} 状态`);
}
await loadTasks();
} catch (err) {
showToast(err.message, true);
}
}
async function openLog(taskId) {
currentLogTaskId = taskId;
els.logTitle.textContent = taskId;
els.logSubtitle.textContent = "加载日志…";
els.logContent.textContent = "";
els.logDialog.showModal();
await refreshLog();
}
async function refreshLog() {
if (!currentLogTaskId) return;
try {
const data = await api(`/tasks/${encodeURIComponent(currentLogTaskId)}/log?lines=300`);
const lineCount = data.content ? data.content.split("\n").length : 0;
els.logSubtitle.textContent = `最近 ${lineCount}`;
els.logContent.textContent = data.content || "";
els.logContent.scrollTop = els.logContent.scrollHeight;
} catch (err) {
els.logSubtitle.textContent = err.message;
showToast(err.message, true);
}
}
function bindActions(root) {
root.addEventListener("click", (event) => {
const btn = event.target.closest("[data-action]");
if (!btn) return;
handleAction(btn.dataset.action, btn.dataset.id);
});
}
function closeSidebar() {
els.sidebar.classList.remove("open");
els.overlay.hidden = true;
}
document.getElementById("btn-refresh").addEventListener("click", loadTasks);
document.getElementById("btn-refresh-top").addEventListener("click", loadTasks);
document.getElementById("btn-log-refresh").addEventListener("click", refreshLog);
document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close());
els.filter.addEventListener("input", applyFilter);
document.getElementById("menu-toggle").addEventListener("click", () => {
els.sidebar.classList.add("open");
els.overlay.hidden = false;
});
els.overlay.addEventListener("click", closeSidebar);
els.logDialog.addEventListener("click", (event) => {
if (event.target === els.logDialog) els.logDialog.close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (els.logDialog.open) els.logDialog.close();
else if (els.sidebar.classList.contains("open")) closeSidebar();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
els.filter.focus();
}
});
bindActions(document.body);
loadTasks();
refreshTimer = setInterval(loadTasks, REFRESH_INTERVAL);
window.addEventListener("beforeunload", () => clearInterval(refreshTimer));

100
webui/frontend/index.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SproutClaw Cron · 定时任务管理</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="layout">
<aside class="sidebar" id="sidebar" aria-label="主导航">
<div class="brand">
<div class="brand-text">
<h1>SproutClaw Cron</h1>
<p>定时任务管理面板</p>
</div>
</div>
<nav class="sidebar-nav">
<button type="button" class="nav-item active" data-view="tasks">
<span class="nav-dot" aria-hidden="true"></span>
<span>任务清单</span>
</button>
</nav>
<div class="sidebar-foot">
<button type="button" class="btn btn-ghost btn-block" id="btn-refresh">刷新列表</button>
</div>
</aside>
<div class="overlay" id="overlay" hidden></div>
<main class="main">
<header class="topbar">
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="打开菜单">菜单</button>
<div class="masthead">
<h2>定时任务管理</h2>
<p class="masthead-meta" id="summary-text">加载中…</p>
</div>
<button type="button" class="btn btn-primary" id="btn-refresh-top">刷新</button>
</header>
<section class="document">
<div class="doc-meta" id="stats" aria-live="polite"></div>
<div class="toolbar">
<div class="search">
<input type="search" id="filter" placeholder="按任务名或说明过滤…" autocomplete="off" />
</div>
<span class="last-refresh" id="last-refresh"></span>
</div>
<section class="doc-section">
<div class="section-head">
<h3>任务清单</h3>
</div>
<div class="table-wrap">
<table class="task-table" id="task-table">
<thead>
<tr>
<th class="col-status">状态</th>
<th class="col-name">任务</th>
<th class="col-schedule">调度</th>
<th class="col-log">日志</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody id="task-body">
<tr><td colspan="5" class="empty">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="task-cards" id="task-cards" aria-label="任务列表"></div>
</section>
</section>
</main>
</div>
<dialog class="log-dialog" id="log-dialog">
<div class="log-dialog-head">
<div class="log-dialog-title">
<span class="kicker">运行日志</span>
<h3 id="log-title">任务日志</h3>
<p id="log-subtitle"></p>
</div>
<div class="log-dialog-actions">
<button type="button" class="btn btn-ghost" id="btn-log-refresh">刷新日志</button>
<button type="button" class="btn" id="btn-log-close">关闭</button>
</div>
</div>
<pre class="log-content" id="log-content"></pre>
</dialog>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>

566
webui/frontend/styles.css Normal file
View File

@@ -0,0 +1,566 @@
/* SproutClaw Cron · 文档式后台管理面板 */
:root {
/* 配色:暖纸 + 墨色 + 萌芽绿点缀 */
--paper: #faf9f7;
--paper-raised: #ffffff;
--ink: #1c1a17;
--ink-soft: #6b6660;
--ink-faint: #9a948c;
--rule: #e7e2da;
--rule-strong: #d8d2c8;
--accent: #2d6a4f;
--accent-hover: #245340;
--accent-soft: #e6efe9;
--accent-line: #2d6a4f;
--status-on: #2d6a4f;
--status-on-bg: #e6efe9;
--status-off: #9a948c;
--status-off-bg: #efece6;
--status-run: #b45309;
--status-run-bg: #fbf3e6;
--danger: #9b2c2c;
--danger-bg: #f7eded;
--danger-line: #e6c9c9;
--shadow: 0 1px 2px rgba(28, 26, 23, 0.04);
--radius: 6px;
--sidebar-w: 232px;
--doc-max: 1080px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
--font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Source Serif Pro", Georgia, "Songti SC", serif;
--font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: var(--font-sans);
font-size: 15px;
color: var(--ink);
background: var(--paper);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body { overflow-x: hidden; }
button, input, select, textarea, dialog {
font: inherit;
color: inherit;
}
button { cursor: pointer; }
table, caption, th, td { font: inherit; }
code, pre, kbd, samp { font-family: var(--font-mono); }
/* ===== 顶部萌芽绿细条(签名) ===== */
.layout::before {
content: "";
position: fixed;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--accent-line);
z-index: 40;
}
.layout { min-height: 100vh; display: flex; padding-top: 3px; }
/* ===== 侧栏 ===== */
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--paper-raised);
border-right: 1px solid var(--rule);
padding: 1.5rem 1rem 1.25rem;
display: flex;
flex-direction: column;
position: sticky;
top: 3px;
height: calc(100vh - 3px);
z-index: 20;
}
.brand {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0 0.25rem 1.1rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 1.1rem;
}
.brand-text h1 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.05rem;
font-weight: 600;
line-height: 1.15;
letter-spacing: 0.01em;
}
.brand-text p {
margin: 0.2rem 0 0;
font-size: 0.74rem;
color: var(--ink-soft);
letter-spacing: 0.02em;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 0.15rem; }
.nav-item {
border: none;
background: transparent;
text-align: left;
padding: 0.55rem 0.6rem;
border-radius: var(--radius);
color: var(--ink-soft);
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.12s, color 0.12s;
}
.nav-item:hover { background: var(--paper); color: var(--ink); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav-dot {
width: 6px; height: 6px; border-radius: 50%;
background: currentColor;
opacity: 0.55;
}
.nav-item.active .nav-dot { opacity: 1; }
.sidebar-foot { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--rule); }
.auto-note {
margin: 0.5rem 0 0;
font-size: 0.72rem;
color: var(--ink-faint);
text-align: center;
}
/* ===== 主区 ===== */
.main { flex: 1; min-width: 0; }
.topbar {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem 2rem 1.25rem;
background: var(--paper);
border-bottom: 1px solid var(--rule);
}
.menu-toggle { display: none; }
.masthead { flex: 1; min-width: 0; }
.kicker {
display: block;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 0.3rem;
}
.masthead h2 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.6rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.005em;
}
.masthead-meta {
margin: 0.45rem 0 0;
font-size: 0.84rem;
color: var(--ink-soft);
}
/* ===== 文档主体 ===== */
.document {
max-width: var(--doc-max);
margin: 0 auto;
padding: 1.75rem 2rem 3rem;
}
.doc-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 1.5rem;
}
.meta-item { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }
.meta-item .label {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
}
.meta-item .value {
font-family: var(--font-serif);
font-size: 1.35rem;
font-weight: 600;
line-height: 1;
}
.meta-item.is-accent .value { color: var(--accent); }
.meta-item.is-run .value { color: var(--status-run); }
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
background: var(--paper-raised);
border: 1px solid var(--rule);
border-radius: var(--radius);
transition: border-color 0.12s, box-shadow 0.12s;
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search input {
flex: 1;
border: none;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: var(--ink);
min-width: 0;
}
.search input::placeholder { color: var(--ink-faint); }
.last-refresh {
font-size: 0.76rem;
color: var(--ink-faint);
white-space: nowrap;
}
/* ===== 文档分节 ===== */
.doc-section { margin-top: 0.5rem; }
.section-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.85rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule);
}
.section-head h3 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.1rem;
font-weight: 600;
}
.section-hint {
font-size: 0.78rem;
color: var(--ink-faint);
}
/* ===== 任务表格(桌面) ===== */
.table-wrap { overflow-x: auto; }
.task-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.task-table th {
padding: 0.55rem 0.75rem;
text-align: left;
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
font-weight: 600;
border-bottom: 1px solid var(--rule-strong);
white-space: nowrap;
}
.task-table td {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
.task-table tbody tr { transition: background 0.1s; }
.task-table tbody tr:hover { background: var(--paper-raised); }
.task-table tbody tr:last-child td { border-bottom: none; }
.col-status { width: 88px; }
.col-actions { width: 1%; white-space: nowrap; }
.col-schedule { width: 170px; }
.col-log { width: 150px; }
.task-name {
font-weight: 600;
font-size: 0.92rem;
word-break: break-word;
}
.task-desc {
margin-top: 0.2rem;
font-size: 0.8rem;
color: var(--ink-soft);
word-break: break-word;
overflow-wrap: anywhere;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
font-weight: 500;
}
.status .dot {
width: 7px; height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status.on { color: var(--status-on); }
.status.on .dot { background: var(--status-on); }
.status.off { color: var(--status-off); }
.status.off .dot { background: var(--status-off); }
.status.run { color: var(--status-run); }
.status.run .dot {
background: var(--status-run);
box-shadow: 0 0 0 0 var(--status-run-bg);
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0.45); }
70% { box-shadow: 0 0 0 5px rgba(180, 83, 9, 0); }
100% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0); }
}
.mono { font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink-soft); }
.schedule-code {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--ink);
background: var(--paper-raised);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 0.1rem 0.35rem;
}
.log-meta { font-size: 0.78rem; color: var(--ink-soft); line-height: 1.5; }
.log-meta .muted { color: var(--ink-faint); }
.actions { display: flex; flex-wrap: wrap; gap: 0.3rem; }
/* ===== 按钮 ===== */
.btn {
border: 1px solid var(--rule);
background: var(--paper-raised);
color: var(--ink);
border-radius: var(--radius);
padding: 0.4rem 0.7rem;
font-size: 0.8rem;
line-height: 1.2;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover { background: var(--paper); border-color: var(--rule-strong); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-success { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
.btn-success:hover { background: #dce8e1; border-color: #d2e2da; }
.btn-danger { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
.btn-danger:hover { background: #f1e3e3; }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-ghost:hover { background: var(--paper); border-color: var(--rule); }
.btn-block { width: 100%; }
.empty {
text-align: center;
color: var(--ink-faint);
padding: 2.25rem 1rem !important;
font-size: 0.88rem;
}
/* ===== 移动端任务条目 ===== */
.task-cards { display: none; }
.task-card {
padding: 1rem 0;
border-bottom: 1px solid var(--rule);
}
.task-card:first-child { padding-top: 0.25rem; }
.task-card:last-child { border-bottom: none; padding-bottom: 0.25rem; }
.task-card-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.task-card-head > .name { min-width: 0; flex: 1; }
.task-card-body {
margin: 0.65rem 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem 1rem;
font-size: 0.82rem;
}
.task-card-body .field-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 0.15rem;
}
.task-card .actions { margin-top: 0.75rem; }
.task-card .actions .btn { flex: 1 1 calc(50% - 0.3rem); min-width: 0; text-align: center; }
/* ===== 抽屉遮罩 ===== */
.overlay {
position: fixed;
inset: 0;
background: rgba(28, 26, 23, 0.32);
z-index: 15;
}
.overlay[hidden] { display: none; }
/* ===== 日志弹层 ===== */
.log-dialog {
width: min(960px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: 0;
background: var(--paper-raised);
box-shadow: 0 12px 40px rgba(28, 26, 23, 0.18);
color: var(--ink);
}
.log-dialog::backdrop { background: rgba(28, 26, 23, 0.42); }
.log-dialog-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--rule);
}
.log-dialog-title .kicker { margin-bottom: 0.2rem; }
.log-dialog-title h3 {
margin: 0;
font-family: var(--font-mono);
font-size: 0.95rem;
font-weight: 600;
}
.log-dialog-title p { margin: 0.25rem 0 0; font-size: 0.8rem; color: var(--ink-soft); }
.log-dialog-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.log-content {
margin: 0;
padding: 1.1rem 1.25rem;
max-height: 62vh;
overflow: auto;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
background: #1b1a17;
color: #e7e2da;
}
.log-content:empty::before { content: "(暂无日志)"; color: #6b6660; }
/* ===== Toast ===== */
.toast {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
background: var(--ink);
color: #fff;
padding: 0.6rem 1.1rem;
border-radius: 999px;
font-size: 0.84rem;
z-index: 60;
box-shadow: 0 6px 24px rgba(28, 26, 23, 0.25);
}
.toast.is-error { background: var(--danger); }
/* ===== 响应式 ===== */
@media (max-width: 920px) {
.topbar { padding: 1.1rem 1.25rem 1rem; }
.document { padding: 1.25rem 1.25rem 2.5rem; }
.masthead h2 { font-size: 1.35rem; }
.meta-item .value { font-size: 1.2rem; }
}
@media (max-width: 720px) {
.sidebar {
position: fixed;
left: 0; top: 3px; bottom: 0;
height: calc(100vh - 3px);
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 0 0 40px rgba(28, 26, 23, 0.18);
}
.sidebar.open { transform: translateX(0); }
.overlay:not([hidden]) { display: block; }
.menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
height: 38px;
padding: 0 0.9rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-raised);
font-size: 0.84rem;
flex-shrink: 0;
}
.topbar { padding: 0.9rem 0.9rem 0.85rem; gap: 0.6rem; }
.masthead h2 { font-size: 1.2rem; }
#btn-refresh-top { flex-shrink: 0; padding-inline: 0.6rem; }
.document { padding: 1rem 0.9rem 2rem; }
/* 统计折叠为单行紧凑元数据 */
.doc-meta { gap: 0.4rem 1.25rem; padding-bottom: 1rem; margin-bottom: 1.1rem; }
.meta-item .value { font-size: 1.05rem; }
.meta-item .label { font-size: 0.66rem; }
.toolbar { gap: 0.6rem; }
.last-refresh { font-size: 0.72rem; }
/* 桌面表格隐藏,启用移动条目 */
.table-wrap { display: none; }
.task-cards { display: block; }
.log-dialog-head { flex-direction: column; gap: 0.6rem; }
.log-dialog-actions { width: 100%; }
.log-dialog-actions .btn { flex: 1; }
.log-content { max-height: 55vh; padding: 0.9rem 1rem; }
}
@media (max-width: 380px) {
.task-card-body { grid-template-columns: 1fr; }
}
/* 减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}

16
webui/start.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/webui/backend"
VENV="$BACKEND/.venv"
if [[ ! -d "$VENV" ]]; then
python3 -m venv "$VENV"
"$VENV/bin/pip" install -r "$BACKEND/requirements.txt"
fi
HOST="${SPROUTCLAW_CRON_WEB_HOST:-0.0.0.0}"
PORT="${SPROUTCLAW_CRON_WEB_PORT:-8765}"
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT"