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.
Files changed (61) hide show
  1. nonebot_plugin_ruok/__init__.py +718 -0
  2. nonebot_plugin_ruok/api.py +348 -0
  3. nonebot_plugin_ruok/chat_render.py +154 -0
  4. nonebot_plugin_ruok/collector.py +197 -0
  5. nonebot_plugin_ruok/collectors/__init__.py +107 -0
  6. nonebot_plugin_ruok/collectors/metrics.py +659 -0
  7. nonebot_plugin_ruok/collectors/modules.py +252 -0
  8. nonebot_plugin_ruok/collectors/monitor.py +231 -0
  9. nonebot_plugin_ruok/collectors/notifications.py +325 -0
  10. nonebot_plugin_ruok/collectors/sessions.py +569 -0
  11. nonebot_plugin_ruok/collectors/trackers.py +119 -0
  12. nonebot_plugin_ruok/config.py +50 -0
  13. nonebot_plugin_ruok/protocol.py +248 -0
  14. nonebot_plugin_ruok/webui/auth.py +1002 -0
  15. nonebot_plugin_ruok/webui/jinja.py +87 -0
  16. nonebot_plugin_ruok/webui/router.py +1571 -0
  17. nonebot_plugin_ruok/webui/sse.py +238 -0
  18. nonebot_plugin_ruok/webui/static/vendor/chartjs/LICENSE.md +9 -0
  19. nonebot_plugin_ruok/webui/static/vendor/chartjs/chart.umd.min.js +14 -0
  20. nonebot_plugin_ruok/webui/static/vendor/htmx/LICENSE +13 -0
  21. nonebot_plugin_ruok/webui/static/vendor/htmx/htmx.min.js +1 -0
  22. nonebot_plugin_ruok/webui/static/vendor/idiomorph/LICENSE +13 -0
  23. nonebot_plugin_ruok/webui/static/vendor/idiomorph/idiomorph-ext.min.js +1 -0
  24. nonebot_plugin_ruok/webui/static/vendor/pico/LICENSE.md +21 -0
  25. nonebot_plugin_ruok/webui/static/vendor/pico/pico.min.css +4 -0
  26. nonebot_plugin_ruok/webui/templates/_auth_shell.html.jinja2 +268 -0
  27. nonebot_plugin_ruok/webui/templates/_dashboard_connections.html.jinja2 +13 -0
  28. nonebot_plugin_ruok/webui/templates/_dashboard_disk.html.jinja2 +26 -0
  29. nonebot_plugin_ruok/webui/templates/_dashboard_hero.html.jinja2 +19 -0
  30. nonebot_plugin_ruok/webui/templates/_dashboard_metrics.html.jinja2 +39 -0
  31. nonebot_plugin_ruok/webui/templates/_dashboard_stats.html.jinja2 +46 -0
  32. nonebot_plugin_ruok/webui/templates/_dashboard_system.html.jinja2 +232 -0
  33. nonebot_plugin_ruok/webui/templates/_dashboard_trends.html.jinja2 +195 -0
  34. nonebot_plugin_ruok/webui/templates/_linked_list.html.jinja2 +18 -0
  35. nonebot_plugin_ruok/webui/templates/_module_form.html.jinja2 +89 -0
  36. nonebot_plugin_ruok/webui/templates/_modules_table.html.jinja2 +50 -0
  37. nonebot_plugin_ruok/webui/templates/_notification_form.html.jinja2 +84 -0
  38. nonebot_plugin_ruok/webui/templates/_notifications_list.html.jinja2 +65 -0
  39. nonebot_plugin_ruok/webui/templates/_session_card.html.jinja2 +94 -0
  40. nonebot_plugin_ruok/webui/templates/_session_container.html.jinja2 +18 -0
  41. nonebot_plugin_ruok/webui/templates/_sessions_list.html.jinja2 +102 -0
  42. nonebot_plugin_ruok/webui/templates/_user_sessions.html.jinja2 +36 -0
  43. nonebot_plugin_ruok/webui/templates/_users_panel.html.jinja2 +271 -0
  44. nonebot_plugin_ruok/webui/templates/base.html.jinja2 +980 -0
  45. nonebot_plugin_ruok/webui/templates/chat_session_list.html.jinja2 +117 -0
  46. nonebot_plugin_ruok/webui/templates/chat_status.html.jinja2 +104 -0
  47. nonebot_plugin_ruok/webui/templates/dashboard.html.jinja2 +362 -0
  48. nonebot_plugin_ruok/webui/templates/dashboard_user.html.jinja2 +70 -0
  49. nonebot_plugin_ruok/webui/templates/login.html.jinja2 +54 -0
  50. nonebot_plugin_ruok/webui/templates/modules.html.jinja2 +16 -0
  51. nonebot_plugin_ruok/webui/templates/modules_detail.html.jinja2 +70 -0
  52. nonebot_plugin_ruok/webui/templates/notifications.html.jinja2 +16 -0
  53. nonebot_plugin_ruok/webui/templates/notifications_detail.html.jinja2 +70 -0
  54. nonebot_plugin_ruok/webui/templates/register.html.jinja2 +61 -0
  55. nonebot_plugin_ruok/webui/templates/reset_password.html.jinja2 +55 -0
  56. nonebot_plugin_ruok/webui/templates/sessions.html.jinja2 +170 -0
  57. nonebot_plugin_ruok/webui/templates/sessions_detail.html.jinja2 +234 -0
  58. nonebot_plugin_ruok/webui/templates/users.html.jinja2 +11 -0
  59. nonebot_plugin_ruok-0.2.0.dist-info/METADATA +614 -0
  60. nonebot_plugin_ruok-0.2.0.dist-info/RECORD +61 -0
  61. 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