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
@@ -1,14 +1,26 @@
1
1
  """
2
2
  JetTask Monitor 独立后端API服务
3
3
  完全脱离gradio和integrated_gradio_app依赖
4
+
5
+ 数据库说明:
6
+ 1. 任务中心元数据库:通过环境变量配置(TASK_CENTER_DB_*)
7
+ - 存储命名空间配置等管理数据
8
+ - 由任务中心自己使用
9
+
10
+ 2. JetTask应用数据库:在WebUI中为每个命名空间配置
11
+ - Redis:任务队列存储
12
+ - PostgreSQL:任务结果存储
13
+ - 由JetTask worker使用
4
14
  """
5
- from fastapi import FastAPI, HTTPException
15
+ from fastapi import FastAPI, HTTPException, Request
6
16
  from fastapi.middleware.cors import CORSMiddleware
7
17
  from contextlib import asynccontextmanager
8
18
  from datetime import datetime, timedelta, timezone
9
19
  from typing import List, Dict, Optional
10
20
  from pydantic import BaseModel
21
+ from sqlalchemy import text
11
22
  import logging
23
+ import traceback
12
24
 
13
25
  import sys
14
26
  import os
@@ -16,13 +28,21 @@ import os
16
28
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
29
 
18
30
  from data_access import JetTaskDataAccess
31
+ from namespace_data_access import get_namespace_data_access
32
+ from data_api import router as data_router
33
+ from namespace_api import router as namespace_router
34
+ from queue_stats_v2 import QueueStatsV2
35
+ from queue_backlog_api import router as backlog_router
36
+ from redis_monitor_api import router as redis_monitor_router
19
37
 
20
38
  # 设置日志
21
39
  logging.basicConfig(level=logging.INFO)
22
40
  logger = logging.getLogger(__name__)
23
41
 
24
- # 创建数据访问实例
42
+ # 创建数据访问实例(保留旧的实例,用于兼容)
25
43
  data_access = JetTaskDataAccess()
44
+ # 新的命名空间数据访问
45
+ namespace_data_access = get_namespace_data_access()
26
46
 
27
47
 
28
48
  @asynccontextmanager
@@ -30,10 +50,22 @@ async def lifespan(app: FastAPI):
30
50
  """应用生命周期管理"""
31
51
  # 启动时初始化
32
52
  try:
53
+ # 1. 初始化JetTask数据访问(用于查询任务数据)
33
54
  await data_access.initialize()
55
+
56
+ # 2. 记录任务中心配置
57
+ from jettask.webui.backend.config import task_center_config
58
+ logger.info("=" * 60)
59
+ logger.info("任务中心配置:")
60
+ logger.info(f" 元数据库: {task_center_config.meta_db_host}:{task_center_config.meta_db_port}/{task_center_config.meta_db_name}")
61
+ logger.info(f" API服务: {task_center_config.api_host}:{task_center_config.api_port}")
62
+ logger.info(f" 基础URL: {task_center_config.base_url}")
63
+ logger.info("=" * 60)
64
+
34
65
  logger.info("JetTask Monitor API 启动成功")
35
66
  except Exception as e:
36
67
  logger.error(f"启动失败: {e}")
68
+ traceback.print_exc()
37
69
  raise
38
70
 
39
71
  yield
@@ -44,6 +76,7 @@ async def lifespan(app: FastAPI):
44
76
  logger.info("JetTask Monitor API 关闭完成")
45
77
  except Exception as e:
46
78
  logger.error(f"关闭时出错: {e}")
79
+ traceback.print_exc()
47
80
 
48
81
 
49
82
  app = FastAPI(
@@ -67,6 +100,7 @@ class TimeRangeQuery(BaseModel):
67
100
  end_time: Optional[datetime] = None
68
101
  time_range: Optional[str] = "15m" # 默认15分钟
69
102
  queues: Optional[List[str]] = None
103
+ filters: Optional[List[Dict]] = None # 筛选条件
70
104
 
71
105
 
72
106
  class QueueTimelineResponse(BaseModel):
@@ -74,6 +108,10 @@ class QueueTimelineResponse(BaseModel):
74
108
  granularity: str
75
109
 
76
110
 
111
+ class TrimQueueRequest(BaseModel):
112
+ max_length: int
113
+
114
+
77
115
  @app.get("/")
78
116
  async def root():
79
117
  """根路径"""
@@ -82,97 +120,103 @@ async def root():
82
120
  "version": "1.0.0",
83
121
  "timestamp": datetime.now(timezone.utc).isoformat()
84
122
  }
123
+
124
+
125
+ def get_base_queue_name(queue_name: str) -> str:
126
+ """提取基础队列名(去除优先级后缀)"""
127
+ # 检查是否包含优先级后缀(格式: queue_name:priority)
128
+ if ':' in queue_name:
129
+ parts = queue_name.rsplit(':', 1)
130
+ # 检查最后一部分是否是数字(优先级)
131
+ if parts[-1].isdigit():
132
+ return parts[0]
133
+ return queue_name
85
134
 
86
135
 
87
- @app.get("/api/queues")
88
- async def get_queues():
89
- """获取所有队列列表"""
136
+ @app.get("/api/queues/{namespace}")
137
+ async def get_queues(namespace: str):
138
+ """获取指定命名空间的队列列表"""
90
139
  try:
91
- queues_data = await data_access.fetch_queues_data()
140
+ # 使用命名空间数据访问
141
+ namespace_access = get_namespace_data_access()
142
+ # 获取指定命名空间的队列信息
143
+ queues_data = await namespace_access.get_queue_stats(namespace)
92
144
  return {
93
145
  "success": True,
94
- "data": [q['队列名称'] for q in queues_data]
146
+ "data": list(set([get_base_queue_name(q['queue_name']) for q in queues_data]))
95
147
  }
96
148
  except Exception as e:
97
149
  logger.error(f"获取队列列表失败: {e}")
150
+ traceback.print_exc()
98
151
  raise HTTPException(status_code=500, detail=str(e))
99
152
 
100
153
 
101
- @app.post("/api/queue-timeline")
102
- async def get_queue_timeline(query: TimeRangeQuery):
103
- """获取队列处理趋势数据"""
154
+ @app.post("/api/queue-flow-rates")
155
+ async def get_queue_flow_rates(query: TimeRangeQuery):
156
+ """获取单个队列的流量速率(入队、开始执行、完成)"""
104
157
  try:
105
- logger.info(f"获取队列时间线数据: {query}")
158
+ # 处理时间范围
159
+ now = datetime.now(timezone.utc)
106
160
 
107
- # 确定时间范围
108
161
  if query.start_time and query.end_time:
162
+ # 使用提供的时间范围
109
163
  start_time = query.start_time
110
164
  end_time = query.end_time
111
- time_range = "custom"
165
+ logger.info(f"使用自定义时间范围: {start_time} 到 {end_time}")
112
166
  else:
113
- # 使用预设时间范围
114
- end_time = datetime.now(timezone.utc)
115
- time_range = query.time_range
116
-
117
- if time_range == "15m":
118
- start_time = end_time - timedelta(minutes=15)
119
- elif time_range == "30m":
120
- start_time = end_time - timedelta(minutes=30)
121
- elif time_range == "1h":
122
- start_time = end_time - timedelta(hours=1)
123
- elif time_range == "3h":
124
- start_time = end_time - timedelta(hours=3)
125
- elif time_range == "6h":
126
- start_time = end_time - timedelta(hours=6)
127
- elif time_range == "12h":
128
- start_time = end_time - timedelta(hours=12)
129
- elif time_range == "24h":
130
- start_time = end_time - timedelta(hours=24)
131
- elif time_range == "7d":
132
- start_time = end_time - timedelta(days=7)
133
- elif time_range == "30d":
134
- start_time = end_time - timedelta(days=30)
167
+ # 根据time_range参数计算时间范围
168
+ time_range_map = {
169
+ "15m": timedelta(minutes=15),
170
+ "30m": timedelta(minutes=30),
171
+ "1h": timedelta(hours=1),
172
+ "3h": timedelta(hours=3),
173
+ "6h": timedelta(hours=6),
174
+ "12h": timedelta(hours=12),
175
+ "24h": timedelta(hours=24),
176
+ "7d": timedelta(days=7),
177
+ "30d": timedelta(days=30),
178
+ }
179
+
180
+ delta = time_range_map.get(query.time_range, timedelta(minutes=15))
181
+
182
+ # 获取队列的最新任务时间,确保图表包含最新数据
183
+ queue_name = query.queues[0] if query.queues else None
184
+ if queue_name:
185
+ latest_time = await data_access.get_latest_task_time(queue_name)
186
+ if latest_time:
187
+ # 使用最新任务时间作为结束时间
188
+ end_time = latest_time.replace(second=59, microsecond=999999) # 包含整分钟
189
+ logger.info(f"使用最新任务时间: {latest_time}")
190
+ else:
191
+ # 如果没有任务,使用当前时间
192
+ end_time = now.replace(second=0, microsecond=0)
135
193
  else:
136
- start_time = end_time - timedelta(minutes=15)
137
-
138
- # 如果没有指定队列,获取前3个队列
139
- if not query.queues:
140
- queues_data = await data_access.fetch_queues_data()
141
- query.queues = [q['队列名称'] for q in queues_data][:10]
142
- # logger.info(f'{start_time=} {end_time=} {query.queues=}')
143
- # 获取时间线数据
144
- timeline_data = await data_access.fetch_queue_timeline_data(
145
- query.queues, start_time, end_time
146
- )
147
- # logger.info(f"获取到时间线数据: {timeline_data} 条记录")
148
- # 计算数据粒度 - 与 data_access 中的逻辑保持一致
149
- duration = (end_time - start_time).total_seconds()
150
- ideal_interval_seconds = duration / 200 # 目标200个点
151
-
152
- # 判断时间跨度和粒度
153
- is_cross_day = start_time.date() != end_time.date()
154
-
155
- if ideal_interval_seconds <= 1:
156
- granularity = "second" # 秒级
157
- elif ideal_interval_seconds <= 60:
158
- granularity = "minute" # 分钟级以下
159
- elif ideal_interval_seconds <= 3600:
160
- granularity = "hour" # 小时级
161
- elif is_cross_day:
162
- granularity = "day" # 跨天
163
- else:
164
- granularity = "hour" # 默认小时级
194
+ end_time = now.replace(second=0, microsecond=0)
195
+
196
+ start_time = end_time - delta
197
+ logger.info(f"使用预设时间范围 {query.time_range}: {start_time} 到 {end_time}, delta: {delta}")
198
+
199
+ # 确保有队列名称
200
+ if not query.queues or len(query.queues) == 0:
201
+ return {"data": [], "granularity": "minute"}
165
202
 
166
- return QueueTimelineResponse(
167
- data=timeline_data,
168
- granularity=granularity
203
+ # 获取第一个队列的流量速率
204
+ queue_name = query.queues[0]
205
+ data, granularity = await data_access.fetch_queue_flow_rates(
206
+ queue_name, start_time, end_time, query.filters
169
207
  )
170
208
 
209
+ return {"data": data, "granularity": granularity}
210
+
171
211
  except Exception as e:
172
- logger.error(f"获取队列时间线数据失败: {e}")
212
+ logger.error(f"获取队列流量速率失败: {e}")
213
+ traceback.print_exc()
173
214
  raise HTTPException(status_code=500, detail=str(e))
174
215
 
175
216
 
217
+ # 删除了旧的 /api/queue-timeline 接口,使用 data_api.py 中的新接口
218
+
219
+
176
220
  @app.get("/api/stats")
177
221
  async def get_global_stats():
178
222
  """获取全局统计信息"""
@@ -184,6 +228,7 @@ async def get_global_stats():
184
228
  }
185
229
  except Exception as e:
186
230
  logger.error(f"获取全局统计信息失败: {e}")
231
+ traceback.print_exc()
187
232
  raise HTTPException(status_code=500, detail=str(e))
188
233
 
189
234
 
@@ -198,6 +243,189 @@ async def get_queues_detail():
198
243
  }
199
244
  except Exception as e:
200
245
  logger.error(f"获取队列详细信息失败: {e}")
246
+ traceback.print_exc()
247
+ raise HTTPException(status_code=500, detail=str(e))
248
+
249
+
250
+ # 删除了旧的 /api/queue-details 接口,使用 data_api.py 中的新接口
251
+
252
+
253
+ @app.delete("/api/queue/{queue_name}")
254
+ async def delete_queue(queue_name: str):
255
+ """删除队列"""
256
+ try:
257
+ # 这里需要实现删除队列的逻辑
258
+ # 暂时返回模拟响应
259
+ logger.info(f"删除队列请求: {queue_name}")
260
+ return {
261
+ "success": True,
262
+ "message": f"队列 {queue_name} 已删除"
263
+ }
264
+ except Exception as e:
265
+ logger.error(f"删除队列失败: {e}")
266
+ traceback.print_exc()
267
+ return {
268
+ "success": False,
269
+ "message": str(e)
270
+ }
271
+
272
+
273
+ @app.post("/api/queue/{queue_name}/trim")
274
+ async def trim_queue(queue_name: str, request: TrimQueueRequest):
275
+ """裁剪队列到指定长度"""
276
+ try:
277
+ # 这里需要实现裁剪队列的逻辑
278
+ # 暂时返回模拟响应
279
+ logger.info(f"裁剪队列请求: {queue_name}, 保留 {request.max_length} 条消息")
280
+ return {
281
+ "success": True,
282
+ "message": f"队列 {queue_name} 已裁剪至 {request.max_length} 条消息"
283
+ }
284
+ except Exception as e:
285
+ logger.error(f"裁剪队列失败: {e}")
286
+ traceback.print_exc()
287
+ return {
288
+ "success": False,
289
+ "message": str(e)
290
+ }
291
+
292
+
293
+ @app.post("/api/tasks")
294
+ async def get_tasks(request: Request):
295
+ """获取队列的任务列表(支持灵活筛选和时间范围)"""
296
+ try:
297
+ # 解析请求体
298
+ body = await request.json()
299
+ queue_name = body.get('queue_name')
300
+ page = body.get('page', 1)
301
+ page_size = body.get('page_size', 20)
302
+ filters = body.get('filters', [])
303
+
304
+ # 处理时间范围参数(与 /api/queue-flow-rates 保持一致)
305
+ start_time = body.get('start_time')
306
+ end_time = body.get('end_time')
307
+ time_range = body.get('time_range')
308
+
309
+ if not queue_name:
310
+ raise HTTPException(status_code=400, detail="queue_name is required")
311
+
312
+ # 如果提供了时间范围,计算起止时间
313
+ if not start_time or not end_time:
314
+ if time_range:
315
+ now = datetime.now(timezone.utc)
316
+ time_range_map = {
317
+ "15m": timedelta(minutes=15),
318
+ "30m": timedelta(minutes=30),
319
+ "1h": timedelta(hours=1),
320
+ "3h": timedelta(hours=3),
321
+ "6h": timedelta(hours=6),
322
+ "12h": timedelta(hours=12),
323
+ "24h": timedelta(hours=24),
324
+ "7d": timedelta(days=7),
325
+ "30d": timedelta(days=30),
326
+ }
327
+
328
+ delta = time_range_map.get(time_range)
329
+ if delta:
330
+ # 获取队列的最新任务时间,确保包含最新数据
331
+ latest_time = await data_access.get_latest_task_time(queue_name)
332
+ if latest_time:
333
+ end_time = latest_time.replace(second=59, microsecond=999999)
334
+ else:
335
+ end_time = now
336
+ start_time = end_time - delta
337
+ logger.info(f"使用时间范围 {time_range}: {start_time} 到 {end_time}")
338
+
339
+ # 如果有时间范围,将其转换为datetime对象
340
+ if start_time and isinstance(start_time, str):
341
+ start_time = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
342
+ if end_time and isinstance(end_time, str):
343
+ end_time = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
344
+
345
+ logger.info(f"获取队列 {queue_name} 的任务列表, 页码: {page}, 每页: {page_size}, 筛选条件: {filters}, 时间范围: {start_time} - {end_time}")
346
+
347
+ # 调用数据访问层获取真实数据
348
+ result = await data_access.fetch_tasks_with_filters(
349
+ queue_name=queue_name,
350
+ page=page,
351
+ page_size=page_size,
352
+ filters=filters,
353
+ start_time=start_time,
354
+ end_time=end_time
355
+ )
356
+
357
+ return result
358
+ except Exception as e:
359
+ import traceback
360
+ traceback.print_exc()
361
+ logger.error(f"获取任务列表失败: {e}")
362
+ raise HTTPException(status_code=500, detail=str(e))
363
+
364
+
365
+ @app.get("/api/task/{task_id}/details")
366
+ async def get_task_details(task_id: str, consumer_group: Optional[str] = None):
367
+ """获取单个任务的详细数据(包括task_data和result)
368
+
369
+ Args:
370
+ task_id: 任务ID (stream_id)
371
+ consumer_group: 消费者组名称(可选,用于精确定位)
372
+ """
373
+ try:
374
+ logger.info(f"获取任务 {task_id} 的详细数据, consumer_group={consumer_group}")
375
+
376
+ # 调用数据访问层获取任务详细数据
377
+ task_details = await data_access.fetch_task_details(task_id, consumer_group)
378
+
379
+ if not task_details:
380
+ raise HTTPException(status_code=404, detail="Task not found")
381
+
382
+ return {
383
+ "success": True,
384
+ "data": task_details
385
+ }
386
+ except HTTPException:
387
+ raise
388
+ except Exception as e:
389
+ logger.error(f"获取任务详细数据失败: {e}")
390
+ traceback.print_exc()
391
+ raise HTTPException(status_code=500, detail=str(e))
392
+
393
+
394
+ @app.get("/api/tasks")
395
+ async def get_tasks_legacy(
396
+ queue_name: str,
397
+ page: int = 1,
398
+ page_size: int = 20,
399
+ status: Optional[str] = None,
400
+ task_id: Optional[str] = None,
401
+ worker_id: Optional[str] = None
402
+ ):
403
+ """获取队列的任务列表(向后兼容旧版本)"""
404
+ try:
405
+ # 构建筛选条件
406
+ filters = []
407
+ if status:
408
+ filters.append({'field': 'status', 'operator': 'eq', 'value': status})
409
+ if task_id:
410
+ filters.append({'field': 'id', 'operator': 'eq', 'value': task_id})
411
+ if worker_id:
412
+ filters.append({'field': 'worker_id', 'operator': 'eq', 'value': worker_id})
413
+
414
+ logger.info(f"获取队列 {queue_name} 的任务列表, 页码: {page}, 每页: {page_size}")
415
+
416
+ # 调用数据访问层获取真实数据
417
+ result = await data_access.fetch_tasks_with_filters(
418
+ queue_name=queue_name,
419
+ page=page,
420
+ page_size=page_size,
421
+ filters=filters
422
+ )
423
+
424
+ return result
425
+ except Exception as e:
426
+ import traceback
427
+ traceback.print_exc()
428
+ logger.error(f"获取任务列表失败: {e}")
201
429
  raise HTTPException(status_code=500, detail=str(e))
202
430
 
203
431
 
@@ -210,11 +438,1072 @@ async def health_check():
210
438
  }
211
439
 
212
440
 
441
+ # ============= 定时任务管理API =============
442
+
443
+ class ScheduledTaskRequest(BaseModel):
444
+ """定时任务请求模型"""
445
+ namespace: str # 命名空间
446
+ name: str
447
+ queue_name: str
448
+ task_data: dict
449
+ schedule_type: str # cron, interval, daily, weekly, monthly
450
+ schedule_config: dict # 根据schedule_type的不同配置
451
+ is_active: bool = True
452
+ description: Optional[str] = None
453
+ max_retry: int = 3
454
+ timeout: Optional[int] = None
455
+
456
+
457
+ @app.get("/api/scheduled-tasks")
458
+ async def get_scheduled_tasks(
459
+ page: int = 1,
460
+ page_size: int = 20,
461
+ search: Optional[str] = None,
462
+ is_active: Optional[bool] = None
463
+ ):
464
+ """获取定时任务列表(兼容旧版本)"""
465
+ try:
466
+ # 使用真实的数据库操作
467
+ async with data_access.get_session() as session:
468
+ tasks, total = await data_access.fetch_scheduled_tasks(
469
+ session=session,
470
+ page=page,
471
+ page_size=page_size,
472
+ search=search,
473
+ is_active=is_active
474
+ )
475
+
476
+ return {
477
+ "success": True,
478
+ "data": tasks,
479
+ "total": total,
480
+ "page": page,
481
+ "page_size": page_size
482
+ }
483
+ except Exception as e:
484
+ logger.error(f"获取定时任务列表失败: {e}")
485
+ traceback.print_exc()
486
+ raise HTTPException(status_code=500, detail=str(e))
487
+
488
+
489
+ @app.get("/api/scheduled-tasks/statistics/{namespace}")
490
+ async def get_scheduled_tasks_statistics(namespace: str):
491
+ """获取定时任务统计数据"""
492
+ try:
493
+ async with data_access.AsyncSessionLocal() as session:
494
+ # 获取统计数据,传递命名空间参数
495
+ stats = await data_access.get_scheduled_tasks_statistics(session, namespace)
496
+ return stats
497
+ except Exception as e:
498
+ logger.error(f"获取定时任务统计失败: {e}")
499
+ traceback.print_exc()
500
+ raise HTTPException(status_code=500, detail=str(e))
501
+
502
+
503
+ @app.post("/api/scheduled-tasks/list")
504
+ async def get_scheduled_tasks_with_filters(request: Request):
505
+ """获取定时任务列表(支持高级筛选)"""
506
+ try:
507
+ # 解析请求体
508
+ body = await request.json()
509
+
510
+ page = body.get('page', 1)
511
+ page_size = body.get('page_size', 20)
512
+ search = body.get('search')
513
+ is_active = body.get('is_active')
514
+ filters = body.get('filters', [])
515
+ time_range = body.get('time_range')
516
+ start_time = body.get('start_time')
517
+ end_time = body.get('end_time')
518
+
519
+ print(f'{filters=}')
520
+ # 使用真实的数据库操作
521
+ async with data_access.get_session() as session:
522
+ tasks, total = await data_access.fetch_scheduled_tasks(
523
+ session=session,
524
+ page=page,
525
+ page_size=page_size,
526
+ search=search,
527
+ is_active=is_active,
528
+ filters=filters,
529
+ time_range=time_range,
530
+ start_time=start_time,
531
+ end_time=end_time
532
+ )
533
+
534
+ return {
535
+ "success": True,
536
+ "data": tasks,
537
+ "total": total,
538
+ "page": page,
539
+ "page_size": page_size
540
+ }
541
+ except Exception as e:
542
+ logger.error(f"获取定时任务列表失败: {e}")
543
+ traceback.print_exc()
544
+ raise HTTPException(status_code=500, detail=str(e))
545
+
546
+
547
+ def validate_schedule_config(schedule_type: str, schedule_config: dict):
548
+ """验证调度配置"""
549
+ if schedule_type == 'interval':
550
+ if 'seconds' in schedule_config:
551
+ seconds = schedule_config.get('seconds')
552
+ if seconds is None or seconds <= 0:
553
+ raise ValueError(f"间隔时间必须大于0秒,当前值: {seconds}")
554
+ if seconds < 1:
555
+ raise ValueError(f"间隔时间不能小于1秒,当前值: {seconds}秒。小于1秒的高频任务可能影响系统性能")
556
+ elif 'minutes' in schedule_config:
557
+ minutes = schedule_config.get('minutes')
558
+ if minutes is None or minutes <= 0:
559
+ raise ValueError(f"间隔时间必须大于0分钟,当前值: {minutes}")
560
+ else:
561
+ raise ValueError("interval类型的任务必须指定seconds或minutes")
562
+ elif schedule_type == 'cron':
563
+ if 'cron_expression' not in schedule_config:
564
+ raise ValueError("cron类型的任务必须指定cron_expression")
565
+ # 可以添加cron表达式格式验证
566
+
567
+ @app.post("/api/scheduled-tasks")
568
+ async def create_scheduled_task(request: ScheduledTaskRequest):
569
+ """创建定时任务"""
570
+ try:
571
+ # 验证调度配置
572
+ validate_schedule_config(request.schedule_type, request.schedule_config)
573
+
574
+ # 使用真实的数据库操作
575
+ task_data = {
576
+ "namespace": request.namespace,
577
+ "name": request.name,
578
+ "queue_name": request.queue_name,
579
+ "task_data": request.task_data,
580
+ "schedule_type": request.schedule_type,
581
+ "schedule_config": request.schedule_config,
582
+ "is_active": request.is_active,
583
+ "description": request.description,
584
+ "max_retry": request.max_retry,
585
+ "timeout": request.timeout
586
+ }
587
+
588
+ async with data_access.get_session() as session:
589
+ task = await data_access.create_scheduled_task(session, task_data)
590
+
591
+ return {
592
+ "success": True,
593
+ "data": task,
594
+ "message": "定时任务创建成功"
595
+ }
596
+ except Exception as e:
597
+ logger.error(f"创建定时任务失败: {e}")
598
+ traceback.print_exc()
599
+ raise HTTPException(status_code=500, detail=str(e))
600
+
601
+
602
+ @app.put("/api/scheduled-tasks/{task_id}")
603
+ async def update_scheduled_task(task_id: str, request: ScheduledTaskRequest):
604
+ """更新定时任务"""
605
+ try:
606
+ # 验证调度配置
607
+ validate_schedule_config(request.schedule_type, request.schedule_config)
608
+
609
+ # 使用真实的数据库操作
610
+ task_data = {
611
+ "namespace": request.namespace,
612
+ "name": request.name,
613
+ "queue_name": request.queue_name,
614
+ "task_data": request.task_data,
615
+ "schedule_type": request.schedule_type,
616
+ "schedule_config": request.schedule_config,
617
+ "is_active": request.is_active,
618
+ "description": request.description,
619
+ "max_retry": request.max_retry,
620
+ "timeout": request.timeout
621
+ }
622
+
623
+ async with data_access.get_session() as session:
624
+ task = await data_access.update_scheduled_task(session, task_id, task_data)
625
+
626
+ return {
627
+ "success": True,
628
+ "data": task,
629
+ "message": "定时任务更新成功"
630
+ }
631
+ except Exception as e:
632
+ logger.error(f"更新定时任务失败: {e}")
633
+ traceback.print_exc()
634
+ raise HTTPException(status_code=500, detail=str(e))
635
+
636
+
637
+ @app.delete("/api/scheduled-tasks/{task_id}")
638
+ async def delete_scheduled_task(task_id: str):
639
+ """删除定时任务"""
640
+ try:
641
+ # 使用真实的数据库操作
642
+ async with data_access.get_session() as session:
643
+ success = await data_access.delete_scheduled_task(session, task_id)
644
+
645
+ if success:
646
+ return {
647
+ "success": True,
648
+ "message": f"定时任务 {task_id} 已删除"
649
+ }
650
+ else:
651
+ raise HTTPException(status_code=404, detail="定时任务不存在")
652
+ except HTTPException:
653
+ raise
654
+ except Exception as e:
655
+ logger.error(f"删除定时任务失败: {e}")
656
+ traceback.print_exc()
657
+ raise HTTPException(status_code=500, detail=str(e))
658
+
659
+
660
+ @app.post("/api/scheduled-tasks/{task_id}/toggle")
661
+ async def toggle_scheduled_task(task_id: str):
662
+ """启用/禁用定时任务"""
663
+ try:
664
+ # 使用真实的数据库操作
665
+ async with data_access.get_session() as session:
666
+ task = await data_access.toggle_scheduled_task(session, task_id)
667
+
668
+ if task:
669
+ return {
670
+ "success": True,
671
+ "data": {
672
+ "id": task["id"],
673
+ "is_active": task["enabled"] # 映射 enabled 到 is_active
674
+ },
675
+ "message": "定时任务状态已更新"
676
+ }
677
+ else:
678
+ raise HTTPException(status_code=404, detail="定时任务不存在")
679
+ except HTTPException:
680
+ raise
681
+ except Exception as e:
682
+ logger.error(f"切换定时任务状态失败: {e}")
683
+ traceback.print_exc()
684
+ raise HTTPException(status_code=500, detail=str(e))
685
+
686
+
687
+ @app.post("/api/scheduled-tasks/{scheduled_task_id}/execute")
688
+ async def execute_scheduled_task_now(scheduled_task_id: str):
689
+ """立即执行定时任务"""
690
+ try:
691
+ # 使用真实的数据库操作获取任务信息
692
+ async with data_access.get_session() as session:
693
+ # 获取定时任务详情
694
+ task = await data_access.get_scheduled_task_by_id(session, scheduled_task_id)
695
+
696
+ if not task:
697
+ raise HTTPException(status_code=404, detail=f"定时任务 {scheduled_task_id} 不存在")
698
+
699
+ # 检查任务是否启用
700
+ if not task.get('enabled', False):
701
+ raise HTTPException(status_code=400, detail="任务已禁用,无法执行")
702
+
703
+ # 复用现有的任务发送机制
704
+ import sys
705
+ import os
706
+ # 添加jettask到Python路径
707
+ jettask_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
708
+ if jettask_path not in sys.path:
709
+ sys.path.insert(0, jettask_path)
710
+
711
+
712
+ from jettask import Jettask
713
+
714
+ # 获取任务的命名空间
715
+ namespace = task.get('namespace', 'default')
716
+
717
+ # 从命名空间数据访问获取该命名空间的配置
718
+ from namespace_data_access import get_namespace_data_access
719
+ namespace_access = get_namespace_data_access()
720
+ conn = await namespace_access.manager.get_connection(namespace)
721
+
722
+ # 获取该命名空间的Redis和PostgreSQL URL
723
+ redis_url = conn.redis_config.get('url') if conn.redis_config else None
724
+ pg_url = conn.pg_config.get('url') if conn.pg_config else None
725
+
726
+ if not redis_url:
727
+ raise HTTPException(status_code=500, detail=f"命名空间 {namespace} 没有配置Redis")
728
+
729
+ # 创建Jettask实例,使用正确的命名空间
730
+ jettask_app = Jettask(
731
+ redis_url=redis_url,
732
+ pg_url=pg_url,
733
+ redis_prefix=namespace # 使用命名空间作为Redis前缀
734
+ )
735
+
736
+ print(f"触发定时任务 {scheduled_task_id} (命名空间: {namespace}), 任务信息: {task}")
737
+ # 准备任务参数
738
+ task_args = task.get('task_args', [])
739
+ task_kwargs = task.get('task_kwargs', {})
740
+
741
+ # 使用TaskMessage和send_tasks发送任务
742
+ from jettask.core.message import TaskMessage
743
+
744
+ # 添加_task_name到kwargs中用于路由
745
+ task_kwargs['_task_name'] = task['task_name']
746
+
747
+ # 创建TaskMessage
748
+ task_msg = TaskMessage(
749
+ queue=task['queue_name'],
750
+ args=task_args,
751
+ kwargs=task_kwargs,
752
+ scheduled_task_id=scheduled_task_id
753
+ )
754
+
755
+ # 发送任务
756
+ event_ids = await jettask_app.send_tasks([task_msg])
757
+ event_id = event_ids[0] if event_ids else None
758
+
759
+
760
+ # 记录执行历史
761
+ logger.info(f"定时任务 {scheduled_task_id} (命名空间: {namespace}) 已手动触发,event_id: {event_id}")
762
+
763
+ return {
764
+ "success": True,
765
+ "message": f"任务已成功触发",
766
+ "event_id": str(event_id) if event_id else None,
767
+ "task_name": task['task_name'],
768
+ "queue_name": task['queue_name'],
769
+ "namespace": namespace
770
+ }
771
+
772
+ except HTTPException:
773
+ raise
774
+ except Exception as e:
775
+ import traceback
776
+ traceback.print_exc()
777
+ logger.error(f"执行定时任务失败: {e}")
778
+ raise HTTPException(status_code=500, detail=str(e))
779
+
780
+
781
+ @app.get("/api/scheduled-tasks/{task_id}/history")
782
+ async def get_scheduled_task_history(
783
+ task_id: str,
784
+ page: int = 1,
785
+ page_size: int = 20
786
+ ):
787
+ """获取定时任务执行历史"""
788
+ try:
789
+ # 使用真实的数据库操作
790
+ async with data_access.get_session() as session:
791
+ history, total = await data_access.fetch_task_execution_history(
792
+ session=session,
793
+ task_id=task_id,
794
+ page=page,
795
+ page_size=page_size
796
+ )
797
+
798
+ return {
799
+ "success": True,
800
+ "data": history,
801
+ "total": total,
802
+ "page": page,
803
+ "page_size": page_size
804
+ }
805
+ except Exception as e:
806
+ logger.error(f"获取定时任务执行历史失败: {e}")
807
+ traceback.print_exc()
808
+ raise HTTPException(status_code=500, detail=str(e))
809
+
810
+
811
+ @app.get("/api/scheduled-tasks/{task_id}/execution-trend")
812
+ async def get_scheduled_task_execution_trend(
813
+ task_id: str,
814
+ time_range: str = "7d"
815
+ ):
816
+ """获取定时任务执行趋势"""
817
+ try:
818
+ # 使用真实的数据库操作
819
+ async with data_access.get_session() as session:
820
+ data = await data_access.fetch_task_execution_trend(
821
+ session=session,
822
+ task_id=task_id,
823
+ time_range=time_range
824
+ )
825
+
826
+ return {
827
+ "success": True,
828
+ "data": data,
829
+ "time_range": time_range
830
+ }
831
+ except Exception as e:
832
+ logger.error(f"获取定时任务执行趋势失败: {e}")
833
+ traceback.print_exc()
834
+ raise HTTPException(status_code=500, detail=str(e))
835
+
836
+
837
+ # ============= 命名空间管理API (任务中心) =============
838
+
839
+ # 导入命名空间API路由
840
+ from jettask.webui.backend.namespace_api import router as namespace_router
841
+
842
+ # 注册命名空间路由
843
+ app.include_router(namespace_router)
844
+ app.include_router(data_router)
845
+ app.include_router(backlog_router)
846
+ app.include_router(redis_monitor_router)
847
+
848
+
849
+
850
+
851
+
852
+
853
+
854
+ # ============= 告警管理API =============
855
+
856
+ class AlertRuleRequest(BaseModel):
857
+ """告警规则请求模型"""
858
+ name: str
859
+ rule_type: str # queue_size, error_rate, response_time, custom
860
+ target_queues: List[str] # 监控的队列列表
861
+ condition: dict # 触发条件配置
862
+ action_type: str # webhook, email, sms
863
+ action_config: dict # 通知配置
864
+ is_active: bool = True
865
+ description: Optional[str] = None
866
+ check_interval: int = 60 # 检查间隔(秒)
867
+
868
+
869
+ @app.get("/api/alert-rules")
870
+ async def get_alert_rules(
871
+ page: int = 1,
872
+ page_size: int = 20,
873
+ is_active: Optional[bool] = None
874
+ ):
875
+ """获取告警规则列表"""
876
+ try:
877
+ # 模拟数据
878
+ rules = [
879
+ {
880
+ "id": "rule_001",
881
+ "name": "队列积压告警",
882
+ "rule_type": "queue_size",
883
+ "target_queues": ["order_queue", "payment_queue"],
884
+ "condition": {"threshold": 1000, "operator": ">"},
885
+ "action_type": "webhook",
886
+ "action_config": {"url": "https://example.com/webhook"},
887
+ "is_active": True,
888
+ "last_triggered": "2025-08-31T14:30:00Z",
889
+ "created_at": "2025-08-01T10:00:00Z",
890
+ "description": "当队列积压超过1000时触发告警"
891
+ },
892
+ {
893
+ "id": "rule_002",
894
+ "name": "高错误率告警",
895
+ "rule_type": "error_rate",
896
+ "target_queues": ["*"], # 所有队列
897
+ "condition": {"threshold": 0.1, "operator": ">", "window": 300},
898
+ "action_type": "email",
899
+ "action_config": {"recipients": ["admin@example.com"]},
900
+ "is_active": True,
901
+ "last_triggered": None,
902
+ "created_at": "2025-08-15T14:00:00Z",
903
+ "description": "5分钟内错误率超过10%时告警"
904
+ },
905
+ {
906
+ "id": "rule_003",
907
+ "name": "响应时间告警",
908
+ "rule_type": "response_time",
909
+ "target_queues": ["api_queue"],
910
+ "condition": {"threshold": 5000, "operator": ">", "percentile": 95},
911
+ "action_type": "webhook",
912
+ "action_config": {"url": "https://slack.com/webhook"},
913
+ "is_active": False,
914
+ "last_triggered": "2025-08-30T09:00:00Z",
915
+ "created_at": "2025-07-01T08:00:00Z",
916
+ "description": "95分位响应时间超过5秒时告警"
917
+ }
918
+ ]
919
+
920
+ # 过滤
921
+ if is_active is not None:
922
+ rules = [r for r in rules if r["is_active"] == is_active]
923
+
924
+ # 分页
925
+ total = len(rules)
926
+ start = (page - 1) * page_size
927
+ end = start + page_size
928
+ paginated_rules = rules[start:end]
929
+
930
+ return {
931
+ "success": True,
932
+ "data": paginated_rules,
933
+ "total": total,
934
+ "page": page,
935
+ "page_size": page_size
936
+ }
937
+ except Exception as e:
938
+ logger.error(f"获取告警规则列表失败: {e}")
939
+ traceback.print_exc()
940
+ raise HTTPException(status_code=500, detail=str(e))
941
+
942
+
943
+ @app.post("/api/alert-rules")
944
+ async def create_alert_rule(request: AlertRuleRequest):
945
+ """创建告警规则"""
946
+ try:
947
+ rule = {
948
+ "id": f"rule_{datetime.now().strftime('%Y%m%d%H%M%S')}",
949
+ "name": request.name,
950
+ "rule_type": request.rule_type,
951
+ "target_queues": request.target_queues,
952
+ "condition": request.condition,
953
+ "action_type": request.action_type,
954
+ "action_config": request.action_config,
955
+ "is_active": request.is_active,
956
+ "description": request.description,
957
+ "check_interval": request.check_interval,
958
+ "created_at": datetime.now(timezone.utc).isoformat()
959
+ }
960
+
961
+ return {
962
+ "success": True,
963
+ "data": rule,
964
+ "message": "告警规则创建成功"
965
+ }
966
+ except Exception as e:
967
+ logger.error(f"创建告警规则失败: {e}")
968
+ traceback.print_exc()
969
+ raise HTTPException(status_code=500, detail=str(e))
970
+
971
+
972
+ @app.put("/api/alert-rules/{rule_id}")
973
+ async def update_alert_rule(rule_id: str, request: AlertRuleRequest):
974
+ """更新告警规则"""
975
+ try:
976
+ rule = {
977
+ "id": rule_id,
978
+ "name": request.name,
979
+ "rule_type": request.rule_type,
980
+ "target_queues": request.target_queues,
981
+ "condition": request.condition,
982
+ "action_type": request.action_type,
983
+ "action_config": request.action_config,
984
+ "is_active": request.is_active,
985
+ "description": request.description,
986
+ "check_interval": request.check_interval,
987
+ "updated_at": datetime.now(timezone.utc).isoformat()
988
+ }
989
+
990
+ return {
991
+ "success": True,
992
+ "data": rule,
993
+ "message": "告警规则更新成功"
994
+ }
995
+ except Exception as e:
996
+ logger.error(f"更新告警规则失败: {e}")
997
+ traceback.print_exc()
998
+ raise HTTPException(status_code=500, detail=str(e))
999
+
1000
+
1001
+ @app.delete("/api/alert-rules/{rule_id}")
1002
+ async def delete_alert_rule(rule_id: str):
1003
+ """删除告警规则"""
1004
+ try:
1005
+ return {
1006
+ "success": True,
1007
+ "message": f"告警规则 {rule_id} 已删除"
1008
+ }
1009
+ except Exception as e:
1010
+ logger.error(f"删除告警规则失败: {e}")
1011
+ traceback.print_exc()
1012
+ raise HTTPException(status_code=500, detail=str(e))
1013
+
1014
+
1015
+ @app.post("/api/alert-rules/{rule_id}/toggle")
1016
+ async def toggle_alert_rule(rule_id: str):
1017
+ """启用/禁用告警规则"""
1018
+ try:
1019
+ return {
1020
+ "success": True,
1021
+ "data": {
1022
+ "id": rule_id,
1023
+ "is_active": True # 切换后的状态
1024
+ },
1025
+ "message": "告警规则状态已更新"
1026
+ }
1027
+ except Exception as e:
1028
+ logger.error(f"切换告警规则状态失败: {e}")
1029
+ traceback.print_exc()
1030
+ raise HTTPException(status_code=500, detail=str(e))
1031
+
1032
+
1033
+ @app.get("/api/alert-rules/{rule_id}/history")
1034
+ async def get_alert_history(
1035
+ rule_id: str,
1036
+ page: int = 1,
1037
+ page_size: int = 20
1038
+ ):
1039
+ """获取告警触发历史"""
1040
+ try:
1041
+ # 模拟告警历史数据
1042
+ history = [
1043
+ {
1044
+ "id": f"alert_{i}",
1045
+ "rule_id": rule_id,
1046
+ "triggered_at": (datetime.now(timezone.utc) - timedelta(hours=i*2)).isoformat(),
1047
+ "resolved_at": (datetime.now(timezone.utc) - timedelta(hours=i*2-1)).isoformat() if i % 2 == 0 else None,
1048
+ "status": "resolved" if i % 2 == 0 else "active",
1049
+ "trigger_value": 1200 + i * 100,
1050
+ "notification_sent": True,
1051
+ "notification_response": {"status": "success"}
1052
+ }
1053
+ for i in range(1, 11)
1054
+ ]
1055
+
1056
+ # 分页
1057
+ total = len(history)
1058
+ start = (page - 1) * page_size
1059
+ end = start + page_size
1060
+ paginated_history = history[start:end]
1061
+
1062
+ return {
1063
+ "success": True,
1064
+ "data": paginated_history,
1065
+ "total": total,
1066
+ "page": page,
1067
+ "page_size": page_size
1068
+ }
1069
+ except Exception as e:
1070
+ logger.error(f"获取告警历史失败: {e}")
1071
+ traceback.print_exc()
1072
+ raise HTTPException(status_code=500, detail=str(e))
1073
+
1074
+
1075
+ @app.post("/api/alert-rules/{rule_id}/test")
1076
+ async def test_alert_rule(rule_id: str):
1077
+ """测试告警规则(发送测试通知)"""
1078
+ try:
1079
+ # 模拟测试告警
1080
+ return {
1081
+ "success": True,
1082
+ "message": "测试通知已发送",
1083
+ "data": {
1084
+ "rule_id": rule_id,
1085
+ "test_time": datetime.now(timezone.utc).isoformat(),
1086
+ "notification_result": {
1087
+ "status": "success",
1088
+ "response": {"code": 200, "message": "OK"}
1089
+ }
1090
+ }
1091
+ }
1092
+ except Exception as e:
1093
+ logger.error(f"测试告警规则失败: {e}")
1094
+ traceback.print_exc()
1095
+ raise HTTPException(status_code=500, detail=str(e))
1096
+
1097
+
1098
+ # ===== 新增API v2: 支持消费者组和优先级队列 =====
1099
+
1100
+ # 新的简洁路径
1101
+ @app.get("/api/data/queue-stats")
1102
+ async def get_queue_stats_simplified(
1103
+ namespace: str = "default",
1104
+ queue: Optional[str] = None,
1105
+ start_time: Optional[datetime] = None,
1106
+ end_time: Optional[datetime] = None,
1107
+ time_range: Optional[str] = None
1108
+ ):
1109
+ """
1110
+ 获取队列统计信息 - 简化的API路径
1111
+
1112
+ 参数:
1113
+ - namespace: 命名空间,默认为'default'
1114
+ - queue: 可选,筛选特定队列
1115
+ - start_time: 开始时间
1116
+ - end_time: 结束时间
1117
+ - time_range: 时间范围(如'15m', '1h', '24h')
1118
+ """
1119
+ # 直接调用原有的函数
1120
+ return await get_queue_stats_v2(namespace, queue, start_time, end_time, time_range)
1121
+
1122
+ # 保留原有路径以保证兼容性
1123
+ @app.get("/api/v2/namespaces/{namespace}/queues/stats")
1124
+ async def get_queue_stats_v2(
1125
+ namespace: str,
1126
+ queue: Optional[str] = None,
1127
+ start_time: Optional[datetime] = None,
1128
+ end_time: Optional[datetime] = None,
1129
+ time_range: Optional[str] = None
1130
+ ):
1131
+ """
1132
+ 获取队列统计信息v2 - 支持消费者组详情和优先级队列
1133
+
1134
+ 参数:
1135
+ - queue: 可选,筛选特定队列
1136
+ - start_time: 开始时间
1137
+ - end_time: 结束时间
1138
+ - time_range: 时间范围(如'15m', '1h', '24h')
1139
+ """
1140
+ try:
1141
+ # 获取命名空间连接
1142
+ conn = await namespace_data_access.manager.get_connection(namespace)
1143
+
1144
+ # 获取Redis客户端
1145
+ redis_client = await conn.get_redis_client(decode=False)
1146
+
1147
+ # 获取PostgreSQL会话(可选)
1148
+ pg_session = None
1149
+ if conn.AsyncSessionLocal:
1150
+ pg_session = conn.AsyncSessionLocal()
1151
+
1152
+ try:
1153
+ # 创建统计服务实例
1154
+ stats_service = QueueStatsV2(
1155
+ redis_client=redis_client,
1156
+ pg_session=pg_session,
1157
+ redis_prefix=conn.redis_prefix
1158
+ )
1159
+
1160
+ # 处理时间筛选参数
1161
+ time_filter = None
1162
+ if time_range or start_time or end_time:
1163
+ time_filter = {}
1164
+
1165
+ # 如果提供了time_range,计算开始和结束时间
1166
+ if time_range and time_range != 'custom':
1167
+ now = datetime.now(timezone.utc)
1168
+ if time_range.endswith('m'):
1169
+ minutes = int(time_range[:-1])
1170
+ time_filter['start_time'] = now - timedelta(minutes=minutes)
1171
+ time_filter['end_time'] = now
1172
+ elif time_range.endswith('h'):
1173
+ hours = int(time_range[:-1])
1174
+ time_filter['start_time'] = now - timedelta(hours=hours)
1175
+ time_filter['end_time'] = now
1176
+ elif time_range.endswith('d'):
1177
+ days = int(time_range[:-1])
1178
+ time_filter['start_time'] = now - timedelta(days=days)
1179
+ time_filter['end_time'] = now
1180
+ else:
1181
+ # 使用提供的start_time和end_time
1182
+ if start_time:
1183
+ time_filter['start_time'] = start_time
1184
+ if end_time:
1185
+ time_filter['end_time'] = end_time
1186
+
1187
+ # 获取队列统计(使用分组格式)
1188
+ stats = await stats_service.get_queue_stats_grouped(time_filter)
1189
+
1190
+ # 如果指定了队列筛选,则过滤结果
1191
+ if queue:
1192
+ stats = [s for s in stats if s['queue_name'] == queue]
1193
+
1194
+ return {
1195
+ "success": True,
1196
+ "data": stats
1197
+ }
1198
+
1199
+ finally:
1200
+ if pg_session:
1201
+ await pg_session.close()
1202
+ await redis_client.aclose()
1203
+
1204
+ except Exception as e:
1205
+ logger.error(f"获取队列统计v2失败: {e}")
1206
+ traceback.print_exc()
1207
+ raise HTTPException(status_code=500, detail=str(e))
1208
+
1209
+
1210
+ @app.post("/api/v2/namespaces/{namespace}/tasks")
1211
+ async def get_tasks_v2(namespace: str, request: Request):
1212
+ """
1213
+ 获取任务列表v2 - 支持tasks和task_runs表连表查询
1214
+ """
1215
+ try:
1216
+ # 解析请求体
1217
+ body = await request.json()
1218
+ queue_name = body.get('queue_name')
1219
+ page = body.get('page', 1)
1220
+ page_size = body.get('page_size', 20)
1221
+ filters = body.get('filters', [])
1222
+
1223
+ if not queue_name:
1224
+ raise HTTPException(status_code=400, detail="queue_name is required")
1225
+
1226
+ # 获取命名空间连接
1227
+ conn = await namespace_data_access.manager.get_connection(namespace)
1228
+
1229
+ if not conn.AsyncSessionLocal:
1230
+ raise HTTPException(status_code=400, detail="PostgreSQL not configured for this namespace")
1231
+
1232
+ # async with conn.AsyncSessionLocal() as session:
1233
+ # result = await get_task_details_v2(
1234
+ # pg_session=session,
1235
+ # queue_name=queue_name,
1236
+ # page=page,
1237
+ # page_size=page_size,
1238
+ # filters=filters
1239
+ # )
1240
+
1241
+ return {
1242
+ "success": True,
1243
+ "data": {"error": "get_task_details_v2 not implemented"}
1244
+ }
1245
+
1246
+ except HTTPException:
1247
+ raise
1248
+ except Exception as e:
1249
+ logger.error(f"获取任务列表v2失败: {e}")
1250
+ traceback.print_exc()
1251
+ raise HTTPException(status_code=500, detail=str(e))
1252
+
1253
+
1254
+ @app.get("/api/v2/namespaces/{namespace}/consumer-groups/{group_name}/stats")
1255
+ async def get_consumer_group_stats(namespace: str, group_name: str):
1256
+ """
1257
+ 获取特定消费者组的详细统计
1258
+ """
1259
+ try:
1260
+ # 获取命名空间连接
1261
+ conn = await namespace_data_access.manager.get_connection(namespace)
1262
+
1263
+ # 获取PostgreSQL会话
1264
+ if not conn.AsyncSessionLocal:
1265
+ raise HTTPException(status_code=400, detail="PostgreSQL not configured for this namespace")
1266
+
1267
+ async with conn.AsyncSessionLocal() as session:
1268
+ # 查询消费者组的执行统计
1269
+ query = text("""
1270
+ WITH group_stats AS (
1271
+ SELECT
1272
+ tr.consumer_group,
1273
+ tr.task_name,
1274
+ COUNT(*) as total_tasks,
1275
+ COUNT(CASE WHEN tr.status = 'success' THEN 1 END) as success_count,
1276
+ COUNT(CASE WHEN tr.status = 'failed' THEN 1 END) as failed_count,
1277
+ COUNT(CASE WHEN tr.status = 'running' THEN 1 END) as running_count,
1278
+ AVG(tr.execution_time) as avg_execution_time,
1279
+ MIN(tr.execution_time) as min_execution_time,
1280
+ MAX(tr.execution_time) as max_execution_time,
1281
+ AVG(tr.duration) as avg_duration,
1282
+ MIN(tr.started_at) as first_task_time,
1283
+ MAX(tr.completed_at) as last_task_time
1284
+ FROM task_runs tr
1285
+ WHERE tr.consumer_group = :group_name
1286
+ AND tr.started_at > NOW() - INTERVAL '24 hours'
1287
+ GROUP BY tr.consumer_group, tr.task_name
1288
+ ),
1289
+ hourly_stats AS (
1290
+ SELECT
1291
+ DATE_TRUNC('hour', tr.started_at) as hour,
1292
+ COUNT(*) as task_count,
1293
+ AVG(tr.execution_time) as avg_exec_time
1294
+ FROM task_runs tr
1295
+ WHERE tr.consumer_group = :group_name
1296
+ AND tr.started_at > NOW() - INTERVAL '24 hours'
1297
+ GROUP BY DATE_TRUNC('hour', tr.started_at)
1298
+ ORDER BY hour
1299
+ )
1300
+ SELECT
1301
+ (SELECT row_to_json(gs) FROM group_stats gs) as summary,
1302
+ (SELECT json_agg(hs) FROM hourly_stats hs) as hourly_trend
1303
+ """)
1304
+
1305
+ result = await session.execute(query, {'group_name': group_name})
1306
+ row = result.fetchone()
1307
+
1308
+ if not row or not row.summary:
1309
+ return {
1310
+ "success": True,
1311
+ "data": {
1312
+ "group_name": group_name,
1313
+ "summary": {},
1314
+ "hourly_trend": []
1315
+ }
1316
+ }
1317
+
1318
+ return {
1319
+ "success": True,
1320
+ "data": {
1321
+ "group_name": group_name,
1322
+ "summary": row.summary,
1323
+ "hourly_trend": row.hourly_trend or []
1324
+ }
1325
+ }
1326
+
1327
+ except Exception as e:
1328
+ logger.error(f"获取消费者组统计失败: {e}")
1329
+ traceback.print_exc()
1330
+ raise HTTPException(status_code=500, detail=str(e))
1331
+
1332
+
1333
+ # ============= Stream积压监控API =============
1334
+
1335
+ @app.get("/api/stream-backlog/{namespace}")
1336
+ async def get_stream_backlog(
1337
+ namespace: str,
1338
+ stream_name: Optional[str] = None,
1339
+ hours: int = 24
1340
+ ):
1341
+ """
1342
+ 获取Stream积压监控数据
1343
+
1344
+ Args:
1345
+ namespace: 命名空间
1346
+ stream_name: 可选,指定stream名称
1347
+ hours: 查询最近多少小时的数据(默认24小时)
1348
+ """
1349
+ try:
1350
+ from datetime import datetime, timedelta, timezone
1351
+
1352
+ # 计算时间范围
1353
+ end_time = datetime.now(timezone.utc)
1354
+ start_time = end_time - timedelta(hours=hours)
1355
+
1356
+ async with data_access.AsyncSessionLocal() as session:
1357
+ # 构建查询
1358
+ if stream_name:
1359
+ query = text("""
1360
+ SELECT
1361
+ stream_name,
1362
+ consumer_group,
1363
+ last_published_offset,
1364
+ last_delivered_offset,
1365
+ last_acked_offset,
1366
+ pending_count,
1367
+ backlog_undelivered,
1368
+ backlog_unprocessed,
1369
+ created_at
1370
+ FROM stream_backlog_monitor
1371
+ WHERE namespace = :namespace
1372
+ AND stream_name = :stream_name
1373
+ AND created_at >= :start_time
1374
+ AND created_at <= :end_time
1375
+ ORDER BY created_at DESC
1376
+ LIMIT 1000
1377
+ """)
1378
+ params = {
1379
+ 'namespace': namespace,
1380
+ 'stream_name': stream_name,
1381
+ 'start_time': start_time,
1382
+ 'end_time': end_time
1383
+ }
1384
+ else:
1385
+ # 获取最新的所有stream数据
1386
+ query = text("""
1387
+ SELECT DISTINCT ON (stream_name, consumer_group)
1388
+ stream_name,
1389
+ consumer_group,
1390
+ last_published_offset,
1391
+ last_delivered_offset,
1392
+ last_acked_offset,
1393
+ pending_count,
1394
+ backlog_undelivered,
1395
+ backlog_unprocessed,
1396
+ created_at
1397
+ FROM stream_backlog_monitor
1398
+ WHERE namespace = :namespace
1399
+ AND created_at >= :start_time
1400
+ ORDER BY stream_name, consumer_group, created_at DESC
1401
+ """)
1402
+ params = {
1403
+ 'namespace': namespace,
1404
+ 'start_time': start_time
1405
+ }
1406
+
1407
+ result = await session.execute(query, params)
1408
+ rows = result.fetchall()
1409
+
1410
+ # 格式化数据
1411
+ data = []
1412
+ for row in rows:
1413
+ data.append({
1414
+ 'stream_name': row.stream_name,
1415
+ 'consumer_group': row.consumer_group,
1416
+ 'last_published_offset': row.last_published_offset,
1417
+ 'last_delivered_offset': row.last_delivered_offset,
1418
+ 'last_acked_offset': row.last_acked_offset,
1419
+ 'pending_count': row.pending_count,
1420
+ 'backlog_undelivered': row.backlog_undelivered,
1421
+ 'backlog_unprocessed': row.backlog_unprocessed,
1422
+ 'created_at': row.created_at.isoformat() if row.created_at else None
1423
+ })
1424
+
1425
+ return {
1426
+ 'success': True,
1427
+ 'data': data,
1428
+ 'total': len(data)
1429
+ }
1430
+
1431
+ except Exception as e:
1432
+ logger.error(f"获取Stream积压监控数据失败: {e}")
1433
+ traceback.print_exc()
1434
+ raise HTTPException(status_code=500, detail=str(e))
1435
+
1436
+
1437
+ @app.get("/api/stream-backlog/{namespace}/summary")
1438
+ async def get_stream_backlog_summary(namespace: str):
1439
+ """
1440
+ 获取Stream积压监控汇总数据
1441
+
1442
+ Args:
1443
+ namespace: 命名空间
1444
+ """
1445
+ try:
1446
+ async with data_access.AsyncSessionLocal() as session:
1447
+ # 获取最新的汇总数据
1448
+ query = text("""
1449
+ WITH latest_data AS (
1450
+ SELECT DISTINCT ON (stream_name, consumer_group)
1451
+ stream_name,
1452
+ consumer_group,
1453
+ backlog_undelivered,
1454
+ backlog_unprocessed,
1455
+ pending_count
1456
+ FROM stream_backlog_monitor
1457
+ WHERE namespace = :namespace
1458
+ AND created_at >= NOW() - INTERVAL '1 hour'
1459
+ ORDER BY stream_name, consumer_group, created_at DESC
1460
+ )
1461
+ SELECT
1462
+ COUNT(DISTINCT stream_name) as total_streams,
1463
+ COUNT(DISTINCT consumer_group) as total_groups,
1464
+ SUM(backlog_unprocessed) as total_backlog,
1465
+ SUM(pending_count) as total_pending,
1466
+ MAX(backlog_unprocessed) as max_backlog
1467
+ FROM latest_data
1468
+ """)
1469
+
1470
+ result = await session.execute(query, {'namespace': namespace})
1471
+ row = result.fetchone()
1472
+
1473
+ if row:
1474
+ return {
1475
+ 'success': True,
1476
+ 'data': {
1477
+ 'total_streams': row.total_streams or 0,
1478
+ 'total_groups': row.total_groups or 0,
1479
+ 'total_backlog': row.total_backlog or 0,
1480
+ 'total_pending': row.total_pending or 0,
1481
+ 'max_backlog': row.max_backlog or 0
1482
+ }
1483
+ }
1484
+ else:
1485
+ return {
1486
+ 'success': True,
1487
+ 'data': {
1488
+ 'total_streams': 0,
1489
+ 'total_groups': 0,
1490
+ 'total_backlog': 0,
1491
+ 'total_pending': 0,
1492
+ 'max_backlog': 0
1493
+ }
1494
+ }
1495
+
1496
+ except Exception as e:
1497
+ logger.error(f"获取Stream积压监控汇总失败: {e}")
1498
+ traceback.print_exc()
1499
+ raise HTTPException(status_code=500, detail=str(e))
1500
+
1501
+
213
1502
  def run_server():
214
1503
  """运行 Web UI 服务器"""
215
1504
  import uvicorn
216
1505
  uvicorn.run(
217
- "jettask.webui.backend.main:app",
1506
+ app,
218
1507
  host="0.0.0.0",
219
1508
  port=8001,
220
1509
  log_level="info",