PyMkDB 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.
- pymkdb/__init__.py +6 -0
- pymkdb/cli.py +57 -0
- pymkdb-0.1.0.dist-info/METADATA +86 -0
- pymkdb-0.1.0.dist-info/RECORD +54 -0
- pymkdb-0.1.0.dist-info/WHEEL +5 -0
- pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
- pymkdb-0.1.0.dist-info/top_level.txt +3 -0
- sdk/__init__.py +1 -0
- sdk/connection.py +225 -0
- sdk/delta.py +19 -0
- sdk/http_connection.py +180 -0
- sdk/mkdb_client.py +226 -0
- sdk/responses.py +154 -0
- src/__init__.py +1 -0
- src/config/db.py +227 -0
- src/config/server.py +52 -0
- src/db/__init__.py +207 -0
- src/db/cache/__init__.py +1 -0
- src/db/cache/ram_cache.py +144 -0
- src/db/cache/write_queue.py +156 -0
- src/db/maintenance/__init__.py +0 -0
- src/db/maintenance/compactor.py +118 -0
- src/db/maintenance/task_scheduler.py +73 -0
- src/db/objects/store.py +283 -0
- src/db/parity/__init__.py +0 -0
- src/db/parity/parity_manager.py +196 -0
- src/db/query/__init__.py +1 -0
- src/db/query/full_text_index.py +168 -0
- src/db/query/numeric_index.py +196 -0
- src/db/query/query_engine.py +308 -0
- src/db/query/tokenizer.py +48 -0
- src/db/query_workers/__init__.py +16 -0
- src/db/query_workers/dispatcher.py +339 -0
- src/db/query_workers/task.py +78 -0
- src/db/query_workers/worker.py +292 -0
- src/db/requesting/main.py +0 -0
- src/db/storage/__init__.py +1 -0
- src/db/storage/blob_store.py +47 -0
- src/db/storage/index_manager.py +92 -0
- src/db/storage/log_manager.py +119 -0
- src/db/storage/serializer.py +38 -0
- src/filing/__init__.py +31 -0
- src/objects/__init__.py +190 -0
- src/runtime/__init__.py +15 -0
- src/server/__init__.py +0 -0
- src/server/coms/actions.py +209 -0
- src/server/coms/http.py +46 -0
- src/server/coms/http_handlers.py +445 -0
- src/server/coms/metrics.py +231 -0
- src/server/coms/socket.py +461 -0
- src/server/coms/socket_protocol.py +54 -0
- src/server/control/api/actions.py +1001 -0
- src/server/control/server.py +404 -0
- src/server/event_log.py +58 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from http.server import BaseHTTPRequestHandler
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from src.filing import read_file
|
|
10
|
+
from src.server.coms.http import HTTPServer
|
|
11
|
+
|
|
12
|
+
# ── Session store (in-memory, cleared on restart) ────────────────────────────
|
|
13
|
+
_SESSIONS: dict[str, float] = {} # admin cookie token → expiry
|
|
14
|
+
_SDK_SESSIONS: dict[str, str] = {} # sdk bearer token → control_username
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Password helpers ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def hash_password(password: str) -> str:
|
|
20
|
+
"""Hash a password using PBKDF2-HMAC-SHA256 with a random salt."""
|
|
21
|
+
salt = secrets.token_hex(16)
|
|
22
|
+
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 260_000)
|
|
23
|
+
return f"pbkdf2:sha256:260000:{salt}:{key.hex()}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_password(password: str, stored: str) -> bool:
|
|
27
|
+
"""Verify a plaintext password against a stored PBKDF2 hash."""
|
|
28
|
+
if not stored:
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
_, algo, iters, salt, key_hex = stored.split(":")
|
|
32
|
+
key = hashlib.pbkdf2_hmac(algo, password.encode(), salt.encode(), int(iters))
|
|
33
|
+
return secrets.compare_digest(key.hex(), key_hex)
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Session helpers ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _new_session(ttl: int) -> str:
|
|
41
|
+
token = secrets.token_hex(32)
|
|
42
|
+
_SESSIONS[token] = time.time() + ttl
|
|
43
|
+
_purge_expired()
|
|
44
|
+
return token
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _purge_expired() -> None:
|
|
48
|
+
now = time.time()
|
|
49
|
+
for t in [t for t, exp in _SESSIONS.items() if now > exp]:
|
|
50
|
+
del _SESSIONS[t]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_session(token: str) -> bool:
|
|
54
|
+
if not token:
|
|
55
|
+
return False
|
|
56
|
+
expiry = _SESSIONS.get(token)
|
|
57
|
+
if expiry is None:
|
|
58
|
+
return False
|
|
59
|
+
if time.time() > expiry:
|
|
60
|
+
del _SESSIONS[token]
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def invalidate_all_sessions() -> int:
|
|
66
|
+
count = len(_SESSIONS)
|
|
67
|
+
_SESSIONS.clear()
|
|
68
|
+
return count
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def active_session_count() -> int:
|
|
72
|
+
_purge_expired()
|
|
73
|
+
return len(_SESSIONS)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── SDK / control-user session helpers ───────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def _new_sdk_token(username: str) -> str:
|
|
79
|
+
token = "sdk_" + secrets.token_hex(32)
|
|
80
|
+
_SDK_SESSIONS[token] = username
|
|
81
|
+
return token
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_sdk_token(token: str):
|
|
85
|
+
"""Return username or None."""
|
|
86
|
+
return _SDK_SESSIONS.get(token)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Role-based permission map ─────────────────────────────────────────────────
|
|
90
|
+
# Maps action name → minimum role required ("viewer" < "operator" < "admin").
|
|
91
|
+
# Any action NOT listed requires "admin" (safe default).
|
|
92
|
+
|
|
93
|
+
_ROLE_ORDER = {"viewer": 0, "operator": 1, "admin": 2}
|
|
94
|
+
|
|
95
|
+
_ACTION_ROLES: dict[str, str] = {
|
|
96
|
+
# viewer
|
|
97
|
+
"api_list_stores": "viewer",
|
|
98
|
+
"api_get_store_config": "viewer",
|
|
99
|
+
"api_export_store_json": "viewer",
|
|
100
|
+
"api_get_db_info": "viewer",
|
|
101
|
+
"api_get_dashboard": "viewer",
|
|
102
|
+
"api_get_event_log": "viewer",
|
|
103
|
+
"api_get_auth_status": "viewer",
|
|
104
|
+
"api_get_db_settings": "viewer",
|
|
105
|
+
"api_get_server_status": "viewer",
|
|
106
|
+
"api_list_users": "viewer",
|
|
107
|
+
"api_get_rate_limit_log": "viewer",
|
|
108
|
+
"api_list_control_users": "viewer",
|
|
109
|
+
"api_query_store": "viewer",
|
|
110
|
+
"api_get_data_security": "viewer",
|
|
111
|
+
"api_get_store_metrics": "viewer",
|
|
112
|
+
"api_get_all_store_metrics": "viewer",
|
|
113
|
+
# operator
|
|
114
|
+
"api_create_store": "operator",
|
|
115
|
+
"api_delete_store": "operator",
|
|
116
|
+
"api_update_store": "operator",
|
|
117
|
+
"api_update_store_config": "operator",
|
|
118
|
+
"api_reset_store_metrics": "operator",
|
|
119
|
+
# admin
|
|
120
|
+
"api_update_server_config": "admin",
|
|
121
|
+
"api_server_control": "admin",
|
|
122
|
+
"api_set_auth_password": "admin",
|
|
123
|
+
"api_set_session_ttl": "admin",
|
|
124
|
+
"api_invalidate_sessions": "admin",
|
|
125
|
+
"api_set_data_security": "admin",
|
|
126
|
+
"api_update_db_settings": "admin",
|
|
127
|
+
# admin — everything else defaults to admin
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Request handler ───────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
class HTTPRequestHandler(BaseHTTPRequestHandler):
|
|
134
|
+
database = None # set by start_control_server
|
|
135
|
+
|
|
136
|
+
# helpers ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _json(self, code: int, payload: dict) -> None:
|
|
139
|
+
body = json.dumps(payload).encode()
|
|
140
|
+
self.send_response(code)
|
|
141
|
+
self.send_header("Content-Type", "application/json")
|
|
142
|
+
self.send_header("Content-Length", str(len(body)))
|
|
143
|
+
self.end_headers()
|
|
144
|
+
self.wfile.write(body)
|
|
145
|
+
|
|
146
|
+
def _html(self, code: int, body: bytes) -> None:
|
|
147
|
+
self.send_response(code)
|
|
148
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
149
|
+
self.send_header("Content-Length", str(len(body)))
|
|
150
|
+
self.send_header("Cache-Control", "no-store")
|
|
151
|
+
self.end_headers()
|
|
152
|
+
self.wfile.write(body)
|
|
153
|
+
|
|
154
|
+
def _redirect(self, location: str) -> None:
|
|
155
|
+
self.send_response(302)
|
|
156
|
+
self.send_header("Location", location)
|
|
157
|
+
self.end_headers()
|
|
158
|
+
|
|
159
|
+
def _read_body(self, max_bytes: int = 1_048_576) -> dict:
|
|
160
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
161
|
+
if length > max_bytes:
|
|
162
|
+
raise ValueError(f"Payload too large ({length} bytes, max {max_bytes})")
|
|
163
|
+
raw = self.rfile.read(length)
|
|
164
|
+
return json.loads(raw.decode())
|
|
165
|
+
|
|
166
|
+
def _serve_file(self, name: str) -> None:
|
|
167
|
+
# Sanitise: strip path separators to prevent traversal
|
|
168
|
+
safe_name = os.path.basename(name)
|
|
169
|
+
file_path = os.path.join(
|
|
170
|
+
os.path.dirname(os.path.abspath(__file__)), "files", f"{safe_name}.html"
|
|
171
|
+
)
|
|
172
|
+
if os.path.exists(file_path):
|
|
173
|
+
self._html(200, read_file(file_path).encode())
|
|
174
|
+
else:
|
|
175
|
+
self._html(404, b"<h1>404 Not Found</h1>")
|
|
176
|
+
|
|
177
|
+
def _get_cookie(self, name: str) -> str:
|
|
178
|
+
raw = self.headers.get("Cookie", "")
|
|
179
|
+
for part in raw.split(";"):
|
|
180
|
+
k, _, v = part.strip().partition("=")
|
|
181
|
+
if k.strip() == name:
|
|
182
|
+
return v.strip()
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
def _auth_enabled(self) -> bool:
|
|
186
|
+
db = self.database
|
|
187
|
+
return bool(db and db.config.servers.control_server.auth.enabled)
|
|
188
|
+
|
|
189
|
+
def _is_authenticated(self) -> bool:
|
|
190
|
+
if not self._auth_enabled():
|
|
191
|
+
return True
|
|
192
|
+
return _validate_session(self._get_cookie("mkdb_token"))
|
|
193
|
+
|
|
194
|
+
def _sdk_username(self) -> str | None:
|
|
195
|
+
"""Return the control_user username if a valid SDK Bearer token is present."""
|
|
196
|
+
auth = self.headers.get("Authorization", "")
|
|
197
|
+
if auth.startswith("Bearer "):
|
|
198
|
+
return _validate_sdk_token(auth[7:].strip())
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _caller_role(self) -> str:
|
|
202
|
+
"""Return the effective role of the caller (admin session → 'admin'; SDK token → user role)."""
|
|
203
|
+
if self._is_authenticated(): # admin session or no auth required
|
|
204
|
+
return "admin"
|
|
205
|
+
sdk_user = self._sdk_username()
|
|
206
|
+
if sdk_user and self.database:
|
|
207
|
+
cu = self.database.config.control_users.get(sdk_user)
|
|
208
|
+
if cu:
|
|
209
|
+
return cu.role
|
|
210
|
+
return "" # unauthenticated
|
|
211
|
+
|
|
212
|
+
# GET ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def do_GET(self) -> None:
|
|
215
|
+
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
216
|
+
|
|
217
|
+
if path == "/":
|
|
218
|
+
self._redirect("/home")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
# Always accessible — no auth check
|
|
222
|
+
if path == "/auth/status":
|
|
223
|
+
self._handle_auth_status()
|
|
224
|
+
return
|
|
225
|
+
if path == "/login":
|
|
226
|
+
self._serve_file("login")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# All other pages require valid session when auth is enabled
|
|
230
|
+
if not self._is_authenticated():
|
|
231
|
+
self._redirect("/login")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
self._serve_file(path.lstrip("/"))
|
|
235
|
+
|
|
236
|
+
# POST ────────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def do_POST(self) -> None:
|
|
239
|
+
path = urlparse(self.path).path
|
|
240
|
+
|
|
241
|
+
if path == "/auth/login":
|
|
242
|
+
self._handle_auth_login()
|
|
243
|
+
return
|
|
244
|
+
if path == "/auth/logout":
|
|
245
|
+
self._handle_auth_logout()
|
|
246
|
+
return
|
|
247
|
+
if path == "/control/auth":
|
|
248
|
+
self._handle_control_auth()
|
|
249
|
+
return
|
|
250
|
+
if path == "/control/auth/logout":
|
|
251
|
+
self._handle_control_auth_logout()
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Admin session OR valid SDK Bearer token may call /control
|
|
255
|
+
caller_role = self._caller_role()
|
|
256
|
+
if not caller_role:
|
|
257
|
+
self._json(401, {"status": "error", "message": "Authentication required"})
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if path == "/control":
|
|
261
|
+
self._handle_control(caller_role)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
self._json(404, {"status": "error", "message": "Not found"})
|
|
265
|
+
|
|
266
|
+
# Auth endpoints ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def _handle_auth_status(self) -> None:
|
|
269
|
+
enabled = self._auth_enabled()
|
|
270
|
+
authenticated = self._is_authenticated()
|
|
271
|
+
has_password = False
|
|
272
|
+
if self.database and enabled:
|
|
273
|
+
has_password = bool(
|
|
274
|
+
self.database.config.servers.control_server.auth.password_hash
|
|
275
|
+
)
|
|
276
|
+
self._json(200, {
|
|
277
|
+
"enabled": enabled,
|
|
278
|
+
"authenticated": authenticated,
|
|
279
|
+
"has_password": has_password,
|
|
280
|
+
"sessions": active_session_count(),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
def _handle_auth_login(self) -> None:
|
|
284
|
+
try:
|
|
285
|
+
data = self._read_body()
|
|
286
|
+
except Exception as e:
|
|
287
|
+
self._json(400, {"status": "error", "message": str(e)})
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
auth_cfg = (
|
|
291
|
+
self.database.config.servers.control_server.auth
|
|
292
|
+
if self.database else None
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if not self._auth_enabled() or auth_cfg is None:
|
|
296
|
+
# Auth disabled — hand out a short-lived session so the UI cookie
|
|
297
|
+
# pattern works uniformly without requiring a real password check.
|
|
298
|
+
token = _new_session(3600)
|
|
299
|
+
self._json(200, {"status": "ok", "token": token})
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
password = data.get("password", "")
|
|
303
|
+
|
|
304
|
+
if not auth_cfg.password_hash:
|
|
305
|
+
# First-time setup: accept this password and store it.
|
|
306
|
+
if not password:
|
|
307
|
+
self._json(400, {"status": "error", "message": "Password cannot be empty"})
|
|
308
|
+
return
|
|
309
|
+
auth_cfg.password_hash = hash_password(password)
|
|
310
|
+
self.database.config.save() # type: ignore
|
|
311
|
+
elif not verify_password(password, auth_cfg.password_hash):
|
|
312
|
+
self._json(401, {"status": "error", "message": "Incorrect password"})
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
token = _new_session(auth_cfg.session_ttl)
|
|
316
|
+
self._json(200, {"status": "ok", "token": token})
|
|
317
|
+
|
|
318
|
+
def _handle_auth_logout(self) -> None:
|
|
319
|
+
token = self._get_cookie("mkdb_token")
|
|
320
|
+
if token and token in _SESSIONS:
|
|
321
|
+
del _SESSIONS[token]
|
|
322
|
+
self._json(200, {"status": "ok"})
|
|
323
|
+
|
|
324
|
+
# Control endpoint ────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
def _handle_control_auth(self) -> None:
|
|
327
|
+
"""Authenticate a control/SDK user and return a Bearer token."""
|
|
328
|
+
try:
|
|
329
|
+
data = self._read_body()
|
|
330
|
+
except Exception as e:
|
|
331
|
+
self._json(400, {"status": "error", "message": str(e)})
|
|
332
|
+
return
|
|
333
|
+
username = str(data.get("username", "")).strip()
|
|
334
|
+
password = str(data.get("password", ""))
|
|
335
|
+
if not username or not password:
|
|
336
|
+
self._json(400, {"status": "error", "message": "username and password required"})
|
|
337
|
+
return
|
|
338
|
+
db = self.database
|
|
339
|
+
if db is None:
|
|
340
|
+
self._json(503, {"status": "error", "message": "Database not available"})
|
|
341
|
+
return
|
|
342
|
+
cu = db.config.control_users.get(username)
|
|
343
|
+
if cu is None or not verify_password(password, cu.password_hash):
|
|
344
|
+
self._json(403, {"status": "error", "message": "Invalid credentials"})
|
|
345
|
+
return
|
|
346
|
+
token = _new_sdk_token(username)
|
|
347
|
+
self._json(200, {"status": "ok", "token": token, "role": cu.role})
|
|
348
|
+
|
|
349
|
+
def _handle_control_auth_logout(self) -> None:
|
|
350
|
+
auth = self.headers.get("Authorization", "")
|
|
351
|
+
if auth.startswith("Bearer "):
|
|
352
|
+
_SDK_SESSIONS.pop(auth[7:].strip(), None)
|
|
353
|
+
self._json(200, {"status": "ok"})
|
|
354
|
+
|
|
355
|
+
def _handle_control(self, caller_role: str = "admin") -> None:
|
|
356
|
+
try:
|
|
357
|
+
data = self._read_body(max_bytes=10_485_760)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
self._json(400, {"status": "error", "message": str(e)})
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
action = data.get("action")
|
|
363
|
+
if action not in CONTROL_FUNCTIONS:
|
|
364
|
+
self._json(400, {"status": "error", "message": f"Unknown action: {action!r}"})
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
# RBAC check
|
|
368
|
+
required_role = _ACTION_ROLES.get(action, "admin")
|
|
369
|
+
if _ROLE_ORDER.get(caller_role, -1) < _ROLE_ORDER.get(required_role, 2):
|
|
370
|
+
self._json(403, {"status": "error",
|
|
371
|
+
"message": f"Action '{action}' requires role '{required_role}' (you have '{caller_role}')"})
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
result = CONTROL_FUNCTIONS[action](self.database, data)
|
|
376
|
+
self._json(200, {"status": "success", "result": result})
|
|
377
|
+
except Exception as e:
|
|
378
|
+
from traceback import print_exc
|
|
379
|
+
print_exc()
|
|
380
|
+
self._json(500, {"status": "error", "message": str(e)})
|
|
381
|
+
|
|
382
|
+
def log_message(self, format: str, *args) -> None:
|
|
383
|
+
pass # suppress default stdout logging
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
CONTROL_FUNCTIONS: dict = {}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def start_control_server(host: str, port: int, database=None):
|
|
390
|
+
HTTPRequestHandler.database = database
|
|
391
|
+
|
|
392
|
+
import inspect
|
|
393
|
+
from src.server.control.api import actions
|
|
394
|
+
for name, obj in inspect.getmembers(actions):
|
|
395
|
+
if inspect.isfunction(obj) and "api" in obj.__name__:
|
|
396
|
+
CONTROL_FUNCTIONS[name] = obj
|
|
397
|
+
|
|
398
|
+
srv = HTTPServer(
|
|
399
|
+
name="Control-HTTP",
|
|
400
|
+
host=host,
|
|
401
|
+
port=port,
|
|
402
|
+
responder=HTTPRequestHandler, # type: ignore
|
|
403
|
+
)
|
|
404
|
+
return srv
|
src/server/event_log.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
event_log.py — In-memory circular event log for the MkDB dashboard.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from src.server.event_log import emit, get_events
|
|
6
|
+
|
|
7
|
+
emit("info", "store:products", "Store loaded — 1024 entries recovered from disk")
|
|
8
|
+
emit("warning", "store:orders", "RAM cache full — eviction started")
|
|
9
|
+
emit("error", "db", "File recovery attempted on segment orders_003.log")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
_MAX_EVENTS = 200 # circular buffer depth
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_events: list[dict] = [] # newest at the end
|
|
19
|
+
|
|
20
|
+
Level = Literal["info", "warning", "error", "recovery"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def emit(level: Level, source: str, message: str) -> None:
|
|
24
|
+
"""Append one event to the ring buffer, dropping the oldest if full."""
|
|
25
|
+
entry = {
|
|
26
|
+
"ts": round(time.time(), 3),
|
|
27
|
+
"level": level,
|
|
28
|
+
"source": source,
|
|
29
|
+
"message": message,
|
|
30
|
+
}
|
|
31
|
+
with _lock:
|
|
32
|
+
_events.append(entry)
|
|
33
|
+
if len(_events) > _MAX_EVENTS:
|
|
34
|
+
_events.pop(0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_events(limit: int = 50, level: str = "") -> list[dict]:
|
|
38
|
+
"""Return up to `limit` most-recent events (newest first).
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
limit : int
|
|
43
|
+
Maximum number of events to return (capped at _MAX_EVENTS).
|
|
44
|
+
level : str
|
|
45
|
+
If given, filter to only events with this level.
|
|
46
|
+
"""
|
|
47
|
+
with _lock:
|
|
48
|
+
snapshot = list(_events) # copy under lock
|
|
49
|
+
snapshot.reverse() # newest first
|
|
50
|
+
if level:
|
|
51
|
+
snapshot = [e for e in snapshot if e["level"] == level]
|
|
52
|
+
return snapshot[:max(1, min(limit, _MAX_EVENTS))]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear() -> None:
|
|
56
|
+
"""Wipe all events (used by tests / admin reset)."""
|
|
57
|
+
with _lock:
|
|
58
|
+
_events.clear()
|