This commit is contained in:
Marsway 2026-01-05 14:58:50 +08:00
commit 537c54df14
76 changed files with 3753 additions and 0 deletions

9
.env Normal file
View File

@ -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

142
README.md Normal file
View File

@ -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 访问外部系统(统一超时、重试、日志)。
#### SecurityFernet 加解密)
- 位置:`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`
### 数据层与 AdminSQLAdmin
- 模型:`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. 创建一个示例 JobExampleJob
- `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 镜像完成。

2
app/__init__.py Normal file
View File

@ -0,0 +1,2 @@
#

Binary file not shown.

Binary file not shown.

3
app/admin/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

Binary file not shown.

37
app/admin/routes.py Normal file
View File

@ -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)

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

191
app/admin/views.py Normal file
View File

@ -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>"
),
}

3
app/core/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
app/core/config.py Normal file
View File

@ -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()

82
app/core/log_capture.py Normal file
View File

@ -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

37
app/core/logging.py Normal file
View File

@ -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)

3
app/db/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

64
app/db/crud.py Normal file
View File

@ -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)

21
app/db/engine.py Normal file
View File

@ -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()

65
app/db/models.py Normal file
View File

@ -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)
# 密文 tokenFernet 加密后的字符串)
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")

26
app/db/schema.py Normal file
View File

@ -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 ''"))

View File

@ -0,0 +1,5 @@
"""系统集成适配器"""
from app.integrations.base import BaseClient
__all__ = ["BaseClient"]

Binary file not shown.

Binary file not shown.

70
app/integrations/base.py Normal file
View File

@ -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()

3
app/jobs/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

21
app/jobs/base.py Normal file
View File

@ -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

52
app/main.py Normal file
View File

@ -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()

3
app/plugins/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

46
app/plugins/manager.py Normal file
View File

@ -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()

3
app/security/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

63
app/security/fernet.py Normal file
View File

@ -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"))

3
app/tasks/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

34
app/tasks/celery_app.py Normal file
View File

@ -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
)

70
app/tasks/dispatcher.py Normal file
View File

@ -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}

112
app/tasks/execute.py Normal file
View File

@ -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}

164
connecthub.sh Executable file
View File

@ -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

BIN
data/connecthub.db Normal file

Binary file not shown.

1
data/fernet.key Normal file
View File

@ -0,0 +1 @@
hiwc97uAjCqHbteGdrN9BKaV9iHO4aV-_FfRJTn2Mo8=

1450
data/logs/connecthub.log Normal file

File diff suppressed because it is too large Load Diff

27
docker-compose.dev.yml Normal file
View File

@ -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"

48
docker-compose.yml Normal file
View File

@ -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

22
docker/Dockerfile Normal file
View File

@ -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

9
env.example Normal file
View File

@ -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

3
extensions/__init__.py Normal file
View File

@ -0,0 +1,3 @@
#

Binary file not shown.

View File

@ -0,0 +1,3 @@
#

Binary file not shown.

Binary file not shown.

View File

@ -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}

33
extensions/example/job.py Normal file
View File

@ -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}

33
pyproject.toml Normal file
View File

@ -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