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.
Files changed (89) hide show
  1. jettask/constants.py +213 -0
  2. jettask/core/app.py +525 -205
  3. jettask/core/cli.py +193 -185
  4. jettask/core/consumer_manager.py +126 -34
  5. jettask/core/context.py +3 -0
  6. jettask/core/enums.py +137 -0
  7. jettask/core/event_pool.py +501 -168
  8. jettask/core/message.py +147 -0
  9. jettask/core/offline_worker_recovery.py +181 -114
  10. jettask/core/task.py +10 -174
  11. jettask/core/task_batch.py +153 -0
  12. jettask/core/unified_manager_base.py +243 -0
  13. jettask/core/worker_scanner.py +54 -54
  14. jettask/executors/asyncio.py +184 -64
  15. jettask/webui/backend/config.py +51 -0
  16. jettask/webui/backend/data_access.py +2083 -92
  17. jettask/webui/backend/data_api.py +3294 -0
  18. jettask/webui/backend/dependencies.py +261 -0
  19. jettask/webui/backend/init_meta_db.py +158 -0
  20. jettask/webui/backend/main.py +1358 -69
  21. jettask/webui/backend/main_unified.py +78 -0
  22. jettask/webui/backend/main_v2.py +394 -0
  23. jettask/webui/backend/namespace_api.py +295 -0
  24. jettask/webui/backend/namespace_api_old.py +294 -0
  25. jettask/webui/backend/namespace_data_access.py +611 -0
  26. jettask/webui/backend/queue_backlog_api.py +727 -0
  27. jettask/webui/backend/queue_stats_v2.py +521 -0
  28. jettask/webui/backend/redis_monitor_api.py +476 -0
  29. jettask/webui/backend/unified_api_router.py +1601 -0
  30. jettask/webui/db_init.py +204 -32
  31. jettask/webui/frontend/package-lock.json +492 -1
  32. jettask/webui/frontend/package.json +4 -1
  33. jettask/webui/frontend/src/App.css +105 -7
  34. jettask/webui/frontend/src/App.jsx +49 -20
  35. jettask/webui/frontend/src/components/NamespaceSelector.jsx +166 -0
  36. jettask/webui/frontend/src/components/QueueBacklogChart.jsx +298 -0
  37. jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +638 -0
  38. jettask/webui/frontend/src/components/QueueDetailsTable.css +65 -0
  39. jettask/webui/frontend/src/components/QueueDetailsTable.jsx +487 -0
  40. jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +465 -0
  41. jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +423 -0
  42. jettask/webui/frontend/src/components/TaskFilter.jsx +425 -0
  43. jettask/webui/frontend/src/components/TimeRangeSelector.css +21 -0
  44. jettask/webui/frontend/src/components/TimeRangeSelector.jsx +160 -0
  45. jettask/webui/frontend/src/components/layout/AppLayout.css +95 -0
  46. jettask/webui/frontend/src/components/layout/AppLayout.jsx +49 -0
  47. jettask/webui/frontend/src/components/layout/Header.css +34 -10
  48. jettask/webui/frontend/src/components/layout/Header.jsx +31 -23
  49. jettask/webui/frontend/src/components/layout/SideMenu.css +137 -0
  50. jettask/webui/frontend/src/components/layout/SideMenu.jsx +209 -0
  51. jettask/webui/frontend/src/components/layout/TabsNav.css +244 -0
  52. jettask/webui/frontend/src/components/layout/TabsNav.jsx +206 -0
  53. jettask/webui/frontend/src/components/layout/UserInfo.css +197 -0
  54. jettask/webui/frontend/src/components/layout/UserInfo.jsx +197 -0
  55. jettask/webui/frontend/src/contexts/NamespaceContext.jsx +72 -0
  56. jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +245 -0
  57. jettask/webui/frontend/src/main.jsx +1 -0
  58. jettask/webui/frontend/src/pages/Alerts.jsx +684 -0
  59. jettask/webui/frontend/src/pages/Dashboard.jsx +1330 -0
  60. jettask/webui/frontend/src/pages/QueueDetail.jsx +1109 -10
  61. jettask/webui/frontend/src/pages/QueueMonitor.jsx +236 -115
  62. jettask/webui/frontend/src/pages/Queues.jsx +5 -1
  63. jettask/webui/frontend/src/pages/ScheduledTasks.jsx +809 -0
  64. jettask/webui/frontend/src/pages/Settings.jsx +800 -0
  65. jettask/webui/frontend/src/services/api.js +7 -5
  66. jettask/webui/frontend/src/utils/suppressWarnings.js +22 -0
  67. jettask/webui/frontend/src/utils/userPreferences.js +154 -0
  68. jettask/webui/multi_namespace_consumer.py +543 -0
  69. jettask/webui/pg_consumer.py +983 -246
  70. jettask/webui/static/dist/assets/index-7129cfe1.css +1 -0
  71. jettask/webui/static/dist/assets/index-8d1935cc.js +774 -0
  72. jettask/webui/static/dist/index.html +2 -2
  73. jettask/webui/task_center.py +216 -0
  74. jettask/webui/task_center_client.py +150 -0
  75. jettask/webui/unified_consumer_manager.py +193 -0
  76. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/METADATA +1 -1
  77. jettask-0.2.4.dist-info/RECORD +134 -0
  78. jettask/webui/pg_consumer_slow.py +0 -1099
  79. jettask/webui/pg_consumer_test.py +0 -678
  80. jettask/webui/static/dist/assets/index-823408e8.css +0 -1
  81. jettask/webui/static/dist/assets/index-9968b0b8.js +0 -543
  82. jettask/webui/test_pg_consumer_recovery.py +0 -547
  83. jettask/webui/test_recovery_simple.py +0 -492
  84. jettask/webui/test_self_recovery.py +0 -467
  85. jettask-0.2.1.dist-info/RECORD +0 -91
  86. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/WHEEL +0 -0
  87. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/entry_points.txt +0 -0
  88. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/licenses/LICENSE +0 -0
  89. {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