flowerui 0.1.0__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.
- flowerui/__init__.py +3 -0
- flowerui/__main__.py +5 -0
- flowerui/api/__init__.py +0 -0
- flowerui/api/routers/__init__.py +0 -0
- flowerui/api/routers/auth.py +48 -0
- flowerui/api/routers/broker.py +33 -0
- flowerui/api/routers/config.py +98 -0
- flowerui/api/routers/control.py +96 -0
- flowerui/api/routers/dashboard.py +106 -0
- flowerui/api/routers/monitor.py +59 -0
- flowerui/api/routers/system.py +55 -0
- flowerui/api/routers/tasks.py +257 -0
- flowerui/api/routers/themes.py +74 -0
- flowerui/api/routers/workers.py +91 -0
- flowerui/api/routers/ws.py +130 -0
- flowerui/cli.py +479 -0
- flowerui/config_bootstrap.py +285 -0
- flowerui/core/__init__.py +0 -0
- flowerui/core/celery_app.py +193 -0
- flowerui/core/config.py +272 -0
- flowerui/core/security.py +119 -0
- flowerui/main.py +216 -0
- flowerui/paths.py +142 -0
- flowerui/relaunch.py +162 -0
- flowerui/services/__init__.py +0 -0
- flowerui/services/broker.py +248 -0
- flowerui/services/deploy_env.py +117 -0
- flowerui/services/events.py +161 -0
- flowerui/services/hub.py +99 -0
- flowerui/services/inspector.py +111 -0
- flowerui/services/manager.py +833 -0
- flowerui/services/metrics.py +60 -0
- flowerui/services/mock.py +582 -0
- flowerui/services/restart.py +349 -0
- flowerui/services/runtime_config.py +916 -0
- flowerui/services/search.py +112 -0
- flowerui/services/state.py +170 -0
- flowerui/services/task_filter.py +150 -0
- flowerui/services/theme_manager.py +350 -0
- flowerui/static/assets/ApplyTaskView-B9qObJT7.js +1 -0
- flowerui/static/assets/ApplyTaskView-D2hDoD-R.css +1 -0
- flowerui/static/assets/BrokerView-DIYIpZmB.js +1 -0
- flowerui/static/assets/BrokerView-ZIKA8iL4.css +1 -0
- flowerui/static/assets/ConfigView-B7YWjAcg.js +4 -0
- flowerui/static/assets/ConfigView-D8CMQNiO.css +1 -0
- flowerui/static/assets/DashboardView-Ca9ROpWV.css +1 -0
- flowerui/static/assets/DashboardView-Dh-iAbii.js +1 -0
- flowerui/static/assets/LoginView-svVGjwbS.js +1 -0
- flowerui/static/assets/LoginView-u7BR9C_H.css +1 -0
- flowerui/static/assets/MonitorView-BcSebSnT.js +1 -0
- flowerui/static/assets/SystemView-C1w8703g.css +1 -0
- flowerui/static/assets/SystemView-Dk3WUk0h.js +1 -0
- flowerui/static/assets/TasksView-CYA-7fKx.css +1 -0
- flowerui/static/assets/TasksView-DajUThXW.js +1 -0
- flowerui/static/assets/WorkersView--kO8bM1W.css +1 -0
- flowerui/static/assets/WorkersView-Dh54AopE.js +1 -0
- flowerui/static/assets/charts-Bb6yjXMn.js +60 -0
- flowerui/static/assets/el-form-item-D_relxjU.css +1 -0
- flowerui/static/assets/el-radio-button-mUGqmzuy.css +1 -0
- flowerui/static/assets/element-BD9JNeug.js +63 -0
- flowerui/static/assets/index-CoWKp2nS.js +2 -0
- flowerui/static/assets/index-DH_7c2-3.css +1 -0
- flowerui/static/assets/useChart-2eo5HY7G.js +1 -0
- flowerui/static/assets/vendor-CWAvDwR0.js +37 -0
- flowerui/static/index.html +16 -0
- flowerui/themes/_template.css +33 -0
- flowerui/themes/amber-night.css +58 -0
- flowerui/themes/aurora.css +91 -0
- flowerui/themes/base.css +81 -0
- flowerui/themes/charcoal-minimal.css +52 -0
- flowerui/themes/cream-peach.css +41 -0
- flowerui/themes/forest-fog.css +41 -0
- flowerui/themes/gray-morandi.css +26 -0
- flowerui/themes/lavender-light.css +22 -0
- flowerui/themes/mint-breeze.css +23 -0
- flowerui/themes/oat-milk.css +22 -0
- flowerui/themes/salt-ocean.css +22 -0
- flowerui/themes/silver-fog.css +41 -0
- flowerui/themes/tech-liquid.css +101 -0
- flowerui/themes/violet-space.css +63 -0
- flowerui-0.1.0.dist-info/LICENSE +21 -0
- flowerui-0.1.0.dist-info/METADATA +403 -0
- flowerui-0.1.0.dist-info/RECORD +86 -0
- flowerui-0.1.0.dist-info/WHEEL +5 -0
- flowerui-0.1.0.dist-info/entry_points.txt +5 -0
- flowerui-0.1.0.dist-info/top_level.txt +1 -0
flowerui/__init__.py
ADDED
flowerui/__main__.py
ADDED
flowerui/api/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""认证接口。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException, status
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ...core.config import settings
|
|
8
|
+
from ...core.security import create_access_token, verify_basic_credentials
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoginRequest(BaseModel):
|
|
14
|
+
username: str
|
|
15
|
+
password: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LoginResponse(BaseModel):
|
|
19
|
+
access_token: str
|
|
20
|
+
token_type: str = "bearer"
|
|
21
|
+
username: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/config")
|
|
25
|
+
async def auth_config():
|
|
26
|
+
"""前端据此决定是否展示登录页。"""
|
|
27
|
+
return {
|
|
28
|
+
"auth_enabled": settings.auth_enabled,
|
|
29
|
+
"basic_auth": bool(settings.basic_auth),
|
|
30
|
+
"oauth2": bool(settings.oauth2_key and settings.auth),
|
|
31
|
+
"oauth2_redirect_uri": settings.oauth2_redirect_uri,
|
|
32
|
+
"auth_provider": settings.auth_provider,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/login", response_model=LoginResponse)
|
|
37
|
+
async def login(payload: LoginRequest):
|
|
38
|
+
if not settings.auth_enabled:
|
|
39
|
+
return LoginResponse(
|
|
40
|
+
access_token=create_access_token("anonymous"), username="anonymous"
|
|
41
|
+
)
|
|
42
|
+
if not verify_basic_credentials(payload.username, payload.password):
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
|
|
45
|
+
)
|
|
46
|
+
return LoginResponse(
|
|
47
|
+
access_token=create_access_token(payload.username), username=payload.username
|
|
48
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Broker 队列视图(对应 flower 的 /broker 页面)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
|
|
8
|
+
from ...core.celery_app import broker_uri, transport_driver
|
|
9
|
+
from ...core.security import get_current_user
|
|
10
|
+
from ...services.manager import ControlError, manager
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api", tags=["broker"], dependencies=[Depends(get_current_user)])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/broker")
|
|
16
|
+
async def broker_overview() -> Dict[str, Any]:
|
|
17
|
+
try:
|
|
18
|
+
queues = await manager.broker_queues()
|
|
19
|
+
except ControlError as exc:
|
|
20
|
+
raise HTTPException(exc.status_code, exc.message) from exc
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
"broker_url": broker_uri(),
|
|
24
|
+
"transport": transport_driver(),
|
|
25
|
+
"mock_mode": manager.mock_mode,
|
|
26
|
+
"queues": queues,
|
|
27
|
+
"totals": {
|
|
28
|
+
"queues": len(queues),
|
|
29
|
+
"messages": sum(q.get("messages") or 0 for q in queues),
|
|
30
|
+
"consumers": sum(q.get("consumers") or 0 for q in queues),
|
|
31
|
+
"unacked": sum(q.get("messages_unacknowledged") or 0 for q in queues),
|
|
32
|
+
},
|
|
33
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""配置查看与修改接口。
|
|
2
|
+
|
|
3
|
+
- ``GET /api/config`` 分组返回全部配置项(敏感值脱敏)
|
|
4
|
+
- ``PUT /api/config`` 批量修改,落盘后立即生效
|
|
5
|
+
- ``POST /api/config/reload`` 不重启进程,重新应用最新配置来源并分级报告
|
|
6
|
+
- ``POST /api/config/reset`` 恢复到进程启动时的取值
|
|
7
|
+
- ``GET /api/config/ui`` 免鉴权,供前端启动时读取界面默认值
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from ...core.security import get_current_user
|
|
18
|
+
from ...services import runtime_config
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("flowerui.config_api")
|
|
21
|
+
router = APIRouter(prefix="/api/config", tags=["config"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UpdateRequest(BaseModel):
|
|
25
|
+
changes: Dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResetRequest(BaseModel):
|
|
29
|
+
keys: Optional[List[str]] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/ui")
|
|
33
|
+
async def get_ui_defaults():
|
|
34
|
+
"""界面默认值。刻意不鉴权:登录页本身也需要按服务端默认主题渲染。"""
|
|
35
|
+
return runtime_config.ui_defaults()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("")
|
|
39
|
+
async def get_config(_user: str = Depends(get_current_user)):
|
|
40
|
+
return runtime_config.describe()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.put("")
|
|
44
|
+
async def update_config(payload: UpdateRequest, _user: str = Depends(get_current_user)):
|
|
45
|
+
try:
|
|
46
|
+
applied = runtime_config.update(payload.changes)
|
|
47
|
+
except runtime_config.ConfigError as exc:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
49
|
+
return {
|
|
50
|
+
"ok": True,
|
|
51
|
+
"applied": applied,
|
|
52
|
+
"message": f"已更新 {len(applied)} 项配置",
|
|
53
|
+
"config": runtime_config.describe(),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/reload")
|
|
58
|
+
async def reload_config(_user: str = Depends(get_current_user)):
|
|
59
|
+
"""不重启进程,重新读取并应用最新配置(app.conf / 环境变量 / CLI / TOML)。
|
|
60
|
+
|
|
61
|
+
适用于手工改了配置文件或注入了新环境变量、又不想重启服务的场景。
|
|
62
|
+
响应按「已热生效 / 需重启 / 仅下次启动」分级列出本次变更,前端据此提示。
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
result = runtime_config.hot_reload()
|
|
66
|
+
except Exception as exc: # noqa: BLE001 - 失败要给出可诊断的回包
|
|
67
|
+
logger.exception("配置热应用失败")
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
70
|
+
detail="配置热应用失败: %s" % exc,
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
parts = []
|
|
74
|
+
if result["applied"]:
|
|
75
|
+
parts.append("已即时生效 %d 项" % len(result["applied"]))
|
|
76
|
+
if result["requires_restart"]:
|
|
77
|
+
parts.append("需重启服务才能生效: %s" % "、".join(result["requires_restart"]))
|
|
78
|
+
if result["next_start_only"]:
|
|
79
|
+
parts.append("仅下次启动生效: %s" % "、".join(result["next_start_only"]))
|
|
80
|
+
result["message"] = ";".join(parts) if parts else "配置无变化,无需重启"
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.post("/reset")
|
|
85
|
+
async def reset_config(payload: ResetRequest, _user: str = Depends(get_current_user)):
|
|
86
|
+
restored = runtime_config.reset(payload.keys)
|
|
87
|
+
return {
|
|
88
|
+
"ok": True,
|
|
89
|
+
"restored": restored,
|
|
90
|
+
"message": f"已恢复 {len(restored)} 项配置的启动值" if restored else "当前没有被修改过的配置项",
|
|
91
|
+
"config": runtime_config.describe(),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/materialize")
|
|
96
|
+
async def materialize_config(_user: str = Depends(get_current_user)):
|
|
97
|
+
"""生成一份完整带中文注释的默认配置模板(保留手工键)。"""
|
|
98
|
+
return runtime_config.materialize()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""远程控制接口:worker 生命周期、进程池、队列消费、任务限流与撤销。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
7
|
+
|
|
8
|
+
from ...core.security import get_current_user
|
|
9
|
+
from ...services.manager import ControlError, manager
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api", tags=["control"], dependencies=[Depends(get_current_user)])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _wrap(fn):
|
|
15
|
+
async def runner(*args, **kwargs):
|
|
16
|
+
try:
|
|
17
|
+
return {"message": await fn(*args, **kwargs)}
|
|
18
|
+
except ControlError as exc:
|
|
19
|
+
raise HTTPException(exc.status_code, exc.message) from exc
|
|
20
|
+
|
|
21
|
+
return runner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ----------------------------------------------------------------------
|
|
25
|
+
# Worker 控制
|
|
26
|
+
# ----------------------------------------------------------------------
|
|
27
|
+
@router.post("/worker/shutdown/{workername:path}")
|
|
28
|
+
async def worker_shutdown(workername: str):
|
|
29
|
+
return await _wrap(manager.worker_shutdown)(workername)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/worker/pool/restart/{workername:path}")
|
|
33
|
+
async def worker_pool_restart(workername: str):
|
|
34
|
+
return await _wrap(manager.worker_pool_restart)(workername)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/worker/pool/grow/{workername:path}")
|
|
38
|
+
async def worker_pool_grow(workername: str, n: int = Query(1, ge=1)):
|
|
39
|
+
return await _wrap(manager.worker_pool_grow)(workername, n)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/worker/pool/shrink/{workername:path}")
|
|
43
|
+
async def worker_pool_shrink(workername: str, n: int = Query(1, ge=1)):
|
|
44
|
+
return await _wrap(manager.worker_pool_shrink)(workername, n)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/worker/pool/autoscale/{workername:path}")
|
|
48
|
+
async def worker_pool_autoscale(
|
|
49
|
+
workername: str,
|
|
50
|
+
min: int = Query(..., ge=1, alias="min"),
|
|
51
|
+
max: int = Query(..., ge=1, alias="max"),
|
|
52
|
+
):
|
|
53
|
+
return await _wrap(manager.worker_pool_autoscale)(workername, min, max)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/worker/queue/add-consumer/{workername:path}")
|
|
57
|
+
async def worker_queue_add_consumer(workername: str, queue: str = Query(...)):
|
|
58
|
+
return await _wrap(manager.worker_add_consumer)(workername, queue)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/worker/queue/cancel-consumer/{workername:path}")
|
|
62
|
+
async def worker_queue_cancel_consumer(workername: str, queue: str = Query(...)):
|
|
63
|
+
return await _wrap(manager.worker_cancel_consumer)(workername, queue)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ----------------------------------------------------------------------
|
|
67
|
+
# 任务控制
|
|
68
|
+
# ----------------------------------------------------------------------
|
|
69
|
+
@router.post("/task/revoke/{taskid}")
|
|
70
|
+
async def task_revoke(
|
|
71
|
+
taskid: str,
|
|
72
|
+
terminate: bool = Query(False),
|
|
73
|
+
signal: str = Query("SIGTERM"),
|
|
74
|
+
):
|
|
75
|
+
return await _wrap(manager.task_revoke)(taskid, terminate, signal)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.post("/task/rate-limit/{taskname:path}")
|
|
79
|
+
async def task_rate_limit(
|
|
80
|
+
taskname: str,
|
|
81
|
+
workername: str = Query(...),
|
|
82
|
+
ratelimit: str = Query(...),
|
|
83
|
+
):
|
|
84
|
+
return await _wrap(manager.task_rate_limit)(taskname, workername, ratelimit)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.post("/task/timeout/{taskname:path}")
|
|
88
|
+
async def task_timeout(
|
|
89
|
+
taskname: str,
|
|
90
|
+
workername: str = Query(...),
|
|
91
|
+
soft: Optional[float] = Query(None),
|
|
92
|
+
hard: Optional[float] = Query(None),
|
|
93
|
+
):
|
|
94
|
+
if soft is None and hard is None:
|
|
95
|
+
raise HTTPException(400, "soft 与 hard 至少提供一个")
|
|
96
|
+
return await _wrap(manager.task_time_limit)(taskname, workername, soft, hard)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Dashboard 聚合接口:概览卡片、趋势时间线、筛选项、任务分布。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import collections
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, Query
|
|
9
|
+
|
|
10
|
+
from ...core.config import settings
|
|
11
|
+
from ...core.security import get_current_user
|
|
12
|
+
from ...services.hub import hub
|
|
13
|
+
from ...services.manager import manager
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api", tags=["dashboard"], dependencies=[Depends(get_current_user)])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/dashboard/summary")
|
|
19
|
+
async def summary() -> Dict[str, Any]:
|
|
20
|
+
return manager.dashboard_summary()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/dashboard/snapshot")
|
|
24
|
+
async def snapshot(
|
|
25
|
+
since: int = Query(0, ge=0, description="事件游标,传上一次返回的 cursor 做增量拉取"),
|
|
26
|
+
timeline_limit: int = Query(120, ge=1, le=720),
|
|
27
|
+
events: bool = Query(True, description="是否附带事件流"),
|
|
28
|
+
event_limit: int = Query(120, ge=1, le=500),
|
|
29
|
+
) -> Dict[str, Any]:
|
|
30
|
+
"""一次请求拿齐 Dashboard 所需的全部实时数据。
|
|
31
|
+
|
|
32
|
+
这是「HTTP 轮询」模式的主力接口:语义与 WebSocket 首帧 snapshot 对齐,
|
|
33
|
+
额外带上事件游标,让轮询也能做到事件不重不漏。
|
|
34
|
+
"""
|
|
35
|
+
payload: Dict[str, Any] = {
|
|
36
|
+
"summary": manager.dashboard_summary(),
|
|
37
|
+
"workers": manager.worker_rows(),
|
|
38
|
+
"timeline": manager.state.get_timeline(timeline_limit),
|
|
39
|
+
"server_time": time.time(),
|
|
40
|
+
}
|
|
41
|
+
if events:
|
|
42
|
+
payload.update(hub.recent(since=since, limit=event_limit))
|
|
43
|
+
else:
|
|
44
|
+
payload.update({"cursor": hub.cursor, "events": []})
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/dashboard/timeline")
|
|
49
|
+
async def timeline(limit: int = Query(120, ge=1, le=720)) -> Dict[str, Any]:
|
|
50
|
+
return {"points": manager.state.get_timeline(limit)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get("/dashboard/filters")
|
|
54
|
+
async def filters() -> Dict[str, Any]:
|
|
55
|
+
"""前端下拉框选项:worker / 任务类型 / 状态 / 队列。"""
|
|
56
|
+
states = sorted(manager.state.totals().keys())
|
|
57
|
+
return {
|
|
58
|
+
"workers": sorted(manager.visible_worker_names()),
|
|
59
|
+
"task_types": manager.state.task_types(),
|
|
60
|
+
"states": states or [
|
|
61
|
+
"PENDING", "RECEIVED", "STARTED", "SUCCESS",
|
|
62
|
+
"FAILURE", "RETRY", "REVOKED",
|
|
63
|
+
],
|
|
64
|
+
"queues": manager.active_queue_names(),
|
|
65
|
+
"columns": settings.task_columns_list,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/dashboard/task-distribution")
|
|
70
|
+
async def task_distribution(top: int = Query(10, ge=1, le=50)) -> Dict[str, Any]:
|
|
71
|
+
"""按任务名统计成功 / 失败 / 平均耗时,用于饼图与柱状图。"""
|
|
72
|
+
agg: Dict[str, Dict[str, Any]] = collections.defaultdict(
|
|
73
|
+
lambda: {"total": 0, "succeeded": 0, "failed": 0, "retried": 0,
|
|
74
|
+
"revoked": 0, "runtime_sum": 0.0, "runtime_n": 0}
|
|
75
|
+
)
|
|
76
|
+
for task in list(manager.state.tasks.values()):
|
|
77
|
+
name = task.name or "unknown"
|
|
78
|
+
item = agg[name]
|
|
79
|
+
item["total"] += 1
|
|
80
|
+
if task.state == "SUCCESS":
|
|
81
|
+
item["succeeded"] += 1
|
|
82
|
+
elif task.state == "FAILURE":
|
|
83
|
+
item["failed"] += 1
|
|
84
|
+
elif task.state == "RETRY":
|
|
85
|
+
item["retried"] += 1
|
|
86
|
+
elif task.state == "REVOKED":
|
|
87
|
+
item["revoked"] += 1
|
|
88
|
+
runtime = getattr(task, "runtime", None)
|
|
89
|
+
if runtime:
|
|
90
|
+
item["runtime_sum"] += runtime
|
|
91
|
+
item["runtime_n"] += 1
|
|
92
|
+
|
|
93
|
+
rows: List[Dict[str, Any]] = []
|
|
94
|
+
for name, item in agg.items():
|
|
95
|
+
rows.append({
|
|
96
|
+
"name": name,
|
|
97
|
+
"total": item["total"],
|
|
98
|
+
"succeeded": item["succeeded"],
|
|
99
|
+
"failed": item["failed"],
|
|
100
|
+
"retried": item["retried"],
|
|
101
|
+
"revoked": item["revoked"],
|
|
102
|
+
"avg_runtime": round(item["runtime_sum"] / item["runtime_n"], 4)
|
|
103
|
+
if item["runtime_n"] else None,
|
|
104
|
+
})
|
|
105
|
+
rows.sort(key=lambda r: r["total"], reverse=True)
|
|
106
|
+
return {"data": rows[:top]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""运维接口:Prometheus 指标与健康检查(对齐 flower /metrics、/healthcheck)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Response
|
|
8
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
9
|
+
|
|
10
|
+
from ...core.celery_app import broker_uri, mask_url_password, transport_driver
|
|
11
|
+
from ...services.manager import manager
|
|
12
|
+
|
|
13
|
+
# 这两个端点不做鉴权,方便被 Prometheus / K8s 探针直接抓取
|
|
14
|
+
router = APIRouter(tags=["monitor"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/metrics")
|
|
18
|
+
async def metrics() -> Response:
|
|
19
|
+
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/healthcheck")
|
|
23
|
+
async def healthcheck() -> Dict[str, str]:
|
|
24
|
+
return {"status": "OK"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/api/system")
|
|
28
|
+
async def system_info() -> Dict[str, Any]:
|
|
29
|
+
"""系统信息面板:运行模式、broker、容量配置。"""
|
|
30
|
+
import platform
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
import celery
|
|
34
|
+
|
|
35
|
+
from ...core.config import settings
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"mock_mode": manager.mock_mode,
|
|
39
|
+
"broker_url": broker_uri(),
|
|
40
|
+
"transport": transport_driver(),
|
|
41
|
+
# 未配置 result backend 时 /api/task/apply 与 /api/task/result 无法取回结果,
|
|
42
|
+
# 这里显式暴露,避免用户对着 503 猜原因。
|
|
43
|
+
# 与 broker_url 一样必须脱敏:结果后端连接串常带密码,直接回显会泄露。
|
|
44
|
+
"result_backend": (
|
|
45
|
+
mask_url_password(settings.result_backend)
|
|
46
|
+
if settings.result_backend else None
|
|
47
|
+
),
|
|
48
|
+
"result_backend_configured": manager.backend_configured(),
|
|
49
|
+
"events_connected": bool(manager.events and manager.events.connected),
|
|
50
|
+
"uptime": int(time.time() - manager.started_at),
|
|
51
|
+
"python_version": sys.version.split()[0],
|
|
52
|
+
"celery_version": celery.__version__,
|
|
53
|
+
"platform": platform.platform(),
|
|
54
|
+
# 只统计当前 broker_url 中的 worker;事件广播会混入其它 broker/db 的
|
|
55
|
+
# 心跳,foreign_workers_hidden 记录被隔离的数量,便于排障。
|
|
56
|
+
"workers_in_memory": manager.configured_worker_count(),
|
|
57
|
+
"foreign_workers_hidden": manager.foreign_worker_count(),
|
|
58
|
+
"tasks_in_memory": len(manager.state.tasks),
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""系统级操作:重启后端进程等。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ...core.security import get_current_user
|
|
8
|
+
from ...services import restart as restart_service
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["system"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RestartResponse(BaseModel):
|
|
14
|
+
ok: bool = True
|
|
15
|
+
message: str = "后端正在重启,请稍候刷新页面"
|
|
16
|
+
mode: str = restart_service.MODE_RELAUNCH
|
|
17
|
+
container: bool = False
|
|
18
|
+
detail: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/api/system/restart", response_model=RestartResponse)
|
|
22
|
+
async def restart_system(_user: str = Depends(get_current_user)) -> RestartResponse:
|
|
23
|
+
"""重启后端 API 进程,使需要重启才生效的配置(监听端口、CORS、持久化、
|
|
24
|
+
认证、演示模式、事件采集等)真正落地。
|
|
25
|
+
|
|
26
|
+
策略由 ``restart_mode`` 决定,并在响应的 ``mode`` 字段回传:
|
|
27
|
+
|
|
28
|
+
- ``python-relaunch``(非容器默认):用 ``sys.executable -m flowerui.relaunch``
|
|
29
|
+
拉起继任者进程(不经过任何 shell / shell 脚本),当前进程优雅退出,
|
|
30
|
+
继任者重新级联解析配置后接管服务;
|
|
31
|
+
- ``exit``(容器 / 编排器托管时默认):**不创建子进程**,因为容器主进程
|
|
32
|
+
退出即容器终止,子进程会被一并杀掉。只让当前进程优雅退出,交由外部
|
|
33
|
+
重启策略(Docker / k8s / systemd / supervisord)重新拉起;
|
|
34
|
+
- ``disabled``:直接返回 409,不执行任何重启动作。
|
|
35
|
+
|
|
36
|
+
Celery worker 属于独立进程,其相关配置需另行重启 worker。
|
|
37
|
+
"""
|
|
38
|
+
plan = restart_service.plan_restart()
|
|
39
|
+
if plan["mode"] == restart_service.MODE_DISABLED:
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=409,
|
|
42
|
+
detail="界面重启已禁用(restart_mode=disabled):请通过容器编排器或进程管理器重启服务。",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
restart_service.restart_later(plan=plan)
|
|
46
|
+
if plan["mode"] == restart_service.MODE_EXIT:
|
|
47
|
+
message = "后端进程即将优雅退出,将由容器 / 进程管理器重新拉起,请稍候刷新页面"
|
|
48
|
+
else:
|
|
49
|
+
message = "后端正在重启,请稍候刷新页面"
|
|
50
|
+
return RestartResponse(
|
|
51
|
+
message=message,
|
|
52
|
+
mode=str(plan["mode"]),
|
|
53
|
+
container=bool(plan["container"]),
|
|
54
|
+
detail=str(plan["reason"]),
|
|
55
|
+
)
|