Доска связей на живых данных хаба (viewer-live, #17) #18

Merged
aep merged 11 commits from feat/17-viewer-live into main 2026-10-14 17:29:06 +03:00
3 changed files with 49 additions and 4 deletions
Showing only changes of commit 9bfb8be549 - Show all commits

View File

@ -1,5 +1,6 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
import hmac
import json
import logging
@ -9,6 +10,7 @@ import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings
@ -20,9 +22,16 @@ from .queue import ClaimedError
log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
class ApiError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(error)
@ -142,6 +151,10 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, settings.get(conn, "base_jql"))
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
@ -249,6 +262,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [
("GET", r"^/(links)?$", h_page, None),
("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
@ -318,7 +332,8 @@ class Handler(BaseHTTPRequestHandler):
match = rx.match(url.path)
if not match:
continue
self._auth(scope)
if scope is not None:
self._auth(scope)
try:
body = json.loads(raw.decode("utf-8")) if raw else {}
except ValueError as e: # битый JSON или не UTF-8
@ -339,10 +354,16 @@ class Handler(BaseHTTPRequestHandler):
self._send(500, {"error": "internal", "detail": str(e)})
def _send(self, status, payload):
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"}
else:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
for name, value in extra.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(data)

View File

@ -1,3 +1,5 @@
<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<title>Спринт 2 линк</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">

View File

@ -1,5 +1,7 @@
import json
import threading
import urllib.error
import urllib.request
import pytest
@ -363,3 +365,23 @@ def test_board_route(hub):
with pytest.raises(HubError) as e:
client.get("/board")
assert e.value.status == 401
def _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404