init
This commit is contained in:
commit
537c54df14
|
|
@ -0,0 +1,9 @@
|
||||||
|
APP_NAME=ConnectHub
|
||||||
|
DATA_DIR=/data
|
||||||
|
DB_URL=sqlite:////data/connecthub.db
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
FERNET_KEY_PATH=/data/fernet.key
|
||||||
|
DEV_MODE=1
|
||||||
|
LOG_DIR=/data/logs
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
## ConnectHub 开发手册
|
||||||
|
|
||||||
|
ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成任务(Job),提供定时调度、执行监控与“一键重试”。
|
||||||
|
|
||||||
|
### 项目结构树
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── app
|
||||||
|
│ ├── admin
|
||||||
|
│ │ ├── routes.py
|
||||||
|
│ │ ├── templates
|
||||||
|
│ │ │ └── joblog_details.html
|
||||||
|
│ │ └── views.py
|
||||||
|
│ ├── core
|
||||||
|
│ │ ├── config.py
|
||||||
|
│ │ └── logging.py
|
||||||
|
│ ├── db
|
||||||
|
│ │ ├── crud.py
|
||||||
|
│ │ ├── engine.py
|
||||||
|
│ │ └── models.py
|
||||||
|
│ ├── integrations
|
||||||
|
│ │ └── base.py
|
||||||
|
│ ├── jobs
|
||||||
|
│ │ └── base.py
|
||||||
|
│ ├── plugins
|
||||||
|
│ │ └── manager.py
|
||||||
|
│ ├── security
|
||||||
|
│ │ └── fernet.py
|
||||||
|
│ ├── tasks
|
||||||
|
│ │ ├── celery_app.py
|
||||||
|
│ │ ├── dispatcher.py
|
||||||
|
│ │ └── execute.py
|
||||||
|
│ └── main.py
|
||||||
|
├── extensions
|
||||||
|
│ └── example
|
||||||
|
│ ├── client.py
|
||||||
|
│ └── job.py
|
||||||
|
├── docker
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── env.example
|
||||||
|
└── pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境与配置
|
||||||
|
|
||||||
|
- `env.example`:环境变量示例(由于环境限制,仓库中使用该文件名;本地运行时请手动创建 `.env` 并参考此文件)
|
||||||
|
- 关键变量:
|
||||||
|
- `DATA_DIR=/data`:容器内数据目录
|
||||||
|
- `DB_URL=sqlite:////data/connecthub.db`:SQLite DB 文件
|
||||||
|
- `REDIS_URL=redis://redis:6379/0`:Celery Broker/Backend
|
||||||
|
- `FERNET_KEY_PATH=/data/fernet.key`:Fernet key 文件(自动生成并持久化)
|
||||||
|
- `LOG_DIR=/data/logs`:日志目录(可选)
|
||||||
|
|
||||||
|
### 核心框架实现要点
|
||||||
|
|
||||||
|
#### BaseJob(插件规范)
|
||||||
|
|
||||||
|
- 位置:`app/jobs/base.py`
|
||||||
|
- 规范:插件必须实现 `run(params, secrets)`,其中:
|
||||||
|
- `params` 来自 `Job.public_cfg`(明文)
|
||||||
|
- `secrets` 来自 `Job.secret_cfg` 解密后的明文(仅内存)
|
||||||
|
|
||||||
|
#### BaseClient(适配器/SDK)
|
||||||
|
|
||||||
|
- 位置:`app/integrations/base.py`
|
||||||
|
- 规范:业务 Job 禁止直接写 HTTP;必须通过 Client 访问外部系统(统一超时、重试、日志)。
|
||||||
|
|
||||||
|
#### Security(Fernet 加解密)
|
||||||
|
|
||||||
|
- 位置:`app/security/fernet.py`
|
||||||
|
- 说明:
|
||||||
|
- `secret_cfg` 在数据库中保存 **Fernet 密文 token**
|
||||||
|
- Worker 执行前自动解密,仅在内存中传给 Job
|
||||||
|
- key 自动生成到 `FERNET_KEY_PATH`(默认 `/data/fernet.key`),volume 挂载后可持久化
|
||||||
|
|
||||||
|
#### PluginManager(动态加载)
|
||||||
|
|
||||||
|
- 位置:`app/plugins/manager.py`
|
||||||
|
- `handler_path` 推荐格式:`extensions.example.job:ExampleJob`
|
||||||
|
|
||||||
|
### 数据层与 Admin(SQLAdmin)
|
||||||
|
|
||||||
|
- 模型:`app/db/models.py`(Job / JobLog)
|
||||||
|
- Admin:
|
||||||
|
- `Job`:可视化增删改查
|
||||||
|
- `JobLog`:可视化查看执行日志(只读)
|
||||||
|
- `JobLog` 详情页自定义 `Retry` 按钮:点击后读取 `snapshot_params` 并触发重试
|
||||||
|
- 关键文件:
|
||||||
|
- `app/admin/views.py`:ModelView 定义;保存 Job 时自动加密 `secret_cfg`
|
||||||
|
- `app/admin/templates/joblog_details.html`:JobLog 详情模板覆盖,加入 Retry 按钮
|
||||||
|
- `app/admin/routes.py`:处理 Retry POST 并触发 Celery
|
||||||
|
|
||||||
|
### 任务引擎(Celery)
|
||||||
|
|
||||||
|
- Celery app:`app/tasks/celery_app.py`
|
||||||
|
- 调度:
|
||||||
|
- Beat 每分钟触发一次 `connecthub.dispatcher.tick`
|
||||||
|
- `dispatcher.tick` 读取 DB Jobs,根据 `cron_expr` 到点触发 `connecthub.execute_job`
|
||||||
|
- 执行:
|
||||||
|
- `app/tasks/execute.py` 的 `execute_job`:读库/解密/加载插件/执行/写 JobLog(含异常堆栈)
|
||||||
|
|
||||||
|
### 运行指南
|
||||||
|
|
||||||
|
1. 在仓库根目录创建 `.env`(参考 `env.example`)
|
||||||
|
2. 生产模式启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 打开 Admin:
|
||||||
|
- `http://localhost:8000/admin`
|
||||||
|
4. 创建一个示例 Job(ExampleJob):
|
||||||
|
- `id`: `example.hello`
|
||||||
|
- `cron_expr`: `* * * * *`(每分钟)
|
||||||
|
- `handler_path`: `extensions.example.job:ExampleJob`
|
||||||
|
- `public_cfg`: `{"name":"Mars"}`
|
||||||
|
- `secret_cfg`: `{"token":"demo-token"}`(保存时自动加密落库)
|
||||||
|
5. 等待 Beat 触发执行,或在 JobLog 里查看结果;若失败/想复跑,在 JobLog 详情页点击 **Retry**。
|
||||||
|
|
||||||
|
### 开发模式(实时更新代码)
|
||||||
|
|
||||||
|
开发阶段可以使用 dev compose 叠加文件,实现:
|
||||||
|
- `backend`:`uvicorn --reload`
|
||||||
|
- `worker/beat`:监听代码变更后自动重启进程加载新代码
|
||||||
|
|
||||||
|
启动命令(二选一):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式 A:直接 docker compose 叠加
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
|
# 方式 B:使用脚本
|
||||||
|
./connecthub.sh dev-build
|
||||||
|
./connecthub.sh dev-start
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境请只使用 `docker-compose.yml`(不挂载源码、不启用 reload/watch),发布通过重新 build 镜像完成。
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
#
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,37 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.db import crud
|
||||||
|
from app.db.engine import get_session
|
||||||
|
from app.tasks.execute import execute_job
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/joblogs/{log_id}/retry")
|
||||||
|
def retry_joblog(request: Request, log_id: int):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
log = crud.get_job_log(session, log_id)
|
||||||
|
if not log:
|
||||||
|
raise HTTPException(status_code=404, detail="JobLog not found")
|
||||||
|
# 关键:用 snapshot_params 重新触发任务(其中 secret_cfg 仍为密文)
|
||||||
|
execute_job.delay(snapshot_params=log.snapshot_params)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
referer = request.headers.get("Referer") or "/admin"
|
||||||
|
return RedirectResponse(referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/jobs/{job_id}/run")
|
||||||
|
def run_job(request: Request, job_id: str):
|
||||||
|
# 触发一次立即执行
|
||||||
|
execute_job.delay(job_id=job_id)
|
||||||
|
referer = request.headers.get("Referer") or "/admin"
|
||||||
|
return RedirectResponse(referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "sqladmin/details.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 仅调整详情页顶部动作按钮间距(Go Back/Delete/Edit/自定义 Action) */
|
||||||
|
a.btn + a.btn,
|
||||||
|
a.btn + button.btn,
|
||||||
|
button.btn + a.btn,
|
||||||
|
button.btn + button.btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
{% extends "sqladmin/list.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.connecthub-run-form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-grow-1 me-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">{{ model_view.name_plural }}</h3>
|
||||||
|
<div class="ms-auto">
|
||||||
|
{% if model_view.can_export %}
|
||||||
|
{% if model_view.export_types | length > 1 %}
|
||||||
|
<div class="ms-3 d-inline-block dropdown">
|
||||||
|
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||||
|
{% for export_type in model_view.export_types %}
|
||||||
|
<li><a class="dropdown-item"
|
||||||
|
href="{{ url_for('admin:export', identity=model_view.identity, export_type=export_type) }}">{{
|
||||||
|
export_type | upper }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% elif model_view.export_types | length == 1 %}
|
||||||
|
<div class="ms-3 d-inline-block">
|
||||||
|
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
|
||||||
|
class="btn btn-secondary">
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_create %}
|
||||||
|
<div class="ms-3 d-inline-block">
|
||||||
|
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||||
|
+ New {{ model_view.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body border-bottom py-3">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="dropdown col-4">
|
||||||
|
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||||
|
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||||
|
aria-haspopup="true" aria-expanded="false">
|
||||||
|
Actions
|
||||||
|
</button>
|
||||||
|
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
||||||
|
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
||||||
|
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-delete">Delete selected items</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="dropdown-item" id="action-custom-{{ custom_action }}" href="#"
|
||||||
|
data-url="{{ model_view._url_for_action(request, custom_action) }}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if model_view.column_searchable_list %}
|
||||||
|
<div class="col-md-4 text-muted">
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="search-input" type="text" class="form-control"
|
||||||
|
placeholder="Search: {{ model_view.search_placeholder() }}"
|
||||||
|
value="{{ request.query_params.get('search', '') }}">
|
||||||
|
<button id="search-button" class="btn" type="button">Search</button>
|
||||||
|
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
|
||||||
|
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table card-table table-vcenter text-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox" aria-label="Select all"
|
||||||
|
id="select-all"></th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
{% for name in model_view._list_prop_names %}
|
||||||
|
{% set label = model_view._column_labels.get(name, name) %}
|
||||||
|
<th>
|
||||||
|
{% if name in model_view._sort_fields %}
|
||||||
|
{% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %}
|
||||||
|
<a href="{{ request.url.include_query_params(sort='desc') }}"><i class="fa-solid fa-arrow-up"></i> {{
|
||||||
|
label }}</a>
|
||||||
|
{% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %}
|
||||||
|
<a href="{{ request.url.include_query_params(sort='asc') }}"><i class="fa-solid fa-arrow-down"></i> {{ label
|
||||||
|
}}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ request.url.include_query_params(sortBy=name, sort='asc') }}">{{ label }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ label }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Run Now</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in pagination.rows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="hidden" value="{{ get_object_identifier(row) }}">
|
||||||
|
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if model_view.can_view_details %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top" title="View">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_edit %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top" title="Edit">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
||||||
|
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-delete" title="Delete">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-trash"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% for name in model_view._list_prop_names %}
|
||||||
|
{% set value, formatted_value = model_view.get_list_value(row, name) %}
|
||||||
|
{% if name in model_view._relation_names %}
|
||||||
|
{% if is_list( value ) %}
|
||||||
|
<td>
|
||||||
|
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
||||||
|
{% if model_view.show_compact_lists %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td><a href="{{ model_view._url_for_details_with_prop(request, row, name) }}">{{ formatted_value }}</a></td>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<td>{{ formatted_value }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<form class="connecthub-run-form" method="post" action="/admin/jobs/{{ get_object_identifier(row) }}/run" onsubmit="return confirm('Run this job now?');">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Run Now</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to
|
||||||
|
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count
|
||||||
|
}}</span> items
|
||||||
|
</p>
|
||||||
|
<ul class="pagination m-0 ms-auto">
|
||||||
|
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
|
||||||
|
{% if pagination.has_previous %}
|
||||||
|
<a class="page-link" href="{{ pagination.previous_page.url }}">
|
||||||
|
{% else %}
|
||||||
|
<a class="page-link" href="#">
|
||||||
|
{% endif %}
|
||||||
|
<i class="fa-solid fa-chevron-left"></i>
|
||||||
|
prev
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for page_control in pagination.page_controls %}
|
||||||
|
<li class="page-item {% if page_control.number == pagination.page %}active{% endif %}"><a class="page-link"
|
||||||
|
href="{{ page_control.url }}">{{ page_control.number }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="page-link" href="{{ pagination.next_page.url }}">
|
||||||
|
{% else %}
|
||||||
|
<a class="page-link" href="#">
|
||||||
|
{% endif %}
|
||||||
|
next
|
||||||
|
<i class="fa-solid fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="dropdown text-muted">
|
||||||
|
Show
|
||||||
|
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
|
||||||
|
aria-expanded="false">
|
||||||
|
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
{% for page_size_option in model_view.page_size_options %}
|
||||||
|
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
|
||||||
|
{{ page_size_option }} / Page
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model_view.get_filters() %}
|
||||||
|
<div class="col-md-3" style="width: 300px; flex-shrink: 0;">
|
||||||
|
<div id="filter-sidebar" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Filters</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for filter in model_view.get_filters() %}
|
||||||
|
{% if filter.has_operator %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||||
|
<div>
|
||||||
|
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
||||||
|
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
||||||
|
{% if current_filter %}
|
||||||
|
<div class="mb-2 text-muted small">
|
||||||
|
Current: {{ current_op }} {{ current_filter }}
|
||||||
|
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
||||||
|
{% for key, value in request.query_params.items() %}
|
||||||
|
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
||||||
|
<option value="">Select operation...</option>
|
||||||
|
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
||||||
|
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text"
|
||||||
|
name="{{ filter.parameter_name }}"
|
||||||
|
placeholder="Enter value"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
value="{{ current_filter }}"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||||
|
<div>
|
||||||
|
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
||||||
|
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
||||||
|
{{ lookup[1] }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for custom_action in model_view._custom_actions_in_list %}
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||||
|
url=model_view._url_for_action(request, custom_action) %}
|
||||||
|
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
{% extends "sqladmin/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* 调整详情页顶部动作按钮间距(Go Back/Retry/Delete/Edit/自定义 Action) */
|
||||||
|
.connecthub-action-row .btn {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">
|
||||||
|
{% for pk in model_view.pk_columns -%}
|
||||||
|
{{ pk.name }}
|
||||||
|
{%- if not loop.last %};{% endif -%}
|
||||||
|
{% endfor %}: {{ get_object_identifier(model) }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body border-bottom py-3">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table card-table table-vcenter text-nowrap table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1">Column</th>
|
||||||
|
<th class="w-1">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for name in model_view._details_prop_names %}
|
||||||
|
{% set label = model_view._column_labels.get(name, name) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
{% set value, formatted_value = model_view.get_detail_value(model, name) %}
|
||||||
|
{% if name in model_view._relation_names %}
|
||||||
|
{% if is_list( value ) %}
|
||||||
|
<td>
|
||||||
|
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
||||||
|
{% if model_view.show_compact_lists %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td><a href="{{ model_view._url_for_details_with_prop(request, model, name) }}">{{ formatted_value }}</a></td>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<td>{{ formatted_value }}</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer container">
|
||||||
|
<div class="row connecthub-action-row">
|
||||||
|
<div class="col-md-1">
|
||||||
|
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
|
||||||
|
Go Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<form method="post" action="/admin/joblogs/{{ get_object_identifier(model) }}/retry" style="display:inline;" onsubmit="return confirm('Retry this job log?');">
|
||||||
|
<button type="submit" class="btn btn-warning">Retry</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
<div class="col-md-1">
|
||||||
|
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
||||||
|
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-delete" class="btn btn-danger">
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_edit %}
|
||||||
|
<div class="col-md-1">
|
||||||
|
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
||||||
|
<div class="col-md-1">
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ model_view._url_for_action(request, custom_action) }}?pks={{ get_object_identifier(model) }}"
|
||||||
|
class="btn btn-secondary">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for custom_action in model_view._custom_actions_in_detail %}
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||||
|
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
||||||
|
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
{% extends "sqladmin/list.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.connecthub-retry-form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-grow-1 me-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">{{ model_view.name_plural }}</h3>
|
||||||
|
<div class="ms-auto">
|
||||||
|
{% if model_view.can_export %}
|
||||||
|
{% if model_view.export_types | length > 1 %}
|
||||||
|
<div class="ms-3 d-inline-block dropdown">
|
||||||
|
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||||
|
{% for export_type in model_view.export_types %}
|
||||||
|
<li><a class="dropdown-item"
|
||||||
|
href="{{ url_for('admin:export', identity=model_view.identity, export_type=export_type) }}">{{
|
||||||
|
export_type | upper }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% elif model_view.export_types | length == 1 %}
|
||||||
|
<div class="ms-3 d-inline-block">
|
||||||
|
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
|
||||||
|
class="btn btn-secondary">
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_create %}
|
||||||
|
<div class="ms-3 d-inline-block">
|
||||||
|
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||||
|
+ New {{ model_view.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body border-bottom py-3">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="dropdown col-4">
|
||||||
|
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||||
|
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||||
|
aria-haspopup="true" aria-expanded="false">
|
||||||
|
Actions
|
||||||
|
</button>
|
||||||
|
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
||||||
|
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
||||||
|
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-delete">Delete selected items</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="dropdown-item" id="action-custom-{{ custom_action }}" href="#"
|
||||||
|
data-url="{{ model_view._url_for_action(request, custom_action) }}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if model_view.column_searchable_list %}
|
||||||
|
<div class="col-md-4 text-muted">
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="search-input" type="text" class="form-control"
|
||||||
|
placeholder="Search: {{ model_view.search_placeholder() }}"
|
||||||
|
value="{{ request.query_params.get('search', '') }}">
|
||||||
|
<button id="search-button" class="btn" type="button">Search</button>
|
||||||
|
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
|
||||||
|
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table card-table table-vcenter text-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox" aria-label="Select all"
|
||||||
|
id="select-all"></th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
{% for name in model_view._list_prop_names %}
|
||||||
|
{% set label = model_view._column_labels.get(name, name) %}
|
||||||
|
<th>
|
||||||
|
{% if name in model_view._sort_fields %}
|
||||||
|
{% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %}
|
||||||
|
<a href="{{ request.url.include_query_params(sort='desc') }}"><i class="fa-solid fa-arrow-up"></i> {{
|
||||||
|
label }}</a>
|
||||||
|
{% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %}
|
||||||
|
<a href="{{ request.url.include_query_params(sort='asc') }}"><i class="fa-solid fa-arrow-down"></i> {{ label
|
||||||
|
}}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ request.url.include_query_params(sortBy=name, sort='asc') }}">{{ label }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ label }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Retry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in pagination.rows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="hidden" value="{{ get_object_identifier(row) }}">
|
||||||
|
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if model_view.can_view_details %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top" title="View">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_edit %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top" title="Edit">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
||||||
|
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-delete" title="Delete">
|
||||||
|
<span class="me-1"><i class="fa-solid fa-trash"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% for name in model_view._list_prop_names %}
|
||||||
|
{% set value, formatted_value = model_view.get_list_value(row, name) %}
|
||||||
|
{% if name in model_view._relation_names %}
|
||||||
|
{% if is_list( value ) %}
|
||||||
|
<td>
|
||||||
|
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
||||||
|
{% if model_view.show_compact_lists %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td><a href="{{ model_view._url_for_details_with_prop(request, row, name) }}">{{ formatted_value }}</a></td>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<td>{{ formatted_value }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<form class="connecthub-retry-form" method="post" action="/admin/joblogs/{{ get_object_identifier(row) }}/retry" onsubmit="return confirm('Retry this job log?');">
|
||||||
|
<button type="submit" class="btn btn-warning btn-sm">Retry</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to
|
||||||
|
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count
|
||||||
|
}}</span> items
|
||||||
|
</p>
|
||||||
|
<ul class="pagination m-0 ms-auto">
|
||||||
|
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
|
||||||
|
{% if pagination.has_previous %}
|
||||||
|
<a class="page-link" href="{{ pagination.previous_page.url }}">
|
||||||
|
{% else %}
|
||||||
|
<a class="page-link" href="#">
|
||||||
|
{% endif %}
|
||||||
|
<i class="fa-solid fa-chevron-left"></i>
|
||||||
|
prev
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for page_control in pagination.page_controls %}
|
||||||
|
<li class="page-item {% if page_control.number == pagination.page %}active{% endif %}"><a class="page-link"
|
||||||
|
href="{{ page_control.url }}">{{ page_control.number }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="page-link" href="{{ pagination.next_page.url }}">
|
||||||
|
{% else %}
|
||||||
|
<a class="page-link" href="#">
|
||||||
|
{% endif %}
|
||||||
|
next
|
||||||
|
<i class="fa-solid fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="dropdown text-muted">
|
||||||
|
Show
|
||||||
|
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
|
||||||
|
aria-expanded="false">
|
||||||
|
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
{% for page_size_option in model_view.page_size_options %}
|
||||||
|
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
|
||||||
|
{{ page_size_option }} / Page
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model_view.get_filters() %}
|
||||||
|
<div class="col-md-3" style="width: 300px; flex-shrink: 0;">
|
||||||
|
<div id="filter-sidebar" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Filters</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for filter in model_view.get_filters() %}
|
||||||
|
{% if filter.has_operator %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||||
|
<div>
|
||||||
|
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
||||||
|
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
||||||
|
{% if current_filter %}
|
||||||
|
<div class="mb-2 text-muted small">
|
||||||
|
Current: {{ current_op }} {{ current_filter }}
|
||||||
|
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
||||||
|
{% for key, value in request.query_params.items() %}
|
||||||
|
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
||||||
|
<option value="">Select operation...</option>
|
||||||
|
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
||||||
|
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text"
|
||||||
|
name="{{ filter.parameter_name }}"
|
||||||
|
placeholder="Enter value"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
value="{{ current_filter }}"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||||
|
<div>
|
||||||
|
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
||||||
|
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
||||||
|
{{ lookup[1] }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model_view.can_delete %}
|
||||||
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for custom_action in model_view._custom_actions_in_list %}
|
||||||
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
|
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||||
|
url=model_view._url_for_action(request, custom_action) %}
|
||||||
|
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from croniter import croniter
|
||||||
|
from markupsafe import Markup
|
||||||
|
from sqladmin import ModelView, action
|
||||||
|
from sqladmin.models import Request
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.db.models import Job, JobLog
|
||||||
|
from app.plugins.manager import load_job_class
|
||||||
|
from app.security.fernet import encrypt_json
|
||||||
|
from app.tasks.execute import execute_job
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_json(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
if not s:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt_seconds(dt: datetime | None) -> str:
|
||||||
|
if not dt:
|
||||||
|
return ""
|
||||||
|
# DB 中保存的时间多为 naive;按 UTC 解释后转换为 Asia/Shanghai 展示
|
||||||
|
tz = ZoneInfo("Asia/Shanghai")
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
return dt.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(s: str, n: int = 120) -> str:
|
||||||
|
s = s or ""
|
||||||
|
return (s[: n - 3] + "...") if len(s) > n else s
|
||||||
|
|
||||||
|
|
||||||
|
class JobAdmin(ModelView, model=Job):
|
||||||
|
name = "Job"
|
||||||
|
name_plural = "Jobs"
|
||||||
|
icon = "fa fa-cogs"
|
||||||
|
|
||||||
|
column_list = [Job.id, Job.enabled, Job.cron_expr, Job.handler_path, Job.updated_at]
|
||||||
|
column_details_list = [
|
||||||
|
Job.id,
|
||||||
|
Job.enabled,
|
||||||
|
Job.cron_expr,
|
||||||
|
Job.handler_path,
|
||||||
|
Job.public_cfg,
|
||||||
|
Job.secret_cfg,
|
||||||
|
Job.last_run_at,
|
||||||
|
Job.created_at,
|
||||||
|
Job.updated_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
# 允许在表单中编辑主键(创建 Job 必填)
|
||||||
|
form_include_pk = True
|
||||||
|
form_columns = [Job.id, Job.enabled, Job.cron_expr, Job.handler_path, Job.public_cfg, Job.secret_cfg]
|
||||||
|
|
||||||
|
# 为 Job 详情页指定模板(用于调整按钮间距)
|
||||||
|
details_template = "job_details.html"
|
||||||
|
|
||||||
|
# 列表页模板:加入每行 Run Now
|
||||||
|
list_template = "job_list.html"
|
||||||
|
|
||||||
|
@action(
|
||||||
|
name="run_now",
|
||||||
|
label="Run Now",
|
||||||
|
confirmation_message="Trigger this job now?",
|
||||||
|
add_in_list=True,
|
||||||
|
add_in_detail=True,
|
||||||
|
)
|
||||||
|
async def run_now(self, request: Request): # type: ignore[override]
|
||||||
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
|
for pk in [p for p in pks if p]:
|
||||||
|
execute_job.delay(job_id=pk)
|
||||||
|
referer = request.headers.get("Referer")
|
||||||
|
return RedirectResponse(referer or request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||||
|
|
||||||
|
async def on_model_change(self, data: dict, model: Job, is_created: bool, request) -> None: # type: ignore[override]
|
||||||
|
# id 必填(避免插入时触发 NOT NULL)
|
||||||
|
raw_id = data.get("id") if is_created else (data.get("id") or getattr(model, "id", None))
|
||||||
|
if raw_id is None or not str(raw_id).strip():
|
||||||
|
raise ValueError("id is required")
|
||||||
|
|
||||||
|
# handler_path 强校验:必须可 import 且继承 BaseJob
|
||||||
|
handler_path = data.get("handler_path") if is_created else (data.get("handler_path") or model.handler_path)
|
||||||
|
if handler_path is None or not str(handler_path).strip():
|
||||||
|
raise ValueError("handler_path is required")
|
||||||
|
load_job_class(str(handler_path).strip())
|
||||||
|
|
||||||
|
# cron_expr 校验:必须是合法 cron 表达式
|
||||||
|
cron_expr = data.get("cron_expr") if is_created else (data.get("cron_expr") or model.cron_expr)
|
||||||
|
if cron_expr is None or not str(cron_expr).strip():
|
||||||
|
raise ValueError("cron_expr is required")
|
||||||
|
base = datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||||
|
itr = croniter(str(cron_expr).strip(), base)
|
||||||
|
_ = itr.get_next(datetime)
|
||||||
|
|
||||||
|
# public_cfg 允许以 JSON 字符串输入
|
||||||
|
pcfg = _maybe_json(data.get("public_cfg"))
|
||||||
|
if isinstance(pcfg, str):
|
||||||
|
raise ValueError("public_cfg must be a JSON object")
|
||||||
|
if isinstance(pcfg, dict):
|
||||||
|
data["public_cfg"] = pcfg
|
||||||
|
|
||||||
|
# secret_cfg:若用户输入 JSON 字符串,则自动加密落库;若输入已是 token,则原样保存
|
||||||
|
scfg = data.get("secret_cfg", "")
|
||||||
|
if scfg is None:
|
||||||
|
data["secret_cfg"] = ""
|
||||||
|
return
|
||||||
|
if isinstance(scfg, str):
|
||||||
|
s = scfg.strip()
|
||||||
|
if not s:
|
||||||
|
data["secret_cfg"] = ""
|
||||||
|
return
|
||||||
|
parsed = _maybe_json(s)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
data["secret_cfg"] = encrypt_json(parsed)
|
||||||
|
else:
|
||||||
|
# 非 JSON:视为已加密 token
|
||||||
|
data["secret_cfg"] = s
|
||||||
|
return
|
||||||
|
if isinstance(scfg, dict):
|
||||||
|
data["secret_cfg"] = encrypt_json(scfg)
|
||||||
|
return
|
||||||
|
raise ValueError("secret_cfg must be JSON object or encrypted token string")
|
||||||
|
|
||||||
|
|
||||||
|
class JobLogAdmin(ModelView, model=JobLog):
|
||||||
|
name = "JobLog"
|
||||||
|
name_plural = "JobLogs"
|
||||||
|
icon = "fa fa-list"
|
||||||
|
|
||||||
|
can_create = False
|
||||||
|
can_edit = False
|
||||||
|
can_delete = False
|
||||||
|
|
||||||
|
# 列表更适合扫读:保留关键字段 + message(截断)
|
||||||
|
column_list = [JobLog.id, JobLog.job_id, JobLog.status, JobLog.started_at, JobLog.finished_at, JobLog.message]
|
||||||
|
# 默认按 started_at 倒序(最新在前)
|
||||||
|
column_default_sort = [(JobLog.started_at, True)]
|
||||||
|
column_details_list = [
|
||||||
|
JobLog.id,
|
||||||
|
JobLog.job_id,
|
||||||
|
JobLog.status,
|
||||||
|
JobLog.snapshot_params,
|
||||||
|
JobLog.message,
|
||||||
|
JobLog.traceback,
|
||||||
|
JobLog.run_log,
|
||||||
|
JobLog.celery_task_id,
|
||||||
|
JobLog.attempt,
|
||||||
|
JobLog.started_at,
|
||||||
|
JobLog.finished_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
# 列表页模板:加入每行 Retry
|
||||||
|
list_template = "joblog_list.html"
|
||||||
|
# 为 JobLog 详情页单独指定模板(用于加入 Retry 按钮)
|
||||||
|
details_template = "joblog_details.html"
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at),
|
||||||
|
JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at),
|
||||||
|
JobLog.message: lambda m, a: _truncate(m.message, 120),
|
||||||
|
}
|
||||||
|
|
||||||
|
column_formatters_detail = {
|
||||||
|
JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at),
|
||||||
|
JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at),
|
||||||
|
JobLog.traceback: lambda m, a: Markup(f"<pre style='white-space:pre-wrap'>{m.traceback or ''}</pre>"),
|
||||||
|
JobLog.run_log: lambda m, a: Markup(
|
||||||
|
"<pre style='max-height:480px;overflow:auto;white-space:pre-wrap'>"
|
||||||
|
+ (m.run_log or "")
|
||||||
|
+ "</pre>"
|
||||||
|
),
|
||||||
|
JobLog.snapshot_params: lambda m, a: Markup(
|
||||||
|
"<pre style='white-space:pre-wrap'>"
|
||||||
|
+ json.dumps(m.snapshot_params or {}, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
+ "</pre>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,20 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "ConnectHub"
|
||||||
|
data_dir: str = "/data"
|
||||||
|
db_url: str = "sqlite:////data/connecthub.db"
|
||||||
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
fernet_key_path: str = "/data/fernet.key"
|
||||||
|
dev_mode: bool = False
|
||||||
|
log_dir: str | None = "/data/logs"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Callable, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
class SafeBufferingHandler(logging.Handler):
|
||||||
|
"""
|
||||||
|
只用于“尽力捕获”运行日志:
|
||||||
|
- emit 内部全 try/except,任何异常都吞掉,绝不影响任务执行
|
||||||
|
- 有最大字节限制,超过后写入截断标记并停止追加
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, max_bytes: int = 200_000, level: int = logging.INFO) -> None:
|
||||||
|
super().__init__(level=level)
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self._buf: list[str] = []
|
||||||
|
self._size_bytes = 0
|
||||||
|
self._truncated = False
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None: # noqa: D401
|
||||||
|
try:
|
||||||
|
if self._truncated:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
line = msg + "\n"
|
||||||
|
try:
|
||||||
|
b = line.encode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._size_bytes + len(b) > self.max_bytes:
|
||||||
|
self._buf.append("[TRUNCATED] run_log exceeded max_bytes\n")
|
||||||
|
self._truncated = True
|
||||||
|
return
|
||||||
|
|
||||||
|
self._buf.append(line)
|
||||||
|
self._size_bytes += len(b)
|
||||||
|
except Exception:
|
||||||
|
# 双保险:任何异常都不能冒泡
|
||||||
|
return
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
try:
|
||||||
|
return "".join(self._buf)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def capture_logs(*, max_bytes: int = 200_000) -> Iterator[Callable[[], str]]:
|
||||||
|
"""
|
||||||
|
捕获当前进程(root logger)输出的日志文本。
|
||||||
|
任何问题都不应影响业务执行。
|
||||||
|
"""
|
||||||
|
root = logging.getLogger()
|
||||||
|
handler = SafeBufferingHandler(max_bytes=max_bytes)
|
||||||
|
handler.setLevel(logging.INFO)
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter(fmt="%(asctime)s %(levelname)s %(name)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
root.addHandler(handler)
|
||||||
|
except Exception:
|
||||||
|
# 无法挂载则降级为空
|
||||||
|
yield lambda: ""
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield handler.get_text
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
logger = logging.getLogger()
|
||||||
|
if getattr(logger, "_connecthub_configured", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(formatter)
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
|
if settings.log_dir:
|
||||||
|
os.makedirs(settings.log_dir, exist_ok=True)
|
||||||
|
fh = RotatingFileHandler(
|
||||||
|
os.path.join(settings.log_dir, "connecthub.log"),
|
||||||
|
maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=5,
|
||||||
|
)
|
||||||
|
fh.setFormatter(formatter)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
setattr(logger, "_connecthub_configured", True)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,64 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import Job, JobLog, JobStatus
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(session: Session, job_id: str) -> Job | None:
|
||||||
|
return session.get(Job, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_enabled_jobs(session: Session) -> list[Job]:
|
||||||
|
return list(session.scalars(select(Job).where(Job.enabled.is_(True))))
|
||||||
|
|
||||||
|
|
||||||
|
def update_job_last_run_at(session: Session, job_id: str, dt: datetime) -> None:
|
||||||
|
job = session.get(Job, job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
job.last_run_at = dt
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def create_job_log(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
status: JobStatus,
|
||||||
|
snapshot_params: dict[str, Any],
|
||||||
|
message: str = "",
|
||||||
|
traceback: str = "",
|
||||||
|
run_log: str = "",
|
||||||
|
celery_task_id: str = "",
|
||||||
|
attempt: int = 0,
|
||||||
|
started_at: datetime | None = None,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
) -> JobLog:
|
||||||
|
log = JobLog(
|
||||||
|
job_id=job_id,
|
||||||
|
status=status,
|
||||||
|
snapshot_params=snapshot_params,
|
||||||
|
message=message,
|
||||||
|
traceback=traceback,
|
||||||
|
run_log=run_log,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
attempt=attempt,
|
||||||
|
started_at=started_at or datetime.utcnow(),
|
||||||
|
finished_at=finished_at,
|
||||||
|
)
|
||||||
|
session.add(log)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_log(session: Session, log_id: int) -> JobLog | None:
|
||||||
|
return session.get(JobLog, log_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.db_url,
|
||||||
|
connect_args={"check_same_thread": False} if settings.db_url.startswith("sqlite") else {},
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, class_=Session, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Session:
|
||||||
|
return SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
cron_expr: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
handler_path: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
public_cfg: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
# 密文 token(Fernet 加密后的字符串)
|
||||||
|
secret_cfg: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
logs: Mapped[list["JobLog"]] = relationship(back_populates="job", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, enum.Enum):
|
||||||
|
SUCCESS = "SUCCESS"
|
||||||
|
FAILURE = "FAILURE"
|
||||||
|
RETRY = "RETRY"
|
||||||
|
|
||||||
|
|
||||||
|
class JobLog(Base):
|
||||||
|
__tablename__ = "job_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
status: Mapped[JobStatus] = mapped_column(
|
||||||
|
Enum(JobStatus, native_enum=False, length=16),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
snapshot_params: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
traceback: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
# 本次执行期间捕获到的完整运行日志(可能很长,按上层捕获器做截断)
|
||||||
|
run_log: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
celery_task_id: Mapped[str] = mapped_column(String, default="", nullable=False)
|
||||||
|
attempt: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
job: Mapped[Job] = relationship(back_populates="logs")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Engine, text
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(conn, table: str, col: str) -> bool:
|
||||||
|
rows = conn.execute(text(f"PRAGMA table_info({table})")).fetchall()
|
||||||
|
return any(r[1] == col for r in rows) # PRAGMA columns: (cid, name, type, notnull, dflt_value, pk)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(engine: Engine) -> None:
|
||||||
|
"""
|
||||||
|
SQLite 轻量自升级:
|
||||||
|
- create_all 不会更新既有表结构,因此用 PRAGMA + ALTER TABLE 补列
|
||||||
|
- 必须保证任何失败都不影响主流程(上层可选择忽略异常)
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# job_logs.run_log
|
||||||
|
if not _has_column(conn, "job_logs", "run_log"):
|
||||||
|
conn.execute(text("ALTER TABLE job_logs ADD COLUMN run_log TEXT NOT NULL DEFAULT ''"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""系统集成适配器"""
|
||||||
|
|
||||||
|
from app.integrations.base import BaseClient
|
||||||
|
|
||||||
|
__all__ = ["BaseClient"]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("connecthub.integrations")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseClient:
|
||||||
|
"""
|
||||||
|
统一的外部系统访问 SDK 基类。
|
||||||
|
业务 Job 禁止直接写 HTTP,只能调用 integrations 下的 Client。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
retries: int = 2,
|
||||||
|
retry_backoff_s: float = 0.5,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.retries = retries
|
||||||
|
self.retry_backoff_s = retry_backoff_s
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=httpx.Timeout(self.timeout_s),
|
||||||
|
headers=self.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||||
|
url = path if path.startswith("/") else f"/{path}"
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(self.retries + 1):
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
resp = self._client.request(method=method, url=url, **kwargs)
|
||||||
|
elapsed_ms = int((time.time() - start) * 1000)
|
||||||
|
logger.info("HTTP %s %s -> %s (%sms)", method, url, resp.status_code, elapsed_ms)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
except Exception as e: # noqa: BLE001 (framework-wide)
|
||||||
|
last_exc = e
|
||||||
|
logger.warning("HTTP failed (%s %s) attempt=%s err=%r", method, url, attempt + 1, e)
|
||||||
|
if attempt < self.retries:
|
||||||
|
time.sleep(self.retry_backoff_s * (2**attempt))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
assert last_exc is not None
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
def get_json(self, path: str, **kwargs: Any) -> Any:
|
||||||
|
return self.request("GET", path, **kwargs).json()
|
||||||
|
|
||||||
|
def post_json(self, path: str, json: Any = None, **kwargs: Any) -> Any:
|
||||||
|
return self.request("POST", path, json=json, **kwargs).json()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,21 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class BaseJob(ABC):
|
||||||
|
"""
|
||||||
|
插件 Job 基类:框架层只负责加载与调度。
|
||||||
|
|
||||||
|
- params: 来自 Job.public_cfg(明文 JSON)
|
||||||
|
- secrets: 来自 Job.secret_cfg 解密后的明文 dict(仅在内存中存在)
|
||||||
|
"""
|
||||||
|
|
||||||
|
job_id: str | None = None
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def run(self, params: dict[str, Any], secrets: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqladmin import Admin
|
||||||
|
|
||||||
|
from app.admin.routes import router as admin_router
|
||||||
|
from app.admin.views import JobAdmin, JobLogAdmin
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import setup_logging
|
||||||
|
from app.db.engine import engine
|
||||||
|
from app.db.schema import ensure_schema
|
||||||
|
from app.security.fernet import get_or_create_fernet_key
|
||||||
|
|
||||||
|
|
||||||
|
def _init_db() -> None:
|
||||||
|
ensure_schema(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_runtime() -> None:
|
||||||
|
# 确保 data 目录存在
|
||||||
|
os.makedirs(settings.data_dir, exist_ok=True)
|
||||||
|
if settings.log_dir:
|
||||||
|
os.makedirs(settings.log_dir, exist_ok=True)
|
||||||
|
# 确保 Fernet key 准备好(或自动生成)
|
||||||
|
get_or_create_fernet_key(settings.fernet_key_path)
|
||||||
|
_init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
setup_logging()
|
||||||
|
_ensure_runtime()
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name)
|
||||||
|
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
admin = Admin(app=app, engine=engine, templates_dir="app/admin/templates")
|
||||||
|
admin.add_view(JobAdmin)
|
||||||
|
admin.add_view(JobLogAdmin)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "name": settings.app_name}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,46 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from app.jobs.base import BaseJob
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandlerRef:
|
||||||
|
module: str
|
||||||
|
cls_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_handler_path(handler_path: str) -> HandlerRef:
|
||||||
|
"""
|
||||||
|
支持两种格式:
|
||||||
|
- "pkg.mod:ClassName"(推荐)
|
||||||
|
- "pkg.mod.ClassName"
|
||||||
|
"""
|
||||||
|
if ":" in handler_path:
|
||||||
|
module, cls_name = handler_path.split(":", 1)
|
||||||
|
return HandlerRef(module=module, cls_name=cls_name)
|
||||||
|
if "." not in handler_path:
|
||||||
|
raise ValueError(f"Invalid handler_path: {handler_path}")
|
||||||
|
module, cls_name = handler_path.rsplit(".", 1)
|
||||||
|
return HandlerRef(module=module, cls_name=cls_name)
|
||||||
|
|
||||||
|
|
||||||
|
def load_job_class(handler_path: str) -> Type[BaseJob]:
|
||||||
|
ref = parse_handler_path(handler_path)
|
||||||
|
mod = importlib.import_module(ref.module)
|
||||||
|
cls = getattr(mod, ref.cls_name, None)
|
||||||
|
if cls is None:
|
||||||
|
raise ImportError(f"Class not found: {ref.module}.{ref.cls_name}")
|
||||||
|
if not isinstance(cls, type) or not issubclass(cls, BaseJob):
|
||||||
|
raise TypeError(f"Handler is not a BaseJob subclass: {handler_path}")
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate(handler_path: str) -> BaseJob:
|
||||||
|
cls = load_job_class(handler_path)
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_dir(path: str) -> None:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_fernet_key(path: str | None = None) -> bytes:
|
||||||
|
key_path = path or settings.fernet_key_path
|
||||||
|
_ensure_parent_dir(key_path)
|
||||||
|
|
||||||
|
if os.path.exists(key_path):
|
||||||
|
with open(key_path, "rb") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
# best-effort set 0o600 (not always supported on some FS)
|
||||||
|
try:
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
fd = os.open(key_path, flags, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(key)
|
||||||
|
f.write(b"\n")
|
||||||
|
except FileExistsError:
|
||||||
|
# race: another process wrote it
|
||||||
|
with open(key_path, "rb") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
with open(key_path, "wb") as f:
|
||||||
|
f.write(key)
|
||||||
|
f.write(b"\n")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
return Fernet(get_or_create_fernet_key())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_json(obj: dict[str, Any]) -> str:
|
||||||
|
data = json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return _fernet().encrypt(data).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_json(token: str) -> dict[str, Any]:
|
||||||
|
if not token:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = _fernet().decrypt(token.encode("utf-8"))
|
||||||
|
except InvalidToken as e:
|
||||||
|
raise ValueError("Invalid secret_cfg token (Fernet)") from e
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,34 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"connecthub",
|
||||||
|
broker=settings.redis_url,
|
||||||
|
backend=settings.redis_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
enable_utc=False,
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
# 明确包含 task 模块,避免 autodiscover 找不到(也避免导入导致循环依赖)
|
||||||
|
include=[
|
||||||
|
"app.tasks.execute",
|
||||||
|
"app.tasks.dispatcher",
|
||||||
|
],
|
||||||
|
beat_schedule={
|
||||||
|
"connecthub-dispatcher-tick-every-minute": {
|
||||||
|
"task": "connecthub.dispatcher.tick",
|
||||||
|
"schedule": 60.0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
worker_redirect_stdouts=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from croniter import croniter
|
||||||
|
|
||||||
|
from app.core.logging import setup_logging
|
||||||
|
from app.db import crud
|
||||||
|
from app.db.engine import get_session
|
||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
from app.tasks.execute import execute_job
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("connecthub.tasks.dispatcher")
|
||||||
|
|
||||||
|
|
||||||
|
def _floor_to_minute(dt: datetime) -> datetime:
|
||||||
|
return dt.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="connecthub.dispatcher.tick")
|
||||||
|
def tick() -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Beat 每分钟触发一次:
|
||||||
|
- 读取 enabled Jobs
|
||||||
|
- cron_expr 到点则触发 execute_job
|
||||||
|
- last_run_at 防止同一分钟重复触发
|
||||||
|
"""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
tz = ZoneInfo("Asia/Shanghai")
|
||||||
|
now = datetime.now(tz)
|
||||||
|
now_min = _floor_to_minute(now)
|
||||||
|
triggered = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for job in crud.list_enabled_jobs(session):
|
||||||
|
last = job.last_run_at
|
||||||
|
if last is not None:
|
||||||
|
# SQLite 通常存 naive datetime;按 Asia/Shanghai 解释
|
||||||
|
if last.tzinfo is None:
|
||||||
|
last_min = _floor_to_minute(last.replace(tzinfo=tz))
|
||||||
|
else:
|
||||||
|
last_min = _floor_to_minute(last.astimezone(tz))
|
||||||
|
if last_min >= now_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# croniter 默认按传入 datetime 计算,这里用 Asia/Shanghai
|
||||||
|
base = now_min - timedelta(minutes=1)
|
||||||
|
itr = croniter(job.cron_expr, base)
|
||||||
|
nxt = itr.get_next(datetime)
|
||||||
|
if _floor_to_minute(nxt.replace(tzinfo=tz)) != now_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
execute_job.delay(job_id=job.id)
|
||||||
|
crud.update_job_last_run_at(session, job.id, now_min.replace(tzinfo=None))
|
||||||
|
triggered += 1
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("dispatcher.tick failed")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return {"triggered": triggered}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback as tb
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.log_capture import capture_logs
|
||||||
|
from app.core.logging import setup_logging
|
||||||
|
from app.db import crud
|
||||||
|
from app.db.engine import engine, get_session
|
||||||
|
from app.db.models import JobStatus
|
||||||
|
from app.db.schema import ensure_schema
|
||||||
|
from app.plugins.manager import instantiate
|
||||||
|
from app.security.fernet import decrypt_json
|
||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("connecthub.tasks.execute")
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="connecthub.execute_job")
|
||||||
|
def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
通用执行入口:
|
||||||
|
- 传 job_id:从 DB 读取 Job 定义
|
||||||
|
- 传 snapshot_params:按快照重跑(用于 Admin 一键重试)
|
||||||
|
"""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
# 确保 schema 已升级(即使 worker 先启动也不会写库失败)
|
||||||
|
try:
|
||||||
|
ensure_schema(engine)
|
||||||
|
except Exception:
|
||||||
|
# schema upgrade 失败不能影响执行(最多导致 run_log 无法写入)
|
||||||
|
pass
|
||||||
|
|
||||||
|
started_at = datetime.utcnow()
|
||||||
|
session = get_session()
|
||||||
|
status = JobStatus.SUCCESS
|
||||||
|
message = ""
|
||||||
|
traceback = ""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
run_log_text = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with capture_logs(max_bytes=200_000) as get_run_log:
|
||||||
|
try:
|
||||||
|
if snapshot_params:
|
||||||
|
job_id = snapshot_params["job_id"]
|
||||||
|
handler_path = snapshot_params["handler_path"]
|
||||||
|
public_cfg = snapshot_params.get("public_cfg", {}) or {}
|
||||||
|
secret_token = snapshot_params.get("secret_cfg", "") or ""
|
||||||
|
else:
|
||||||
|
if not job_id:
|
||||||
|
raise ValueError("job_id or snapshot_params is required")
|
||||||
|
job = crud.get_job(session, job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError(f"Job not found: {job_id}")
|
||||||
|
handler_path = job.handler_path
|
||||||
|
public_cfg = job.public_cfg or {}
|
||||||
|
secret_token = job.secret_cfg or ""
|
||||||
|
|
||||||
|
secrets = decrypt_json(secret_token)
|
||||||
|
job_instance = instantiate(handler_path)
|
||||||
|
out = job_instance.run(params=public_cfg, secrets=secrets)
|
||||||
|
if isinstance(out, dict):
|
||||||
|
result = out
|
||||||
|
message = "OK"
|
||||||
|
|
||||||
|
except Exception as e: # noqa: BLE001 (framework-wide)
|
||||||
|
# 如果是 Celery retry 触发,框架可在此处扩展为自动 retry;此版本先记录失败信息
|
||||||
|
status = JobStatus.FAILURE
|
||||||
|
message = repr(e)
|
||||||
|
traceback = tb.format_exc()
|
||||||
|
logger.exception("execute_job failed job_id=%s", job_id)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
run_log_text = get_run_log() or ""
|
||||||
|
except Exception:
|
||||||
|
run_log_text = ""
|
||||||
|
finally:
|
||||||
|
finished_at = datetime.utcnow()
|
||||||
|
snapshot = snapshot_params or {
|
||||||
|
"job_id": job_id,
|
||||||
|
"handler_path": handler_path if "handler_path" in locals() else "",
|
||||||
|
"public_cfg": public_cfg if "public_cfg" in locals() else {},
|
||||||
|
"secret_cfg": secret_token if "secret_token" in locals() else "",
|
||||||
|
"meta": {
|
||||||
|
"trigger": "celery",
|
||||||
|
"celery_task_id": getattr(self.request, "id", "") or "",
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
crud.create_job_log(
|
||||||
|
session,
|
||||||
|
job_id=str(job_id or ""),
|
||||||
|
status=status,
|
||||||
|
snapshot_params=snapshot,
|
||||||
|
message=message,
|
||||||
|
traceback=traceback,
|
||||||
|
run_log=run_log_text,
|
||||||
|
celery_task_id=getattr(self.request, "id", "") or "",
|
||||||
|
attempt=int(getattr(self.request, "retries", 0) or 0),
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
)
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return {"status": status.value, "job_id": job_id, "result": result, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
cat <<'EOF'
|
||||||
|
缺少 .env 文件。
|
||||||
|
请在仓库根目录创建 .env(参考 env.example),例如:
|
||||||
|
cp env.example .env
|
||||||
|
然后按需修改其中变量。
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
用法:
|
||||||
|
./connecthub.sh build
|
||||||
|
./connecthub.sh start
|
||||||
|
./connecthub.sh restart
|
||||||
|
./connecthub.sh stop
|
||||||
|
./connecthub.sh dev-build
|
||||||
|
./connecthub.sh dev-start
|
||||||
|
./connecthub.sh dev-restart
|
||||||
|
./connecthub.sh dev-stop
|
||||||
|
./connecthub.sh log [--follow|-f] [--tail N] [service]
|
||||||
|
|
||||||
|
环境变量(可选):
|
||||||
|
COMPOSE_FILE=path/to/docker-compose.yml (默认: docker-compose.yml)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./connecthub.sh log
|
||||||
|
./connecthub.sh log beat
|
||||||
|
./connecthub.sh log -f beat
|
||||||
|
./connecthub.sh log --tail 200 worker
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log_usage() {
|
||||||
|
cat <<EOF
|
||||||
|
用法:
|
||||||
|
./connecthub.sh log [--follow|-f] [--tail N] [service]
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 不指定 service:查看全部服务日志
|
||||||
|
- 指定 service:例如 backend/worker/beat/redis
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./connecthub.sh log beat
|
||||||
|
./connecthub.sh log -f beat
|
||||||
|
./connecthub.sh log --tail 200 worker
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"
|
||||||
|
case "$cmd" in
|
||||||
|
build)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" build
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
|
;;
|
||||||
|
dev-build)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml build
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml up -d
|
||||||
|
;;
|
||||||
|
dev-start)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml up -d
|
||||||
|
;;
|
||||||
|
dev-restart)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml down
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml up -d
|
||||||
|
;;
|
||||||
|
dev-stop)
|
||||||
|
require_env
|
||||||
|
docker compose -f "$COMPOSE_FILE" -f docker-compose.dev.yml down
|
||||||
|
;;
|
||||||
|
log)
|
||||||
|
shift || true
|
||||||
|
follow="0"
|
||||||
|
tail=""
|
||||||
|
service=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-f|--follow)
|
||||||
|
follow="1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--tail)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "缺少 --tail 的参数" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
tail="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tail=*)
|
||||||
|
tail="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help|help)
|
||||||
|
log_usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$service" && "${1:0:1}" != "-" ]]; then
|
||||||
|
service="$1"
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
echo "未知参数: $1" >&2
|
||||||
|
echo
|
||||||
|
log_usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
args=(docker compose -f "$COMPOSE_FILE" logs)
|
||||||
|
if [[ "$follow" = "1" ]]; then
|
||||||
|
args+=(--follow)
|
||||||
|
fi
|
||||||
|
if [[ -n "$tail" ]]; then
|
||||||
|
args+=(--tail "$tail")
|
||||||
|
fi
|
||||||
|
if [[ -n "$service" ]]; then
|
||||||
|
args+=("$service")
|
||||||
|
fi
|
||||||
|
"${args[@]}"
|
||||||
|
;;
|
||||||
|
-h|--help|help|"")
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知命令: $cmd" >&2
|
||||||
|
echo
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
hiwc97uAjCqHbteGdrN9BKaV9iHO4aV-_FfRJTn2Mo8=
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
DEV_MODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./extensions:/app/extensions
|
||||||
|
|
||||||
|
worker:
|
||||||
|
environment:
|
||||||
|
DEV_MODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./extensions:/app/extensions
|
||||||
|
command: >
|
||||||
|
sh -c "watchfiles --filter python 'celery -A app.tasks.celery_app:celery_app worker --loglevel=INFO' /app/app /app/extensions"
|
||||||
|
|
||||||
|
beat:
|
||||||
|
environment:
|
||||||
|
DEV_MODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./extensions:/app/extensions
|
||||||
|
command: >
|
||||||
|
sh -c "watchfiles --filter python 'celery -A app.tasks.celery_app:celery_app beat --loglevel=INFO' /app/app /app/extensions"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
command: >
|
||||||
|
sh -c "if [ \"${DEV_MODE:-0}\" = \"1\" ]; then uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload; else uvicorn app.main:app --host 0.0.0.0 --port 8000; fi"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
command: >
|
||||||
|
sh -c "celery -A app.tasks.celery_app:celery_app worker --loglevel=INFO"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
command: >
|
||||||
|
sh -c "celery -A app.tasks.celery_app:celery_app beat --loglevel=INFO"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml /app/pyproject.toml
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -U pip && \
|
||||||
|
pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
COPY extensions /app/extensions
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
APP_NAME=ConnectHub
|
||||||
|
DATA_DIR=/data
|
||||||
|
DB_URL=sqlite:////data/connecthub.db
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
FERNET_KEY_PATH=/data/fernet.key
|
||||||
|
DEV_MODE=1
|
||||||
|
LOG_DIR=/data/logs
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.integrations.base import BaseClient
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleClient(BaseClient):
|
||||||
|
"""
|
||||||
|
演示用 Client:真实业务中应封装 OA/HR/ERP 的 API。
|
||||||
|
这里不做实际外部请求,仅保留结构与调用方式。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ping(self) -> dict:
|
||||||
|
# 真实情况:return self.get_json("/ping")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.jobs.base import BaseJob
|
||||||
|
from extensions.example.client import ExampleClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("connecthub.extensions.example")
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleJob(BaseJob):
|
||||||
|
job_id = "example.hello"
|
||||||
|
|
||||||
|
def run(self, params: dict[str, Any], secrets: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
# params: 明文配置,例如 {"name": "Mars"}
|
||||||
|
name = params.get("name", "World")
|
||||||
|
|
||||||
|
# secrets: 解密后的明文,例如 {"token": "..."}
|
||||||
|
token = secrets.get("token", "<missing>")
|
||||||
|
|
||||||
|
client = ExampleClient(base_url="https://baidu.com", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
try:
|
||||||
|
pong = client.ping()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
logger.info("ExampleJob ran name=%s pong=%s", name, pong)
|
||||||
|
return {"hello": name, "pong": pong}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
[project]
|
||||||
|
name = "connecthub"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "ConnectHub - lightweight enterprise integration middleware"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.27",
|
||||||
|
"sqladmin>=0.16.1",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"pydantic>=2.6",
|
||||||
|
"pydantic-settings>=2.1",
|
||||||
|
"cryptography>=41",
|
||||||
|
"celery>=5.3,<6",
|
||||||
|
"redis>=5",
|
||||||
|
"croniter>=2.0",
|
||||||
|
"httpx>=0.26",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"watchfiles>=0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["app*", "extensions*"]
|
||||||
|
|
||||||
|
[tool.uvicorn]
|
||||||
|
factory = false
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue