jettask 0.2.1__py3-none-any.whl → 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jettask/constants.py +213 -0
- jettask/core/app.py +525 -205
- jettask/core/cli.py +193 -185
- jettask/core/consumer_manager.py +126 -34
- jettask/core/context.py +3 -0
- jettask/core/enums.py +137 -0
- jettask/core/event_pool.py +501 -168
- jettask/core/message.py +147 -0
- jettask/core/offline_worker_recovery.py +181 -114
- jettask/core/task.py +10 -174
- jettask/core/task_batch.py +153 -0
- jettask/core/unified_manager_base.py +243 -0
- jettask/core/worker_scanner.py +54 -54
- jettask/executors/asyncio.py +184 -64
- jettask/webui/backend/config.py +51 -0
- jettask/webui/backend/data_access.py +2083 -92
- jettask/webui/backend/data_api.py +3294 -0
- jettask/webui/backend/dependencies.py +261 -0
- jettask/webui/backend/init_meta_db.py +158 -0
- jettask/webui/backend/main.py +1358 -69
- jettask/webui/backend/main_unified.py +78 -0
- jettask/webui/backend/main_v2.py +394 -0
- jettask/webui/backend/namespace_api.py +295 -0
- jettask/webui/backend/namespace_api_old.py +294 -0
- jettask/webui/backend/namespace_data_access.py +611 -0
- jettask/webui/backend/queue_backlog_api.py +727 -0
- jettask/webui/backend/queue_stats_v2.py +521 -0
- jettask/webui/backend/redis_monitor_api.py +476 -0
- jettask/webui/backend/unified_api_router.py +1601 -0
- jettask/webui/db_init.py +204 -32
- jettask/webui/frontend/package-lock.json +492 -1
- jettask/webui/frontend/package.json +4 -1
- jettask/webui/frontend/src/App.css +105 -7
- jettask/webui/frontend/src/App.jsx +49 -20
- jettask/webui/frontend/src/components/NamespaceSelector.jsx +166 -0
- jettask/webui/frontend/src/components/QueueBacklogChart.jsx +298 -0
- jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +638 -0
- jettask/webui/frontend/src/components/QueueDetailsTable.css +65 -0
- jettask/webui/frontend/src/components/QueueDetailsTable.jsx +487 -0
- jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +465 -0
- jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +423 -0
- jettask/webui/frontend/src/components/TaskFilter.jsx +425 -0
- jettask/webui/frontend/src/components/TimeRangeSelector.css +21 -0
- jettask/webui/frontend/src/components/TimeRangeSelector.jsx +160 -0
- jettask/webui/frontend/src/components/layout/AppLayout.css +95 -0
- jettask/webui/frontend/src/components/layout/AppLayout.jsx +49 -0
- jettask/webui/frontend/src/components/layout/Header.css +34 -10
- jettask/webui/frontend/src/components/layout/Header.jsx +31 -23
- jettask/webui/frontend/src/components/layout/SideMenu.css +137 -0
- jettask/webui/frontend/src/components/layout/SideMenu.jsx +209 -0
- jettask/webui/frontend/src/components/layout/TabsNav.css +244 -0
- jettask/webui/frontend/src/components/layout/TabsNav.jsx +206 -0
- jettask/webui/frontend/src/components/layout/UserInfo.css +197 -0
- jettask/webui/frontend/src/components/layout/UserInfo.jsx +197 -0
- jettask/webui/frontend/src/contexts/NamespaceContext.jsx +72 -0
- jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +245 -0
- jettask/webui/frontend/src/main.jsx +1 -0
- jettask/webui/frontend/src/pages/Alerts.jsx +684 -0
- jettask/webui/frontend/src/pages/Dashboard.jsx +1330 -0
- jettask/webui/frontend/src/pages/QueueDetail.jsx +1109 -10
- jettask/webui/frontend/src/pages/QueueMonitor.jsx +236 -115
- jettask/webui/frontend/src/pages/Queues.jsx +5 -1
- jettask/webui/frontend/src/pages/ScheduledTasks.jsx +809 -0
- jettask/webui/frontend/src/pages/Settings.jsx +800 -0
- jettask/webui/frontend/src/services/api.js +7 -5
- jettask/webui/frontend/src/utils/suppressWarnings.js +22 -0
- jettask/webui/frontend/src/utils/userPreferences.js +154 -0
- jettask/webui/multi_namespace_consumer.py +543 -0
- jettask/webui/pg_consumer.py +983 -246
- jettask/webui/static/dist/assets/index-7129cfe1.css +1 -0
- jettask/webui/static/dist/assets/index-8d1935cc.js +774 -0
- jettask/webui/static/dist/index.html +2 -2
- jettask/webui/task_center.py +216 -0
- jettask/webui/task_center_client.py +150 -0
- jettask/webui/unified_consumer_manager.py +193 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/METADATA +1 -1
- jettask-0.2.4.dist-info/RECORD +134 -0
- jettask/webui/pg_consumer_slow.py +0 -1099
- jettask/webui/pg_consumer_test.py +0 -678
- jettask/webui/static/dist/assets/index-823408e8.css +0 -1
- jettask/webui/static/dist/assets/index-9968b0b8.js +0 -543
- jettask/webui/test_pg_consumer_recovery.py +0 -547
- jettask/webui/test_recovery_simple.py +0 -492
- jettask/webui/test_self_recovery.py +0 -467
- jettask-0.2.1.dist-info/RECORD +0 -91
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/WHEEL +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/entry_points.txt +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,611 @@
|
|
1
|
+
"""
|
2
|
+
命名空间数据访问层 - 支持多租户的数据隔离访问
|
3
|
+
"""
|
4
|
+
import os
|
5
|
+
import asyncio
|
6
|
+
import json
|
7
|
+
import logging
|
8
|
+
import time
|
9
|
+
import traceback
|
10
|
+
from datetime import datetime, timedelta, timezone
|
11
|
+
from typing import Dict, List, Optional, Tuple, Any
|
12
|
+
import redis.asyncio as redis
|
13
|
+
from sqlalchemy import text, bindparam
|
14
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
15
|
+
from sqlalchemy.orm import sessionmaker
|
16
|
+
import aiohttp
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class NamespaceConnection:
|
22
|
+
"""单个命名空间的数据库连接"""
|
23
|
+
|
24
|
+
def __init__(self, namespace_name: str, redis_config: dict, pg_config: dict):
|
25
|
+
self.namespace_name = namespace_name
|
26
|
+
self.redis_config = redis_config
|
27
|
+
self.pg_config = pg_config
|
28
|
+
self.redis_prefix = namespace_name # 使用命名空间名作为Redis前缀
|
29
|
+
|
30
|
+
# 数据库连接对象
|
31
|
+
self.async_engine = None
|
32
|
+
self.AsyncSessionLocal = None
|
33
|
+
self._redis_pool = None
|
34
|
+
self._binary_redis_pool = None
|
35
|
+
self._initialized = False
|
36
|
+
|
37
|
+
async def initialize(self):
|
38
|
+
"""初始化数据库连接"""
|
39
|
+
if self._initialized:
|
40
|
+
return
|
41
|
+
|
42
|
+
try:
|
43
|
+
# 初始化PostgreSQL连接
|
44
|
+
if self.pg_config:
|
45
|
+
dsn = self._build_pg_dsn()
|
46
|
+
if dsn.startswith('postgresql://'):
|
47
|
+
dsn = dsn.replace('postgresql://', 'postgresql+psycopg://', 1)
|
48
|
+
|
49
|
+
self.async_engine = create_async_engine(
|
50
|
+
dsn,
|
51
|
+
pool_size=10,
|
52
|
+
max_overflow=5,
|
53
|
+
pool_pre_ping=True,
|
54
|
+
echo=False
|
55
|
+
)
|
56
|
+
|
57
|
+
self.AsyncSessionLocal = sessionmaker(
|
58
|
+
bind=self.async_engine,
|
59
|
+
class_=AsyncSession,
|
60
|
+
expire_on_commit=False
|
61
|
+
)
|
62
|
+
|
63
|
+
# 初始化Redis连接池
|
64
|
+
if self.redis_config:
|
65
|
+
# 支持两种格式:url格式或分离的host/port格式
|
66
|
+
redis_url = self.redis_config.get('url')
|
67
|
+
if redis_url:
|
68
|
+
# 从URL创建连接池
|
69
|
+
self._redis_pool = redis.ConnectionPool.from_url(
|
70
|
+
redis_url,
|
71
|
+
decode_responses=True,
|
72
|
+
encoding='utf-8'
|
73
|
+
)
|
74
|
+
|
75
|
+
self._binary_redis_pool = redis.ConnectionPool.from_url(
|
76
|
+
redis_url,
|
77
|
+
decode_responses=False
|
78
|
+
)
|
79
|
+
else:
|
80
|
+
# 从分离的配置创建连接池
|
81
|
+
self._redis_pool = redis.ConnectionPool(
|
82
|
+
host=self.redis_config.get('host', 'localhost'),
|
83
|
+
port=self.redis_config.get('port', 6379),
|
84
|
+
db=self.redis_config.get('db', 0),
|
85
|
+
password=self.redis_config.get('password'),
|
86
|
+
decode_responses=True,
|
87
|
+
encoding='utf-8'
|
88
|
+
)
|
89
|
+
|
90
|
+
self._binary_redis_pool = redis.ConnectionPool(
|
91
|
+
host=self.redis_config.get('host', 'localhost'),
|
92
|
+
port=self.redis_config.get('port', 6379),
|
93
|
+
db=self.redis_config.get('db', 0),
|
94
|
+
password=self.redis_config.get('password'),
|
95
|
+
decode_responses=False
|
96
|
+
)
|
97
|
+
|
98
|
+
self._initialized = True
|
99
|
+
logger.info(f"命名空间 {self.namespace_name} 数据库连接初始化成功")
|
100
|
+
|
101
|
+
except Exception as e:
|
102
|
+
logger.error(f"初始化命名空间 {self.namespace_name} 数据库连接失败: {e}")
|
103
|
+
traceback.print_exc()
|
104
|
+
raise
|
105
|
+
|
106
|
+
def _build_pg_dsn(self) -> str:
|
107
|
+
"""构建PostgreSQL DSN"""
|
108
|
+
config = self.pg_config
|
109
|
+
# 支持两种格式:url格式或分离的配置
|
110
|
+
if 'url' in config:
|
111
|
+
return config['url']
|
112
|
+
else:
|
113
|
+
return f"postgresql://{config['user']}:{config['password']}@{config['host']}:{config['port']}/{config['database']}"
|
114
|
+
|
115
|
+
async def get_redis_client(self, decode: bool = True) -> redis.Redis:
|
116
|
+
"""获取Redis客户端"""
|
117
|
+
try:
|
118
|
+
if not self._initialized:
|
119
|
+
await self.initialize()
|
120
|
+
|
121
|
+
pool = self._redis_pool if decode else self._binary_redis_pool
|
122
|
+
if not pool:
|
123
|
+
raise ValueError(f"命名空间 {self.namespace_name} 没有配置Redis")
|
124
|
+
|
125
|
+
return redis.Redis(connection_pool=pool)
|
126
|
+
except Exception as e:
|
127
|
+
# 连接异常时重置初始化标志,允许重新初始化
|
128
|
+
logger.error(f"获取Redis客户端失败: {e}")
|
129
|
+
traceback.print_exc()
|
130
|
+
self._initialized = False
|
131
|
+
raise
|
132
|
+
|
133
|
+
async def get_pg_session(self) -> AsyncSession:
|
134
|
+
"""获取PostgreSQL会话"""
|
135
|
+
try:
|
136
|
+
if not self._initialized:
|
137
|
+
await self.initialize()
|
138
|
+
|
139
|
+
if not self.AsyncSessionLocal:
|
140
|
+
raise ValueError(f"命名空间 {self.namespace_name} 没有配置PostgreSQL")
|
141
|
+
|
142
|
+
return self.AsyncSessionLocal()
|
143
|
+
except Exception as e:
|
144
|
+
# 连接异常时重置初始化标志,允许重新初始化
|
145
|
+
logger.error(f"获取PostgreSQL会话失败: {e}")
|
146
|
+
traceback.print_exc()
|
147
|
+
self._initialized = False
|
148
|
+
raise
|
149
|
+
|
150
|
+
async def close(self):
|
151
|
+
"""关闭数据库连接"""
|
152
|
+
if self._redis_pool:
|
153
|
+
await self._redis_pool.aclose()
|
154
|
+
if self._binary_redis_pool:
|
155
|
+
await self._binary_redis_pool.aclose()
|
156
|
+
if self.async_engine:
|
157
|
+
await self.async_engine.dispose()
|
158
|
+
|
159
|
+
self._initialized = False
|
160
|
+
logger.info(f"命名空间 {self.namespace_name} 数据库连接已关闭")
|
161
|
+
|
162
|
+
|
163
|
+
class NamespaceDataAccessManager:
|
164
|
+
"""
|
165
|
+
命名空间数据访问管理器
|
166
|
+
管理多个命名空间的数据库连接,实现连接池和缓存
|
167
|
+
"""
|
168
|
+
|
169
|
+
def __init__(self, task_center_base_url: str = None):
|
170
|
+
self.task_center_base_url = task_center_base_url or os.getenv(
|
171
|
+
'TASK_CENTER_BASE_URL', 'http://localhost:8001'
|
172
|
+
)
|
173
|
+
self._connections: Dict[str, NamespaceConnection] = {}
|
174
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
175
|
+
|
176
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
177
|
+
"""获取HTTP会话"""
|
178
|
+
if self._session is None or self._session.closed:
|
179
|
+
self._session = aiohttp.ClientSession()
|
180
|
+
return self._session
|
181
|
+
|
182
|
+
async def get_namespace_config(self, namespace_name: str) -> dict:
|
183
|
+
"""从任务中心API获取命名空间配置"""
|
184
|
+
url = f"{self.task_center_base_url}/api/namespaces/{namespace_name}"
|
185
|
+
|
186
|
+
try:
|
187
|
+
session = await self._get_session()
|
188
|
+
async with session.get(url) as resp:
|
189
|
+
if resp.status == 200:
|
190
|
+
data = await resp.json()
|
191
|
+
# API返回的是redis_config和pg_config,直接使用
|
192
|
+
redis_config = data.get('redis_config', {})
|
193
|
+
pg_config = data.get('pg_config', {})
|
194
|
+
|
195
|
+
# 兼容旧格式:如果有redis_url和pg_url字段
|
196
|
+
if not redis_config and data.get('redis_url'):
|
197
|
+
redis_config = {'url': data.get('redis_url')}
|
198
|
+
|
199
|
+
if not pg_config and data.get('pg_url'):
|
200
|
+
pg_config = {'url': data.get('pg_url')}
|
201
|
+
|
202
|
+
return {
|
203
|
+
'name': data.get('name'),
|
204
|
+
'redis_config': redis_config,
|
205
|
+
'pg_config': pg_config
|
206
|
+
}
|
207
|
+
else:
|
208
|
+
raise ValueError(f"无法获取命名空间 {namespace_name} 的配置: HTTP {resp.status}")
|
209
|
+
except Exception as e:
|
210
|
+
logger.error(f"获取命名空间 {namespace_name} 配置失败: {e}")
|
211
|
+
traceback.print_exc()
|
212
|
+
raise
|
213
|
+
|
214
|
+
async def get_connection(self, namespace_name: str) -> NamespaceConnection:
|
215
|
+
"""
|
216
|
+
获取指定命名空间的数据库连接
|
217
|
+
如果连接不存在,会自动创建并初始化
|
218
|
+
"""
|
219
|
+
if namespace_name not in self._connections:
|
220
|
+
# 获取命名空间配置
|
221
|
+
config = await self.get_namespace_config(namespace_name)
|
222
|
+
|
223
|
+
# 创建新的连接对象
|
224
|
+
connection = NamespaceConnection(
|
225
|
+
namespace_name=config['name'],
|
226
|
+
redis_config=config['redis_config'],
|
227
|
+
pg_config=config['pg_config']
|
228
|
+
)
|
229
|
+
|
230
|
+
# 初始化连接
|
231
|
+
await connection.initialize()
|
232
|
+
|
233
|
+
# 缓存连接对象
|
234
|
+
self._connections[namespace_name] = connection
|
235
|
+
logger.info(f"创建命名空间 {namespace_name} 的新连接")
|
236
|
+
|
237
|
+
return self._connections[namespace_name]
|
238
|
+
|
239
|
+
async def list_namespaces(self) -> List[dict]:
|
240
|
+
"""获取所有命名空间列表"""
|
241
|
+
url = f"{self.task_center_base_url}/api/namespaces"
|
242
|
+
|
243
|
+
try:
|
244
|
+
session = await self._get_session()
|
245
|
+
async with session.get(url) as resp:
|
246
|
+
if resp.status == 200:
|
247
|
+
return await resp.json()
|
248
|
+
else:
|
249
|
+
raise ValueError(f"无法获取命名空间列表: HTTP {resp.status}")
|
250
|
+
except Exception as e:
|
251
|
+
logger.error(f"获取命名空间列表失败: {e}")
|
252
|
+
traceback.print_exc()
|
253
|
+
raise
|
254
|
+
|
255
|
+
async def close_connection(self, namespace_name: str):
|
256
|
+
"""关闭指定命名空间的连接"""
|
257
|
+
if namespace_name in self._connections:
|
258
|
+
await self._connections[namespace_name].close()
|
259
|
+
del self._connections[namespace_name]
|
260
|
+
logger.info(f"关闭命名空间 {namespace_name} 的连接")
|
261
|
+
|
262
|
+
async def reset_connection(self, namespace_name: str):
|
263
|
+
"""重置指定命名空间的连接,清除缓存和初始化标志"""
|
264
|
+
if namespace_name in self._connections:
|
265
|
+
# 先关闭现有连接
|
266
|
+
await self._connections[namespace_name].close()
|
267
|
+
del self._connections[namespace_name]
|
268
|
+
logger.info(f"重置命名空间 {namespace_name} 的连接,已清除缓存")
|
269
|
+
|
270
|
+
async def close_all(self):
|
271
|
+
"""关闭所有连接"""
|
272
|
+
for namespace_name in list(self._connections.keys()):
|
273
|
+
await self.close_connection(namespace_name)
|
274
|
+
|
275
|
+
if self._session:
|
276
|
+
await self._session.close()
|
277
|
+
self._session = None
|
278
|
+
|
279
|
+
|
280
|
+
class NamespaceJetTaskDataAccess:
|
281
|
+
"""
|
282
|
+
支持命名空间的JetTask数据访问类
|
283
|
+
所有数据查询方法都需要指定namespace_name参数
|
284
|
+
"""
|
285
|
+
|
286
|
+
def __init__(self, manager: NamespaceDataAccessManager = None):
|
287
|
+
self.manager = manager or NamespaceDataAccessManager()
|
288
|
+
|
289
|
+
async def get_task_detail(self, namespace_name: str, task_id: str) -> dict:
|
290
|
+
"""获取任务详情"""
|
291
|
+
conn = await self.manager.get_connection(namespace_name)
|
292
|
+
redis_client = await conn.get_redis_client()
|
293
|
+
|
294
|
+
try:
|
295
|
+
# 构建任务键
|
296
|
+
task_key = f"{conn.redis_prefix}:TASK:{task_id}"
|
297
|
+
|
298
|
+
# 获取任务信息
|
299
|
+
task_data = await redis_client.hgetall(task_key)
|
300
|
+
if not task_data:
|
301
|
+
return None
|
302
|
+
|
303
|
+
# 解析任务数据
|
304
|
+
result = {
|
305
|
+
'id': task_id,
|
306
|
+
'status': task_data.get('status', 'UNKNOWN'),
|
307
|
+
'name': task_data.get('name', ''),
|
308
|
+
'queue': task_data.get('queue', ''),
|
309
|
+
'worker_id': task_data.get('worker_id', ''),
|
310
|
+
'created_at': task_data.get('created_at', ''),
|
311
|
+
'started_at': task_data.get('started_at', ''),
|
312
|
+
'completed_at': task_data.get('completed_at', ''),
|
313
|
+
'result': task_data.get('result', ''),
|
314
|
+
'error': task_data.get('error', ''),
|
315
|
+
'retry_count': int(task_data.get('retry_count', 0))
|
316
|
+
}
|
317
|
+
|
318
|
+
return result
|
319
|
+
|
320
|
+
finally:
|
321
|
+
await redis_client.aclose()
|
322
|
+
|
323
|
+
async def get_queue_stats(self, namespace_name: str) -> List[dict]:
|
324
|
+
"""获取队列统计信息"""
|
325
|
+
conn = await self.manager.get_connection(namespace_name)
|
326
|
+
redis_client = await conn.get_redis_client()
|
327
|
+
|
328
|
+
try:
|
329
|
+
# 获取所有队列
|
330
|
+
queue_pattern = f"{conn.redis_prefix}:QUEUE:*"
|
331
|
+
print(f'{queue_pattern=}')
|
332
|
+
queue_keys = []
|
333
|
+
async for key in redis_client.scan_iter(match=queue_pattern):
|
334
|
+
queue_keys.append(key)
|
335
|
+
|
336
|
+
stats = []
|
337
|
+
for queue_key in queue_keys:
|
338
|
+
# 提取队列名
|
339
|
+
queue_name = queue_key.replace(f"{conn.redis_prefix}:QUEUE:", "")
|
340
|
+
|
341
|
+
# 获取队列长度
|
342
|
+
queue_length = await redis_client.xlen(queue_key)
|
343
|
+
|
344
|
+
# 获取队列的消费组信息
|
345
|
+
try:
|
346
|
+
groups_info = await redis_client.xinfo_groups(queue_key)
|
347
|
+
consumer_groups = len(groups_info)
|
348
|
+
total_consumers = sum(g.get('consumers', 0) for g in groups_info)
|
349
|
+
total_pending = sum(g.get('pending', 0) for g in groups_info)
|
350
|
+
except redis.ResponseError:
|
351
|
+
consumer_groups = 0
|
352
|
+
total_consumers = 0
|
353
|
+
total_pending = 0
|
354
|
+
|
355
|
+
stats.append({
|
356
|
+
'queue_name': queue_name,
|
357
|
+
'length': queue_length,
|
358
|
+
'consumer_groups': consumer_groups,
|
359
|
+
'consumers': total_consumers,
|
360
|
+
'pending': total_pending
|
361
|
+
})
|
362
|
+
|
363
|
+
return stats
|
364
|
+
|
365
|
+
finally:
|
366
|
+
await redis_client.aclose()
|
367
|
+
|
368
|
+
async def get_scheduled_tasks(self, namespace_name: str, limit: int = 100, offset: int = 0) -> dict:
|
369
|
+
"""获取定时任务列表"""
|
370
|
+
conn = await self.manager.get_connection(namespace_name)
|
371
|
+
|
372
|
+
# 如果没有PostgreSQL配置,返回空结果
|
373
|
+
if not conn.pg_config:
|
374
|
+
return {
|
375
|
+
'tasks': [],
|
376
|
+
'total': 0,
|
377
|
+
'has_more': False
|
378
|
+
}
|
379
|
+
|
380
|
+
async with await conn.get_pg_session() as session:
|
381
|
+
try:
|
382
|
+
# 查询定时任务(按命名空间筛选)
|
383
|
+
query = text("""
|
384
|
+
SELECT
|
385
|
+
id,
|
386
|
+
task_name as name,
|
387
|
+
queue_name as queue,
|
388
|
+
cron_expression,
|
389
|
+
interval_seconds,
|
390
|
+
CASE
|
391
|
+
WHEN cron_expression IS NOT NULL THEN cron_expression
|
392
|
+
WHEN interval_seconds IS NOT NULL THEN interval_seconds::text || ' seconds'
|
393
|
+
ELSE 'unknown'
|
394
|
+
END as schedule,
|
395
|
+
json_build_object(
|
396
|
+
'args', task_args,
|
397
|
+
'kwargs', task_kwargs
|
398
|
+
) as task_data,
|
399
|
+
enabled,
|
400
|
+
last_run_time as last_run_at,
|
401
|
+
next_run_time as next_run_at,
|
402
|
+
execution_count,
|
403
|
+
created_at,
|
404
|
+
updated_at,
|
405
|
+
description,
|
406
|
+
max_retries,
|
407
|
+
retry_delay,
|
408
|
+
timeout
|
409
|
+
FROM scheduled_tasks
|
410
|
+
WHERE namespace = :namespace
|
411
|
+
ORDER BY next_run_time ASC NULLS LAST, id ASC
|
412
|
+
LIMIT :limit OFFSET :offset
|
413
|
+
""")
|
414
|
+
|
415
|
+
result = await session.execute(
|
416
|
+
query,
|
417
|
+
{'namespace': namespace_name, 'limit': limit, 'offset': offset}
|
418
|
+
)
|
419
|
+
tasks = result.fetchall()
|
420
|
+
|
421
|
+
# 获取总数(按命名空间筛选)
|
422
|
+
count_query = text("SELECT COUNT(*) FROM scheduled_tasks WHERE namespace = :namespace")
|
423
|
+
count_result = await session.execute(count_query, {'namespace': namespace_name})
|
424
|
+
total = count_result.scalar()
|
425
|
+
|
426
|
+
# 格式化结果
|
427
|
+
formatted_tasks = []
|
428
|
+
for task in tasks:
|
429
|
+
# 解析调度配置 - 使用原始数据库字段
|
430
|
+
schedule_type = 'unknown'
|
431
|
+
schedule_config = {}
|
432
|
+
|
433
|
+
if hasattr(task, 'cron_expression') and task.cron_expression:
|
434
|
+
# Cron表达式类型
|
435
|
+
schedule_type = 'cron'
|
436
|
+
schedule_config = {'cron_expression': task.cron_expression}
|
437
|
+
elif hasattr(task, 'interval_seconds') and task.interval_seconds:
|
438
|
+
# 间隔执行类型
|
439
|
+
schedule_type = 'interval'
|
440
|
+
try:
|
441
|
+
# 使用float而不是int,避免小数秒被截断为0
|
442
|
+
seconds = float(task.interval_seconds)
|
443
|
+
# 如果间隔小于1秒,至少显示为1秒,避免显示0秒的无效任务
|
444
|
+
if seconds < 1.0:
|
445
|
+
seconds = max(1, int(seconds)) # 小于1秒的向上舍入为1秒
|
446
|
+
else:
|
447
|
+
seconds = int(seconds) # 大于等于1秒的保持整数显示
|
448
|
+
schedule_config = {'seconds': seconds}
|
449
|
+
except (ValueError, TypeError) as e:
|
450
|
+
logger.warning(f"解析间隔秒数失败: {task.interval_seconds}, 错误: {e}")
|
451
|
+
schedule_config = {}
|
452
|
+
|
453
|
+
formatted_tasks.append({
|
454
|
+
'id': task.id,
|
455
|
+
'name': task.name,
|
456
|
+
'queue_name': task.queue, # 前端期望 queue_name 而非 queue
|
457
|
+
'schedule_type': schedule_type, # 新增调度类型
|
458
|
+
'schedule_config': schedule_config, # 新增结构化调度配置
|
459
|
+
'schedule': task.schedule, # 保留原始字段以兼容
|
460
|
+
'task_data': task.task_data if task.task_data else {},
|
461
|
+
'is_active': task.enabled, # 前端期望 is_active 而非 enabled
|
462
|
+
'enabled': task.enabled, # 保留原字段以兼容
|
463
|
+
'last_run': task.last_run_at.isoformat() if task.last_run_at else None, # 前端期望 last_run
|
464
|
+
'last_run_at': task.last_run_at.isoformat() if task.last_run_at else None, # 保留原字段
|
465
|
+
'next_run': task.next_run_at.isoformat() if task.next_run_at else None, # 前端期望 next_run
|
466
|
+
'next_run_at': task.next_run_at.isoformat() if task.next_run_at else None, # 保留原字段
|
467
|
+
'execution_count': task.execution_count,
|
468
|
+
'created_at': task.created_at.isoformat() if task.created_at else None,
|
469
|
+
'updated_at': task.updated_at.isoformat() if task.updated_at else None,
|
470
|
+
'description': task.description,
|
471
|
+
'max_retries': task.max_retries,
|
472
|
+
'retry_delay': task.retry_delay,
|
473
|
+
'timeout': task.timeout
|
474
|
+
})
|
475
|
+
|
476
|
+
return {
|
477
|
+
'tasks': formatted_tasks,
|
478
|
+
'total': total,
|
479
|
+
'has_more': offset + limit < total
|
480
|
+
}
|
481
|
+
|
482
|
+
except Exception as e:
|
483
|
+
logger.error(f"获取定时任务失败: {e}")
|
484
|
+
traceback.print_exc()
|
485
|
+
raise
|
486
|
+
|
487
|
+
async def get_queue_history(self, namespace_name: str, queue_name: str,
|
488
|
+
hours: int = 24, interval: int = 1) -> dict:
|
489
|
+
"""获取队列历史数据"""
|
490
|
+
conn = await self.manager.get_connection(namespace_name)
|
491
|
+
|
492
|
+
# 如果没有PostgreSQL配置,返回模拟数据
|
493
|
+
if not conn.pg_config:
|
494
|
+
return self._generate_mock_history(hours, interval)
|
495
|
+
|
496
|
+
async with await conn.get_pg_session() as session:
|
497
|
+
try:
|
498
|
+
end_time = datetime.now(timezone.utc)
|
499
|
+
start_time = end_time - timedelta(hours=hours)
|
500
|
+
|
501
|
+
# 查询历史数据
|
502
|
+
query = text("""
|
503
|
+
WITH time_series AS (
|
504
|
+
SELECT generate_series(
|
505
|
+
:start_time::timestamp,
|
506
|
+
:end_time::timestamp,
|
507
|
+
CAST(:interval AS interval)
|
508
|
+
) AS bucket
|
509
|
+
)
|
510
|
+
SELECT
|
511
|
+
ts.bucket,
|
512
|
+
COALESCE(AVG(qs.pending_count), 0) as avg_pending,
|
513
|
+
COALESCE(AVG(qs.processing_count), 0) as avg_processing,
|
514
|
+
COALESCE(AVG(qs.completed_count), 0) as avg_completed,
|
515
|
+
COALESCE(AVG(qs.failed_count), 0) as avg_failed,
|
516
|
+
COALESCE(AVG(qs.consumers), 0) as avg_consumers
|
517
|
+
FROM time_series ts
|
518
|
+
LEFT JOIN queue_stats qs ON
|
519
|
+
qs.queue_name = :queue_name AND
|
520
|
+
qs.timestamp >= ts.bucket AND
|
521
|
+
qs.timestamp < ts.bucket + CAST(:interval AS interval)
|
522
|
+
GROUP BY ts.bucket
|
523
|
+
ORDER BY ts.bucket
|
524
|
+
""")
|
525
|
+
|
526
|
+
result = await session.execute(
|
527
|
+
query,
|
528
|
+
{
|
529
|
+
'queue_name': queue_name,
|
530
|
+
'start_time': start_time,
|
531
|
+
'end_time': end_time,
|
532
|
+
'interval': f'{interval} hour'
|
533
|
+
}
|
534
|
+
)
|
535
|
+
|
536
|
+
rows = result.fetchall()
|
537
|
+
|
538
|
+
# 格式化结果
|
539
|
+
timestamps = []
|
540
|
+
pending = []
|
541
|
+
processing = []
|
542
|
+
completed = []
|
543
|
+
failed = []
|
544
|
+
consumers = []
|
545
|
+
|
546
|
+
for row in rows:
|
547
|
+
timestamps.append(row.bucket.isoformat())
|
548
|
+
pending.append(float(row.avg_pending))
|
549
|
+
processing.append(float(row.avg_processing))
|
550
|
+
completed.append(float(row.avg_completed))
|
551
|
+
failed.append(float(row.avg_failed))
|
552
|
+
consumers.append(float(row.avg_consumers))
|
553
|
+
|
554
|
+
return {
|
555
|
+
'timestamps': timestamps,
|
556
|
+
'pending': pending,
|
557
|
+
'processing': processing,
|
558
|
+
'completed': completed,
|
559
|
+
'failed': failed,
|
560
|
+
'consumers': consumers
|
561
|
+
}
|
562
|
+
|
563
|
+
except Exception as e:
|
564
|
+
logger.error(f"获取队列历史数据失败: {e}, 返回模拟数据")
|
565
|
+
traceback.print_exc()
|
566
|
+
return self._generate_mock_history(hours, interval)
|
567
|
+
|
568
|
+
def _generate_mock_history(self, hours: int, interval: int) -> dict:
|
569
|
+
"""生成模拟历史数据"""
|
570
|
+
import random
|
571
|
+
|
572
|
+
now = datetime.now(timezone.utc)
|
573
|
+
timestamps = []
|
574
|
+
pending = []
|
575
|
+
processing = []
|
576
|
+
completed = []
|
577
|
+
failed = []
|
578
|
+
consumers = []
|
579
|
+
|
580
|
+
for i in range(0, hours, interval):
|
581
|
+
timestamp = now - timedelta(hours=hours-i)
|
582
|
+
timestamps.append(timestamp.isoformat())
|
583
|
+
|
584
|
+
# 生成随机数据
|
585
|
+
base_value = 50 + random.randint(-20, 20)
|
586
|
+
pending.append(base_value + random.randint(0, 30))
|
587
|
+
processing.append(base_value // 2 + random.randint(0, 10))
|
588
|
+
completed.append(base_value * 2 + random.randint(0, 50))
|
589
|
+
failed.append(random.randint(0, 10))
|
590
|
+
consumers.append(random.randint(1, 5))
|
591
|
+
|
592
|
+
return {
|
593
|
+
'timestamps': timestamps,
|
594
|
+
'pending': pending,
|
595
|
+
'processing': processing,
|
596
|
+
'completed': completed,
|
597
|
+
'failed': failed,
|
598
|
+
'consumers': consumers
|
599
|
+
}
|
600
|
+
|
601
|
+
|
602
|
+
# 全局实例
|
603
|
+
_global_manager = None
|
604
|
+
|
605
|
+
def get_namespace_data_access() -> NamespaceJetTaskDataAccess:
|
606
|
+
"""获取全局命名空间数据访问实例"""
|
607
|
+
global _global_manager
|
608
|
+
if _global_manager is None:
|
609
|
+
manager = NamespaceDataAccessManager()
|
610
|
+
_global_manager = NamespaceJetTaskDataAccess(manager)
|
611
|
+
return _global_manager
|