commit 6c2db2dfa34a597cf478c136240a09741e053dc2 Author: shumengya Date: Wed Jun 24 16:19:53 2026 +0800 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 后端 + 文档式响应式前端 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4c3bbe --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..480f90e --- /dev/null +++ b/AGENTS.md @@ -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 ` 试跑,确认日志正常 +5. 默认保持关闭;用户要求启用时再 `cronctl enable ` + +## 不要修改 + +- `_template/` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为 +- 不要删除或移动已有任务的 `schedule.cron` 里对 `cronctl.py run` 的调用方式 + +## 任务目录约定 + +``` +/ +├── run.py +├── schedule.cron +├── switch.sh # 可选 +├── logs/.log # 运行时自动创建 +└── *.json # 可选配置 +``` + +禁用任务位于 `.disabled//`,开关用 `cronctl enable|disable|toggle`。 + +## 管理命令 + +```bash +python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status +python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run +python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable +``` + +WebUI:`/shumengya/project/agent/sproutclaw-cron/webui/start.sh` diff --git a/README.md b/README.md new file mode 100644 index 0000000..8dea896 --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# sproutclaw-cron — 定时任务系统 + +树萌芽的定时任务管理框架。每个任务一目录,共享公共库,统一管理开关与日志。 + +--- + +## 目录结构 + +``` +/shumengya/project/agent/sproutclaw-cron/ +├── cronctl.py # 统一管理 CLI(enable/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) +├── / # 任务目录(启用状态) +│ ├── run.py # 任务入口(唯一必须文件) +│ ├── schedule.cron # 系统 cron 配置(复制到 /etc/cron.d/) +│ ├── switch.sh # 一键开关(可用 cronctl 替代) +│ ├── targets.json # 可选:任务自定义配置(JSON) +│ ├── logs/.log # 运行日志(自动创建) +│ └── .lock # flock 互斥锁文件(自动创建) +└── .disabled/ # 禁用任务统一存放目录 + └── / # 关闭的任务移入此处 +``` + +**任务 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/`;`disable` **只**把任务目录移入 `.disabled//`,**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但 `run.py` 检测到禁用后会直接跳过。 + +```bash +python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py sync-cron all # 仅同步 cron.d,不改开关 +``` + +--- + +## 开关机制 + +- **启用**:从 `.disabled//` 移回 `/` +- **关闭**:从 `/` 移入 `.disabled//` +- `run.py` 在入口检查 `task_is_disabled(ctx)`,若关闭直接 `return 0`,不写日志、不发通知、不获取锁 +- `/etc/cron.d/` 可一直保留;`schedule.cron` 通过 `cronctl.py run ` 调度 + +--- + +## 新建任务(模板) + +**从 `_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_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 `,则 `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 密钥对。 diff --git a/_template/README.md b/_template/README.md new file mode 100644 index 0000000..2fd6e33 --- /dev/null +++ b/_template/README.md @@ -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 ` | +| `switch.sh` | 可选,代理 `cronctl` 开关 | + +## run.py 约定 + +1. 入口第一行检查 `task_is_disabled(ctx)`,关闭时直接 `return 0` +2. 使用 `task_logging(ctx)` 写日志到 `logs/.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 >/dev/null 2>&1` +- 本模板默认 **每天 08:00**(`0 8 * * *`) + +## 任务 ID 命名 + +`<主机名>-<功能描述>`,例如 `smallmengya-gitea-repo-sync`。 diff --git a/_template/run.py b/_template/run.py new file mode 100644 index 0000000..c44deb5 --- /dev/null +++ b/_template/run.py @@ -0,0 +1,46 @@ +""" +Hello World 示例任务:每天 08:00 输出一句 hello world。 + +新建任务时复制整个 _template/ 目录为 /,更新 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()) diff --git a/_template/schedule.cron b/_template/schedule.cron new file mode 100644 index 0000000..f944dec --- /dev/null +++ b/_template/schedule.cron @@ -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 diff --git a/_template/switch.sh b/_template/switch.sh new file mode 100644 index 0000000..f5e4224 --- /dev/null +++ b/_template/switch.sh @@ -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" "$@" diff --git a/cronctl.py b/cronctl.py new file mode 100755 index 0000000..e15e250 --- /dev/null +++ b/cronctl.py @@ -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()) diff --git a/lib/shumengya_cron/__init__.py b/lib/shumengya_cron/__init__.py new file mode 100644 index 0000000..9c2ee98 --- /dev/null +++ b/lib/shumengya_cron/__init__.py @@ -0,0 +1,5 @@ +"""shumengya-cron:共用库(日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/lib/shumengya_cron/manager.py b/lib/shumengya_cron/manager.py new file mode 100644 index 0000000..e1b9928 --- /dev/null +++ b/lib/shumengya_cron/manager.py @@ -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: + """将旧的 .disabled/ 目录迁入 .disabled//。""" + 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" diff --git a/lib/shumengya_cron/notify.py b/lib/shumengya_cron/notify.py new file mode 100644 index 0000000..acb6b2e --- /dev/null +++ b/lib/shumengya_cron/notify.py @@ -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}") diff --git a/lib/shumengya_cron/runner.py b/lib/shumengya_cron/runner.py new file mode 100644 index 0000000..6e6ac11 --- /dev/null +++ b/lib/shumengya_cron/runner.py @@ -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// 目录。""" + 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") diff --git a/lib/shumengya_cron/ssh.py b/lib/shumengya_cron/ssh.py new file mode 100644 index 0000000..f31a067 --- /dev/null +++ b/lib/shumengya_cron/ssh.py @@ -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, + ) diff --git a/smallmengya-gitea-repo-sync/repos.json b/smallmengya-gitea-repo-sync/repos.json new file mode 100644 index 0000000..23f955a --- /dev/null +++ b/smallmengya-gitea-repo-sync/repos.json @@ -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"} +] diff --git a/smallmengya-gitea-repo-sync/run.py b/smallmengya-gitea-repo-sync/run.py new file mode 100644 index 0000000..3ec0cd5 --- /dev/null +++ b/smallmengya-gitea-repo-sync/run.py @@ -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.json,local 字段必填,缺失的条目直接跳过。""" + 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()) diff --git a/smallmengya-gitea-repo-sync/schedule.cron b/smallmengya-gitea-repo-sync/schedule.cron new file mode 100644 index 0000000..f7dd3a7 --- /dev/null +++ b/smallmengya-gitea-repo-sync/schedule.cron @@ -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 diff --git a/smallmengya-gitea-repo-sync/switch.sh b/smallmengya-gitea-repo-sync/switch.sh new file mode 100755 index 0000000..f5e4224 --- /dev/null +++ b/smallmengya-gitea-repo-sync/switch.sh @@ -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" "$@" diff --git a/webui/backend/main.py b/webui/backend/main.py new file mode 100644 index 0000000..5b8968d --- /dev/null +++ b/webui/backend/main.py @@ -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") diff --git a/webui/backend/requirements.txt b/webui/backend/requirements.txt new file mode 100644 index 0000000..3fb50f0 --- /dev/null +++ b/webui/backend/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 diff --git a/webui/frontend/app.js b/webui/frontend/app.js new file mode 100644 index 0000000..ef84c82 --- /dev/null +++ b/webui/frontend/app.js @@ -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) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[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 '运行中'; + } + if (task.enabled) { + return '已开启'; + } + return '已关闭'; +} + +function scheduleMarkup(task) { + return task.schedule + ? `${escapeHtml(task.schedule)}` + : ''; +} + +function logMarkup(task) { + const size = formatBytes(task.log_size); + const updated = task.log_updated + ? `${escapeHtml(task.log_updated)}` + : '无记录'; + return `
${size}
${updated}`; +} + +function actionButtons(task) { + const toggleLabel = task.enabled ? "关闭" : "开启"; + const toggleClass = task.enabled ? "btn-danger" : "btn-success"; + return ` +
+ + + + +
+ `; +} + +function nameMarkup(task) { + return ` +
${escapeHtml(task.task_id)}
+ ${task.description ? `
${escapeHtml(task.description)}
` : ""} + `; +} + +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 = ` +
+ 任务总数 + ${tasks.length} +
+
+ 已开启 + ${enabled} +
+
+ 已关闭 + ${off} +
+
+ 运行中 + ${running} +
+ `; + els.summaryText.textContent = running + ? `共 ${tasks.length} 个任务,${enabled} 个已开启,${running} 个正在运行` + : `共 ${tasks.length} 个任务,${enabled} 个已开启`; +} + +function renderTasks(tasks) { + if (!tasks.length) { + els.taskBody.innerHTML = + '没有匹配的任务'; + els.taskCards.innerHTML = '
没有匹配的任务
'; + return; + } + + els.taskBody.innerHTML = tasks + .map( + (task) => ` + + ${statusMarkup(task)} + ${nameMarkup(task)} + ${scheduleMarkup(task)} + ${logMarkup(task)} + ${actionButtons(task)} + + ` + ) + .join(""); + + els.taskCards.innerHTML = tasks + .map( + (task) => ` +
+
+
${nameMarkup(task)}
+ ${statusMarkup(task)} +
+
+
+ 调度 + ${scheduleMarkup(task)} +
+
+ 日志 + ${formatBytes(task.log_size)} + ${task.log_updated ? escapeHtml(task.log_updated) : "无记录"} +
+
+ ${actionButtons(task)} +
+ ` + ) + .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 = `${escapeHtml(err.message)}`; + els.taskCards.innerHTML = `
${escapeHtml(err.message)}
`; + 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)); diff --git a/webui/frontend/index.html b/webui/frontend/index.html new file mode 100644 index 0000000..b42baca --- /dev/null +++ b/webui/frontend/index.html @@ -0,0 +1,100 @@ + + + + + + SproutClaw Cron · 定时任务管理 + + + +
+ + + + +
+
+ +
+

定时任务管理

+

加载中…

+
+ +
+ +
+
+ +
+ + +
+ +
+
+

任务清单

+
+ +
+ + + + + + + + + + + + + +
状态任务调度日志操作
加载中…
+
+ +
+
+
+
+
+ + +
+
+ 运行日志 +

任务日志

+

+
+
+ + +
+
+

+  
+ + + + + + diff --git a/webui/frontend/styles.css b/webui/frontend/styles.css new file mode 100644 index 0000000..c15a8ab --- /dev/null +++ b/webui/frontend/styles.css @@ -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; } +} diff --git a/webui/start.sh b/webui/start.sh new file mode 100644 index 0000000..b1a966e --- /dev/null +++ b/webui/start.sh @@ -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"