nonebot-plugin-ruok 0.2.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.
- nonebot_plugin_ruok/__init__.py +718 -0
- nonebot_plugin_ruok/api.py +348 -0
- nonebot_plugin_ruok/chat_render.py +154 -0
- nonebot_plugin_ruok/collector.py +197 -0
- nonebot_plugin_ruok/collectors/__init__.py +107 -0
- nonebot_plugin_ruok/collectors/metrics.py +659 -0
- nonebot_plugin_ruok/collectors/modules.py +252 -0
- nonebot_plugin_ruok/collectors/monitor.py +231 -0
- nonebot_plugin_ruok/collectors/notifications.py +325 -0
- nonebot_plugin_ruok/collectors/sessions.py +569 -0
- nonebot_plugin_ruok/collectors/trackers.py +119 -0
- nonebot_plugin_ruok/config.py +50 -0
- nonebot_plugin_ruok/protocol.py +248 -0
- nonebot_plugin_ruok/webui/auth.py +1002 -0
- nonebot_plugin_ruok/webui/jinja.py +87 -0
- nonebot_plugin_ruok/webui/router.py +1571 -0
- nonebot_plugin_ruok/webui/sse.py +238 -0
- nonebot_plugin_ruok/webui/static/vendor/chartjs/LICENSE.md +9 -0
- nonebot_plugin_ruok/webui/static/vendor/chartjs/chart.umd.min.js +14 -0
- nonebot_plugin_ruok/webui/static/vendor/htmx/LICENSE +13 -0
- nonebot_plugin_ruok/webui/static/vendor/htmx/htmx.min.js +1 -0
- nonebot_plugin_ruok/webui/static/vendor/idiomorph/LICENSE +13 -0
- nonebot_plugin_ruok/webui/static/vendor/idiomorph/idiomorph-ext.min.js +1 -0
- nonebot_plugin_ruok/webui/static/vendor/pico/LICENSE.md +21 -0
- nonebot_plugin_ruok/webui/static/vendor/pico/pico.min.css +4 -0
- nonebot_plugin_ruok/webui/templates/_auth_shell.html.jinja2 +268 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_connections.html.jinja2 +13 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_disk.html.jinja2 +26 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_hero.html.jinja2 +19 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_metrics.html.jinja2 +39 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_stats.html.jinja2 +46 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_system.html.jinja2 +232 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_trends.html.jinja2 +195 -0
- nonebot_plugin_ruok/webui/templates/_linked_list.html.jinja2 +18 -0
- nonebot_plugin_ruok/webui/templates/_module_form.html.jinja2 +89 -0
- nonebot_plugin_ruok/webui/templates/_modules_table.html.jinja2 +50 -0
- nonebot_plugin_ruok/webui/templates/_notification_form.html.jinja2 +84 -0
- nonebot_plugin_ruok/webui/templates/_notifications_list.html.jinja2 +65 -0
- nonebot_plugin_ruok/webui/templates/_session_card.html.jinja2 +94 -0
- nonebot_plugin_ruok/webui/templates/_session_container.html.jinja2 +18 -0
- nonebot_plugin_ruok/webui/templates/_sessions_list.html.jinja2 +102 -0
- nonebot_plugin_ruok/webui/templates/_user_sessions.html.jinja2 +36 -0
- nonebot_plugin_ruok/webui/templates/_users_panel.html.jinja2 +271 -0
- nonebot_plugin_ruok/webui/templates/base.html.jinja2 +980 -0
- nonebot_plugin_ruok/webui/templates/chat_session_list.html.jinja2 +117 -0
- nonebot_plugin_ruok/webui/templates/chat_status.html.jinja2 +104 -0
- nonebot_plugin_ruok/webui/templates/dashboard.html.jinja2 +362 -0
- nonebot_plugin_ruok/webui/templates/dashboard_user.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/login.html.jinja2 +54 -0
- nonebot_plugin_ruok/webui/templates/modules.html.jinja2 +16 -0
- nonebot_plugin_ruok/webui/templates/modules_detail.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/notifications.html.jinja2 +16 -0
- nonebot_plugin_ruok/webui/templates/notifications_detail.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/register.html.jinja2 +61 -0
- nonebot_plugin_ruok/webui/templates/reset_password.html.jinja2 +55 -0
- nonebot_plugin_ruok/webui/templates/sessions.html.jinja2 +170 -0
- nonebot_plugin_ruok/webui/templates/sessions_detail.html.jinja2 +234 -0
- nonebot_plugin_ruok/webui/templates/users.html.jinja2 +11 -0
- nonebot_plugin_ruok-0.2.0.dist-info/METADATA +614 -0
- nonebot_plugin_ruok-0.2.0.dist-info/RECORD +61 -0
- nonebot_plugin_ruok-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""FastAPI routes for RUOK — health, session, module management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import asyncio
|
|
7
|
+
import secrets
|
|
8
|
+
from typing import Any
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import Query, Header, Depends, Request, APIRouter, HTTPException
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
from .config import ScopedConfig
|
|
15
|
+
from .protocol import ReporterInfo, ModuleDefinition
|
|
16
|
+
from .collector import (
|
|
17
|
+
MetricsStore,
|
|
18
|
+
SessionPluginValidationError,
|
|
19
|
+
SessionUpdateValidationError,
|
|
20
|
+
get_module,
|
|
21
|
+
get_session,
|
|
22
|
+
list_modules,
|
|
23
|
+
delete_module,
|
|
24
|
+
link_sessions,
|
|
25
|
+
list_sessions,
|
|
26
|
+
upsert_module,
|
|
27
|
+
create_session,
|
|
28
|
+
unlink_session,
|
|
29
|
+
update_session,
|
|
30
|
+
get_session_stats,
|
|
31
|
+
_handle_ruok_error,
|
|
32
|
+
get_linked_sessions,
|
|
33
|
+
collect_all_statuses,
|
|
34
|
+
dispatch_notification,
|
|
35
|
+
confirm_session_plugins,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ────────────────────────────────
|
|
39
|
+
# Router factory
|
|
40
|
+
# ────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_ruok_router(config: ScopedConfig, data_dir: Path) -> APIRouter:
|
|
44
|
+
"""Build the FastAPI router for all RUOK endpoints."""
|
|
45
|
+
router = APIRouter(prefix="/ruok/api", tags=["RUOK"])
|
|
46
|
+
|
|
47
|
+
# Simple cache
|
|
48
|
+
_cache: dict[str, Any] = {}
|
|
49
|
+
_cache_time: dict[str, float] = {}
|
|
50
|
+
|
|
51
|
+
def _cached(key: str, factory: Any) -> Any:
|
|
52
|
+
now = time.time()
|
|
53
|
+
if key in _cache and now - _cache_time.get(key, 0) < config.cache_ttl:
|
|
54
|
+
return _cache[key]
|
|
55
|
+
result = factory()
|
|
56
|
+
_cache[key] = result
|
|
57
|
+
_cache_time[key] = now
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def _require_api_key(
|
|
61
|
+
x_ruok_api_key: str | None = Header(None),
|
|
62
|
+
authorization: str | None = Header(None),
|
|
63
|
+
) -> None:
|
|
64
|
+
if not config.api_key:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
token = x_ruok_api_key or ""
|
|
68
|
+
if authorization:
|
|
69
|
+
scheme, _, value = authorization.partition(" ")
|
|
70
|
+
if scheme.lower() == "bearer":
|
|
71
|
+
token = value
|
|
72
|
+
|
|
73
|
+
if not secrets.compare_digest(token, config.api_key):
|
|
74
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
75
|
+
|
|
76
|
+
# ── Health ────────────────────
|
|
77
|
+
|
|
78
|
+
@router.get("/status")
|
|
79
|
+
async def api_status(_: None = Depends(_require_api_key)) -> dict[str, Any]:
|
|
80
|
+
"""Aggregated bot health status."""
|
|
81
|
+
data = _cached("status", lambda: None)
|
|
82
|
+
if data is None:
|
|
83
|
+
data = await collect_all_statuses(config, data_dir)
|
|
84
|
+
_cache["status"] = data
|
|
85
|
+
_cache_time["status"] = time.time()
|
|
86
|
+
return data.model_dump(mode="json")
|
|
87
|
+
|
|
88
|
+
@router.get("/health", response_model=None)
|
|
89
|
+
async def api_health(
|
|
90
|
+
_: None = Depends(_require_api_key),
|
|
91
|
+
) -> dict[str, Any] | JSONResponse:
|
|
92
|
+
"""Simple health check for K8s / Docker / Uptime."""
|
|
93
|
+
data = _cached("status", lambda: None)
|
|
94
|
+
if data is None:
|
|
95
|
+
try:
|
|
96
|
+
data = await collect_all_statuses(config, data_dir)
|
|
97
|
+
except (asyncio.TimeoutError, RuntimeError, OSError):
|
|
98
|
+
return JSONResponse({"status": "unhealthy"}, status_code=503)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
_handle_ruok_error(exc, "api_health", data_dir)
|
|
101
|
+
return JSONResponse({"status": "unhealthy"}, status_code=503)
|
|
102
|
+
if data.overall == "unavailable":
|
|
103
|
+
return JSONResponse(
|
|
104
|
+
{"status": "unhealthy", "overall": data.overall}, status_code=503
|
|
105
|
+
)
|
|
106
|
+
return {"status": "healthy", "overall": data.overall}
|
|
107
|
+
|
|
108
|
+
@router.get("/connections")
|
|
109
|
+
async def api_connections(
|
|
110
|
+
_: None = Depends(_require_api_key),
|
|
111
|
+
) -> list[dict[str, Any]]:
|
|
112
|
+
"""WS connection status only (lightweight)."""
|
|
113
|
+
# connections are always fresh, no caching
|
|
114
|
+
status = await collect_all_statuses(config, data_dir)
|
|
115
|
+
return [c.model_dump(mode="json") for c in status.connections]
|
|
116
|
+
|
|
117
|
+
# ── Sessions ──────────────────
|
|
118
|
+
|
|
119
|
+
@router.get("/sessions")
|
|
120
|
+
async def api_list_sessions(
|
|
121
|
+
_: None = Depends(_require_api_key),
|
|
122
|
+
status: str | None = Query(None),
|
|
123
|
+
module: str | None = Query(None),
|
|
124
|
+
reporter: str | None = Query(None),
|
|
125
|
+
search: str | None = Query(None),
|
|
126
|
+
after: str | None = Query(
|
|
127
|
+
None, description="ISO datetime, e.g. 2026-07-01T00:00:00"
|
|
128
|
+
),
|
|
129
|
+
before: str | None = Query(None, description="ISO datetime"),
|
|
130
|
+
plugin: str | None = Query(
|
|
131
|
+
None, description="Filter by plugin name from ModuleDefinitions"
|
|
132
|
+
),
|
|
133
|
+
) -> list[dict[str, Any]]:
|
|
134
|
+
"""List sessions with optional filters."""
|
|
135
|
+
from datetime import datetime as dt
|
|
136
|
+
|
|
137
|
+
first_seen_after = dt.fromisoformat(after) if after else None
|
|
138
|
+
first_seen_before = dt.fromisoformat(before) if before else None
|
|
139
|
+
sessions = list_sessions(
|
|
140
|
+
data_dir,
|
|
141
|
+
status=status,
|
|
142
|
+
module_name=module,
|
|
143
|
+
reporter_user_id=reporter,
|
|
144
|
+
search=search,
|
|
145
|
+
first_seen_after=first_seen_after,
|
|
146
|
+
first_seen_before=first_seen_before,
|
|
147
|
+
plugin_name=plugin,
|
|
148
|
+
)
|
|
149
|
+
return [s.model_dump(mode="json") for s in sessions]
|
|
150
|
+
|
|
151
|
+
@router.post("/sessions")
|
|
152
|
+
async def api_create_session(
|
|
153
|
+
request: Request,
|
|
154
|
+
_: None = Depends(_require_api_key),
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Create a session via API (for WebUI)."""
|
|
157
|
+
if not config.session_enabled:
|
|
158
|
+
raise HTTPException(status_code=403, detail="Session system disabled")
|
|
159
|
+
|
|
160
|
+
body = await request.json()
|
|
161
|
+
module_name = body.get("module_name", "")
|
|
162
|
+
description = body.get("description", "")
|
|
163
|
+
reporter = ReporterInfo(
|
|
164
|
+
type=body.get("reporter_type", "user"),
|
|
165
|
+
user_id=body.get("user_id"),
|
|
166
|
+
group_id=body.get("group_id"),
|
|
167
|
+
platform=body.get("platform"),
|
|
168
|
+
)
|
|
169
|
+
session = create_session(
|
|
170
|
+
data_dir,
|
|
171
|
+
module_name=module_name,
|
|
172
|
+
description=description,
|
|
173
|
+
reporter=reporter,
|
|
174
|
+
source=body.get("source", "manual"),
|
|
175
|
+
)
|
|
176
|
+
await dispatch_notification(session, config, data_dir)
|
|
177
|
+
_cache.pop("status", None) # invalidate cache
|
|
178
|
+
return session.model_dump(mode="json")
|
|
179
|
+
|
|
180
|
+
@router.get("/sessions/stats")
|
|
181
|
+
async def api_session_stats(_: None = Depends(_require_api_key)) -> dict[str, Any]:
|
|
182
|
+
"""Return aggregate session statistics."""
|
|
183
|
+
stats = get_session_stats(data_dir)
|
|
184
|
+
return stats.model_dump(mode="json")
|
|
185
|
+
|
|
186
|
+
@router.get("/sessions/{session_id}")
|
|
187
|
+
async def api_get_session(
|
|
188
|
+
session_id: str,
|
|
189
|
+
_: None = Depends(_require_api_key),
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Get a single session with all occurrences."""
|
|
192
|
+
session = get_session(data_dir, session_id)
|
|
193
|
+
if session is None:
|
|
194
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
195
|
+
return session.model_dump(mode="json")
|
|
196
|
+
|
|
197
|
+
@router.patch("/sessions/{session_id}")
|
|
198
|
+
async def api_update_session(
|
|
199
|
+
session_id: str,
|
|
200
|
+
request: Request,
|
|
201
|
+
_: None = Depends(_require_api_key),
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
"""Update session status / notes."""
|
|
204
|
+
if not config.session_enabled:
|
|
205
|
+
raise HTTPException(status_code=403, detail="Session system disabled")
|
|
206
|
+
|
|
207
|
+
body = await request.json()
|
|
208
|
+
if body.get("status") == "unsolved" and "affected_plugins" in body:
|
|
209
|
+
try:
|
|
210
|
+
session = confirm_session_plugins(
|
|
211
|
+
data_dir,
|
|
212
|
+
config,
|
|
213
|
+
session_id,
|
|
214
|
+
body.get("affected_plugins", []),
|
|
215
|
+
)
|
|
216
|
+
except SessionPluginValidationError as exc:
|
|
217
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
218
|
+
else:
|
|
219
|
+
if "affected_plugins" in body:
|
|
220
|
+
raise HTTPException(
|
|
221
|
+
status_code=400,
|
|
222
|
+
detail=(
|
|
223
|
+
"affected_plugins can only be set when confirming a session"
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
updates = {
|
|
227
|
+
k: v for k, v in body.items() if k in ("status", "developer_notes")
|
|
228
|
+
}
|
|
229
|
+
try:
|
|
230
|
+
session = update_session(data_dir, session_id, updates)
|
|
231
|
+
except SessionUpdateValidationError as exc:
|
|
232
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
233
|
+
if session is None:
|
|
234
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
235
|
+
_cache.pop("status", None)
|
|
236
|
+
return session.model_dump(mode="json")
|
|
237
|
+
|
|
238
|
+
@router.post("/sessions/{session_id}/link/{other_id}")
|
|
239
|
+
async def api_link_sessions(
|
|
240
|
+
session_id: str,
|
|
241
|
+
other_id: str,
|
|
242
|
+
_: None = Depends(_require_api_key),
|
|
243
|
+
) -> dict[str, Any]:
|
|
244
|
+
"""Link two sessions into the same link_group."""
|
|
245
|
+
if not config.session_enabled:
|
|
246
|
+
raise HTTPException(status_code=403, detail="Session system disabled")
|
|
247
|
+
|
|
248
|
+
ok = link_sessions(data_dir, session_id, other_id)
|
|
249
|
+
if not ok:
|
|
250
|
+
raise HTTPException(
|
|
251
|
+
status_code=404, detail="One or both sessions not found"
|
|
252
|
+
)
|
|
253
|
+
s = get_session(data_dir, session_id)
|
|
254
|
+
return {"linked": True, "link_group": s.link_group if s else None}
|
|
255
|
+
|
|
256
|
+
@router.delete("/sessions/{session_id}/link")
|
|
257
|
+
async def api_unlink_session(
|
|
258
|
+
session_id: str,
|
|
259
|
+
_: None = Depends(_require_api_key),
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
"""Remove a session from its link_group."""
|
|
262
|
+
if not config.session_enabled:
|
|
263
|
+
raise HTTPException(status_code=403, detail="Session system disabled")
|
|
264
|
+
|
|
265
|
+
ok = unlink_session(data_dir, session_id)
|
|
266
|
+
if not ok:
|
|
267
|
+
raise HTTPException(
|
|
268
|
+
status_code=404, detail="Session not found or not linked"
|
|
269
|
+
)
|
|
270
|
+
return {"unlinked": True}
|
|
271
|
+
|
|
272
|
+
@router.get("/sessions/{session_id}/linked")
|
|
273
|
+
async def api_get_linked_sessions(
|
|
274
|
+
session_id: str,
|
|
275
|
+
_: None = Depends(_require_api_key),
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Get all sessions in the same link_group."""
|
|
278
|
+
session = get_session(data_dir, session_id)
|
|
279
|
+
if session is None:
|
|
280
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
281
|
+
linked = get_linked_sessions(data_dir, session_id)
|
|
282
|
+
return {
|
|
283
|
+
"link_group": session.link_group,
|
|
284
|
+
"linked": [s.model_dump(mode="json") for s in linked],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# ── Modules ───────────────────
|
|
288
|
+
|
|
289
|
+
@router.get("/modules")
|
|
290
|
+
async def api_list_modules(
|
|
291
|
+
_: None = Depends(_require_api_key),
|
|
292
|
+
) -> list[dict[str, Any]]:
|
|
293
|
+
"""List modules with real-time status."""
|
|
294
|
+
return [m.model_dump(mode="json") for m in list_modules(data_dir, config)]
|
|
295
|
+
|
|
296
|
+
@router.get("/modules/{name}")
|
|
297
|
+
async def api_get_module(
|
|
298
|
+
name: str,
|
|
299
|
+
_: None = Depends(_require_api_key),
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Get one module."""
|
|
302
|
+
mod = get_module(data_dir, config, name)
|
|
303
|
+
if mod is None:
|
|
304
|
+
raise HTTPException(status_code=404, detail="Module not found")
|
|
305
|
+
return mod.model_dump(mode="json")
|
|
306
|
+
|
|
307
|
+
@router.put("/modules/{name}")
|
|
308
|
+
async def api_upsert_module(
|
|
309
|
+
name: str,
|
|
310
|
+
request: Request,
|
|
311
|
+
_: None = Depends(_require_api_key),
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""Create or update a module definition."""
|
|
314
|
+
body = await request.json()
|
|
315
|
+
definition = ModuleDefinition(
|
|
316
|
+
name=name,
|
|
317
|
+
display_name=body.get("display_name", name),
|
|
318
|
+
plugins=body.get("plugins", []),
|
|
319
|
+
description=body.get("description"),
|
|
320
|
+
)
|
|
321
|
+
upsert_module(data_dir, definition)
|
|
322
|
+
_cache.pop("status", None)
|
|
323
|
+
return definition.model_dump(mode="json")
|
|
324
|
+
|
|
325
|
+
@router.delete("/modules/{name}")
|
|
326
|
+
async def api_delete_module(
|
|
327
|
+
name: str,
|
|
328
|
+
_: None = Depends(_require_api_key),
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""Delete a module definition."""
|
|
331
|
+
ok = delete_module(data_dir, name)
|
|
332
|
+
if not ok:
|
|
333
|
+
raise HTTPException(status_code=404, detail="Module not found")
|
|
334
|
+
_cache.pop("status", None)
|
|
335
|
+
return {"deleted": True}
|
|
336
|
+
|
|
337
|
+
# ── Metrics history ───────────
|
|
338
|
+
|
|
339
|
+
@router.get("/metrics/history")
|
|
340
|
+
async def api_metrics_history(
|
|
341
|
+
_: None = Depends(_require_api_key),
|
|
342
|
+
hours: float = Query(24.0, ge=0.5, le=168.0),
|
|
343
|
+
) -> list[dict[str, Any]]:
|
|
344
|
+
"""Return time-series metrics for the last *hours* hours."""
|
|
345
|
+
points = MetricsStore.query(data_dir, hours=hours)
|
|
346
|
+
return [p.model_dump(mode="json") for p in points]
|
|
347
|
+
|
|
348
|
+
return router
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Image rendering helpers for RUOK chat commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from nonebot import logger
|
|
9
|
+
from nonebot.adapters import Bot, Event
|
|
10
|
+
|
|
11
|
+
from .config import ScopedConfig
|
|
12
|
+
from .protocol import Session, ModuleDefinition
|
|
13
|
+
from .webui.jinja import render_to_string
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChatImageRenderError(RuntimeError):
|
|
17
|
+
"""Raised when a chat command image cannot be rendered or sent."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ChatStatusItem:
|
|
22
|
+
"""Display data for one module in the status image."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
status: str
|
|
26
|
+
label: str
|
|
27
|
+
icon: str
|
|
28
|
+
reasons: list[str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ChatSessionItem:
|
|
33
|
+
"""Display data for one session in the list image."""
|
|
34
|
+
|
|
35
|
+
session_id: str
|
|
36
|
+
module_name: str
|
|
37
|
+
status: str
|
|
38
|
+
label: str
|
|
39
|
+
icon: str
|
|
40
|
+
time_text: str
|
|
41
|
+
description: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def chat_render_enabled(config: ScopedConfig) -> bool:
|
|
45
|
+
"""Return whether chat image rendering is enabled."""
|
|
46
|
+
return config.chat_render_mode == "image"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_status_items(modules: list[ModuleDefinition]) -> list[ChatStatusItem]:
|
|
50
|
+
"""Build presentation data for module status rendering."""
|
|
51
|
+
labels = {
|
|
52
|
+
"available": "可用",
|
|
53
|
+
"degraded": "降级",
|
|
54
|
+
"unavailable": "不可用",
|
|
55
|
+
}
|
|
56
|
+
icons = {"available": "🟢", "degraded": "🟡", "unavailable": "🔴"}
|
|
57
|
+
return [
|
|
58
|
+
ChatStatusItem(
|
|
59
|
+
name=module.display_name or module.name,
|
|
60
|
+
status=module.status,
|
|
61
|
+
label=labels.get(module.status, module.status),
|
|
62
|
+
icon=icons.get(module.status, "⚪"),
|
|
63
|
+
reasons=module.status_reasons[:2],
|
|
64
|
+
)
|
|
65
|
+
for module in modules
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_session_items(sessions: list[Session]) -> list[ChatSessionItem]:
|
|
70
|
+
"""Build presentation data for session list rendering."""
|
|
71
|
+
labels = {
|
|
72
|
+
"pending": "待确认",
|
|
73
|
+
"unsolved": "未解决",
|
|
74
|
+
"solved": "已解决",
|
|
75
|
+
"ignored": "已忽略",
|
|
76
|
+
}
|
|
77
|
+
icons = {
|
|
78
|
+
"pending": "🟡",
|
|
79
|
+
"unsolved": "🔴",
|
|
80
|
+
"solved": "🟢",
|
|
81
|
+
"ignored": "⚪",
|
|
82
|
+
}
|
|
83
|
+
return [
|
|
84
|
+
ChatSessionItem(
|
|
85
|
+
session_id=session.session_id,
|
|
86
|
+
module_name=session.module_name,
|
|
87
|
+
status=session.status,
|
|
88
|
+
label=labels.get(session.status, session.status),
|
|
89
|
+
icon=icons.get(session.status, "❓"),
|
|
90
|
+
time_text=session.last_seen_at.astimezone().strftime("%H:%M"),
|
|
91
|
+
description=session.description[:120],
|
|
92
|
+
)
|
|
93
|
+
for session in sessions
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def render_status_image(
|
|
98
|
+
modules: list[ModuleDefinition],
|
|
99
|
+
config: ScopedConfig,
|
|
100
|
+
) -> bytes:
|
|
101
|
+
"""Render /ruok status output as a PNG image."""
|
|
102
|
+
html = render_to_string(
|
|
103
|
+
"chat_status.html.jinja2",
|
|
104
|
+
items=build_status_items(modules),
|
|
105
|
+
)
|
|
106
|
+
return await _render_html(html, config)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def render_session_list_image(
|
|
110
|
+
*,
|
|
111
|
+
title: str,
|
|
112
|
+
sessions: list[Session],
|
|
113
|
+
total_count: int,
|
|
114
|
+
hidden_count: int,
|
|
115
|
+
config: ScopedConfig,
|
|
116
|
+
) -> bytes:
|
|
117
|
+
"""Render /ruok list output as a PNG image."""
|
|
118
|
+
html = render_to_string(
|
|
119
|
+
"chat_session_list.html.jinja2",
|
|
120
|
+
title=title,
|
|
121
|
+
items=build_session_items(sessions),
|
|
122
|
+
total_count=total_count,
|
|
123
|
+
hidden_count=hidden_count,
|
|
124
|
+
)
|
|
125
|
+
return await _render_html(html, config)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def send_chat_image(
|
|
129
|
+
bot: Bot,
|
|
130
|
+
event: Event,
|
|
131
|
+
image: bytes,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Send a rendered image through the current adapter."""
|
|
134
|
+
if bot.type != "OneBot V11":
|
|
135
|
+
raise ChatImageRenderError(f"adapter does not support image output: {bot.type}")
|
|
136
|
+
try:
|
|
137
|
+
from nonebot.adapters.onebot.v11 import MessageSegment
|
|
138
|
+
|
|
139
|
+
await bot.send(event, MessageSegment.image(image))
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise ChatImageRenderError("send image failed") from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _render_html(html: str, config: ScopedConfig) -> bytes:
|
|
145
|
+
try:
|
|
146
|
+
from nonebot_plugin_htmlrender import render_html
|
|
147
|
+
|
|
148
|
+
return await asyncio.wait_for(
|
|
149
|
+
render_html(html, wait=0),
|
|
150
|
+
timeout=config.chat_render_timeout,
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
logger.warning(f"RUOK: chat image render failed: {exc}")
|
|
154
|
+
raise ChatImageRenderError("render image failed") from exc
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Core engine: health collection, LogMonitor, session storage, module derivation.
|
|
2
|
+
|
|
3
|
+
This module is now a thin re-export layer. The actual implementations live in
|
|
4
|
+
the ``collectors/`` subpackage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime, timezone, timedelta
|
|
13
|
+
|
|
14
|
+
from .protocol import MetricPoint
|
|
15
|
+
|
|
16
|
+
# ── Re-export everything from the collectors subpackage ──
|
|
17
|
+
from .collectors import (
|
|
18
|
+
# monitor
|
|
19
|
+
LogMonitor,
|
|
20
|
+
# trackers
|
|
21
|
+
DiskRateTracker,
|
|
22
|
+
NetworkRateTracker,
|
|
23
|
+
SessionPluginValidationError,
|
|
24
|
+
SessionUpdateValidationError,
|
|
25
|
+
get_module,
|
|
26
|
+
get_session,
|
|
27
|
+
# modules
|
|
28
|
+
list_modules,
|
|
29
|
+
_disk_tracker,
|
|
30
|
+
_startup_time,
|
|
31
|
+
delete_module,
|
|
32
|
+
link_sessions,
|
|
33
|
+
list_sessions,
|
|
34
|
+
upsert_module,
|
|
35
|
+
create_session,
|
|
36
|
+
unlink_session,
|
|
37
|
+
update_session,
|
|
38
|
+
# sessions
|
|
39
|
+
_network_tracker,
|
|
40
|
+
get_session_stats,
|
|
41
|
+
_handle_ruok_error,
|
|
42
|
+
_connection_history,
|
|
43
|
+
get_linked_sessions,
|
|
44
|
+
build_plugin_impacts,
|
|
45
|
+
collect_all_statuses,
|
|
46
|
+
collect_fast_metrics,
|
|
47
|
+
derive_module_status,
|
|
48
|
+
dispatch_notification,
|
|
49
|
+
rebuild_plugin_impacts,
|
|
50
|
+
resolve_module_display,
|
|
51
|
+
confirm_session_plugins,
|
|
52
|
+
collect_process_snapshot,
|
|
53
|
+
# metrics (webui/router)
|
|
54
|
+
_collect_plugin_inventory,
|
|
55
|
+
list_module_related_sessions,
|
|
56
|
+
derive_module_status_with_reasons,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"DiskRateTracker",
|
|
61
|
+
"LogMonitor",
|
|
62
|
+
"MetricsStore",
|
|
63
|
+
"NetworkRateTracker",
|
|
64
|
+
"SessionPluginValidationError",
|
|
65
|
+
"SessionUpdateValidationError",
|
|
66
|
+
"_collect_plugin_inventory",
|
|
67
|
+
"_connection_history",
|
|
68
|
+
"_disk_tracker",
|
|
69
|
+
"_network_tracker",
|
|
70
|
+
"_startup_time",
|
|
71
|
+
"build_plugin_impacts",
|
|
72
|
+
"collect_all_statuses",
|
|
73
|
+
"collect_fast_metrics",
|
|
74
|
+
"collect_process_snapshot",
|
|
75
|
+
"confirm_session_plugins",
|
|
76
|
+
"create_session",
|
|
77
|
+
"delete_module",
|
|
78
|
+
"derive_module_status",
|
|
79
|
+
"derive_module_status_with_reasons",
|
|
80
|
+
"dispatch_notification",
|
|
81
|
+
"get_linked_sessions",
|
|
82
|
+
"get_module",
|
|
83
|
+
"get_session",
|
|
84
|
+
"get_session_stats",
|
|
85
|
+
"link_sessions",
|
|
86
|
+
"list_module_related_sessions",
|
|
87
|
+
"list_modules",
|
|
88
|
+
"list_sessions",
|
|
89
|
+
"rebuild_plugin_impacts",
|
|
90
|
+
"resolve_module_display",
|
|
91
|
+
"unlink_session",
|
|
92
|
+
"update_session",
|
|
93
|
+
"upsert_module",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# ────────────────────────────────
|
|
97
|
+
# 11. Time-series Metrics Store
|
|
98
|
+
# ────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MetricsStore:
|
|
102
|
+
"""Time-series metrics storage backed by daily JSON files.
|
|
103
|
+
|
|
104
|
+
File layout::
|
|
105
|
+
|
|
106
|
+
{data_dir}/metrics/
|
|
107
|
+
├── 2026-07-05.json
|
|
108
|
+
├── 2026-07-04.json
|
|
109
|
+
└── ...
|
|
110
|
+
|
|
111
|
+
Each file is a JSON array of MetricPoint dicts.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _metrics_dir(data_dir: Path) -> Path:
|
|
116
|
+
d = data_dir / "metrics"
|
|
117
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
return d
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _today_file(data_dir: Path) -> Path:
|
|
122
|
+
return (
|
|
123
|
+
MetricsStore._metrics_dir(data_dir)
|
|
124
|
+
/ f"{datetime.now(timezone.utc).date().isoformat()}.json"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def append(data_dir: Path, point: MetricPoint, retention_days: int = 7) -> None:
|
|
129
|
+
"""Append one MetricPoint to today's file and clean old files."""
|
|
130
|
+
file_path = MetricsStore._today_file(data_dir)
|
|
131
|
+
records: list[dict[str, Any]] = []
|
|
132
|
+
if file_path.exists():
|
|
133
|
+
try:
|
|
134
|
+
records = json.loads(file_path.read_text("utf-8"))
|
|
135
|
+
except (json.JSONDecodeError, OSError):
|
|
136
|
+
records = []
|
|
137
|
+
records.append(point.model_dump(mode="json"))
|
|
138
|
+
file_path.write_text(
|
|
139
|
+
json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
140
|
+
)
|
|
141
|
+
# Clean old files
|
|
142
|
+
MetricsStore._cleanup(data_dir, retention_days)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def query(data_dir: Path, hours: float = 24.0) -> list[MetricPoint]:
|
|
146
|
+
"""Return metrics from the last *hours* hours.
|
|
147
|
+
|
|
148
|
+
Loads today's file and yesterday's if needed.
|
|
149
|
+
"""
|
|
150
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
151
|
+
metrics_dir = MetricsStore._metrics_dir(data_dir)
|
|
152
|
+
points: list[MetricPoint] = []
|
|
153
|
+
|
|
154
|
+
today = datetime.now(timezone.utc).date()
|
|
155
|
+
days = max(1, int(hours // 24) + 2)
|
|
156
|
+
|
|
157
|
+
for offset in range(days):
|
|
158
|
+
date_str = (today - timedelta(days=offset)).isoformat()
|
|
159
|
+
fpath = metrics_dir / f"{date_str}.json"
|
|
160
|
+
if not fpath.exists():
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
records = json.loads(fpath.read_text("utf-8"))
|
|
164
|
+
except (json.JSONDecodeError, OSError):
|
|
165
|
+
continue
|
|
166
|
+
for rec in records:
|
|
167
|
+
try:
|
|
168
|
+
pt = MetricPoint.model_validate(rec)
|
|
169
|
+
ts = datetime.fromisoformat(pt.ts)
|
|
170
|
+
if ts >= cutoff:
|
|
171
|
+
points.append(pt)
|
|
172
|
+
except (ValueError, KeyError):
|
|
173
|
+
continue
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
_handle_ruok_error(exc, "MetricsStore.query 解析记录", data_dir)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
points.sort(key=lambda p: p.ts)
|
|
179
|
+
return points
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _cleanup(data_dir: Path, retention_days: int) -> None:
|
|
183
|
+
"""Remove metric files older than *retention_days*."""
|
|
184
|
+
cutoff_date = (
|
|
185
|
+
datetime.now(timezone.utc) - timedelta(days=retention_days)
|
|
186
|
+
).date()
|
|
187
|
+
metrics_dir = MetricsStore._metrics_dir(data_dir)
|
|
188
|
+
for f in metrics_dir.glob("*.json"):
|
|
189
|
+
try:
|
|
190
|
+
file_date = datetime.strptime(f.stem, "%Y-%m-%d").date()
|
|
191
|
+
except ValueError:
|
|
192
|
+
continue
|
|
193
|
+
if file_date < cutoff_date:
|
|
194
|
+
try:
|
|
195
|
+
f.unlink()
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|