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