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:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
49
AGENTS.md
Normal 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
255
README.md
Normal file
@@ -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)
|
||||
├── <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
49
_template/README.md
Normal 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
46
_template/run.py
Normal 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
9
_template/schedule.cron
Normal 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
8
_template/switch.sh
Normal 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
109
cronctl.py
Executable 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())
|
||||
5
lib/shumengya_cron/__init__.py
Normal file
5
lib/shumengya_cron/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""shumengya-cron:共用库(日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
204
lib/shumengya_cron/manager.py
Normal file
204
lib/shumengya_cron/manager.py
Normal 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"
|
||||
208
lib/shumengya_cron/notify.py
Normal file
208
lib/shumengya_cron/notify.py
Normal 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}")
|
||||
282
lib/shumengya_cron/runner.py
Normal file
282
lib/shumengya_cron/runner.py
Normal 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
25
lib/shumengya_cron/ssh.py
Normal 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,
|
||||
)
|
||||
21
smallmengya-gitea-repo-sync/repos.json
Normal file
21
smallmengya-gitea-repo-sync/repos.json
Normal 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"}
|
||||
]
|
||||
202
smallmengya-gitea-repo-sync/run.py
Normal file
202
smallmengya-gitea-repo-sync/run.py
Normal 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.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())
|
||||
10
smallmengya-gitea-repo-sync/schedule.cron
Normal file
10
smallmengya-gitea-repo-sync/schedule.cron
Normal 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
|
||||
8
smallmengya-gitea-repo-sync/switch.sh
Executable file
8
smallmengya-gitea-repo-sync/switch.sh
Executable 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
161
webui/backend/main.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""SproutClaw Cron 定时任务管理面板 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
CRON_ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
sys.path.insert(0, str(CRON_ROOT / "lib"))
|
||||
|
||||
from shumengya_cron.manager import ( # noqa: E402
|
||||
build_task_info,
|
||||
list_tasks,
|
||||
read_log_tail,
|
||||
set_task_state,
|
||||
sync_cron_d,
|
||||
toggle_task,
|
||||
)
|
||||
from shumengya_cron.manager import get_context # noqa: E402
|
||||
from shumengya_cron.runner import task_is_enabled # noqa: E402
|
||||
|
||||
app = FastAPI(title="SproutClaw Cron", version="1.0.0")
|
||||
|
||||
_run_jobs: dict[str, subprocess.Popen[bytes]] = {}
|
||||
_run_lock = threading.Lock()
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
pid: int | None = None
|
||||
|
||||
|
||||
def _task_not_found(exc: FileNotFoundError) -> HTTPException:
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
def _cleanup_run_job(task_id: str, proc: subprocess.Popen[bytes]) -> None:
|
||||
proc.wait()
|
||||
with _run_lock:
|
||||
current = _run_jobs.get(task_id)
|
||||
if current is proc:
|
||||
_run_jobs.pop(task_id, None)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def api_list_tasks() -> list[dict[str, object]]:
|
||||
return [task.to_dict() for task in list_tasks(CRON_ROOT)]
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
def api_get_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
return build_task_info(task_id, CRON_ROOT).to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/enable")
|
||||
def api_enable_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = set_task_state(task_id, True, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/disable")
|
||||
def api_disable_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = set_task_state(task_id, False, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/toggle")
|
||||
def api_toggle_task(task_id: str) -> dict[str, object]:
|
||||
try:
|
||||
info, _message = toggle_task(task_id, CRON_ROOT)
|
||||
return info.to_dict()
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/sync-cron", response_model=MessageResponse)
|
||||
def api_sync_cron(task_id: str) -> MessageResponse:
|
||||
try:
|
||||
ctx = get_context(task_id, CRON_ROOT)
|
||||
message = sync_cron_d(ctx, task_is_enabled(ctx)) or "已同步"
|
||||
return MessageResponse(message=message)
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run", response_model=RunResponse)
|
||||
def api_run_task(task_id: str) -> RunResponse:
|
||||
try:
|
||||
get_context(task_id, CRON_ROOT)
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
with _run_lock:
|
||||
existing = _run_jobs.get(task_id)
|
||||
if existing is not None and existing.poll() is None:
|
||||
return RunResponse(
|
||||
ok=False,
|
||||
message="任务已在后台运行",
|
||||
pid=existing.pid,
|
||||
)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(CRON_ROOT / "cronctl.py"), "run", task_id],
|
||||
cwd=str(CRON_ROOT),
|
||||
)
|
||||
_run_jobs[task_id] = proc
|
||||
threading.Thread(
|
||||
target=_cleanup_run_job,
|
||||
args=(task_id, proc),
|
||||
daemon=True,
|
||||
).start()
|
||||
return RunResponse(message="已在后台启动", pid=proc.pid)
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/log")
|
||||
def api_task_log(
|
||||
task_id: str,
|
||||
lines: int = Query(default=200, ge=1, le=2000),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
content = read_log_tail(task_id, lines, CRON_ROOT)
|
||||
return {"task_id": task_id, "content": content}
|
||||
except FileNotFoundError as exc:
|
||||
raise _task_not_found(exc) from exc
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
2
webui/backend/requirements.txt
Normal file
2
webui/backend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
303
webui/frontend/app.js
Normal file
303
webui/frontend/app.js
Normal 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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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
100
webui/frontend/index.html
Normal 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
566
webui/frontend/styles.css
Normal 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
16
webui/start.sh
Normal 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"
|
||||
Reference in New Issue
Block a user