Initial project import

This commit is contained in:
2026-08-14 09:40:51 +03:00
parent 00040e5ce4
commit d7099bf80d
146 changed files with 30509 additions and 1055 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY observer_server.py /app/observer_server.py
COPY web /app/web
ENV PYTHONUNBUFFERED=1
ENV ONEC_OBSERVER_HOST=0.0.0.0
ENV ONEC_OBSERVER_PORT=8031
ENV ONEC_OBSERVER_AUDIT_DIR=/audit
EXPOSE 8031
CMD ["python", "/app/observer_server.py"]
+14
View File
@@ -0,0 +1,14 @@
# Adapter Observer service
Standalone read-only analytics service for `adapter-1c` operational telemetry.
Run locally with:
```text
ONEC_OBSERVER_AUDIT_DIR=<directory-with-adapter-audit.jsonl> python observer_server.py
```
The production compose definition and operational contract are in
`core/deploy/docker/adapter-observer/` and
`docs/runbooks/adapter-observer.md` respectively. Do not make this service a
dependency of the adapter or give it SQL credentials.
+345
View File
@@ -0,0 +1,345 @@
"""Read-only operational observer for adapter-1c audit telemetry.
This service never connects to 1C SQL storage and never mutates adapter data.
It reads the adapter's privacy-safe rotated JSONL files from a read-only mount.
"""
from __future__ import annotations
import json
import math
import mimetypes
import os
import re
import time
import threading
from collections import Counter, defaultdict
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
ROOT = Path(__file__).resolve().parent
WEB_ROOT = ROOT / "web"
AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_AUDIT_DIR", "/audit"))
MCP_AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_MCP_AUDIT_DIR", "/mcp-audit"))
STATE_DIR = Path(os.environ.get("ONEC_OBSERVER_STATE_DIR", "/state"))
ADAPTER_URL = os.environ.get("ONEC_OBSERVER_ADAPTER_URL", "").rstrip("/")
HOST = os.environ.get("ONEC_OBSERVER_HOST", "0.0.0.0")
PORT = int(os.environ.get("ONEC_OBSERVER_PORT", "8031"))
MAX_ROWS = 10000
def number(value: object) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
AUTO_COVERAGE_BASE = os.environ.get("ONEC_OBSERVER_COVERAGE_BASE_ID", "upo_test")
AUTO_COVERAGE_INTERVAL = max(300, number(os.environ.get("ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS", "900")))
LAST_COVERAGE: dict[str, object] = {"status": "not_started"}
def percentile(values: list[int], q: float) -> int:
if not values:
return 0
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * q) - 1))
return ordered[index]
def audit_files(directory: Path, prefix: str) -> list[Path]:
if not directory.exists():
return []
paths = [p for p in directory.glob(f"{prefix}*") if p.is_file()]
return sorted(paths, key=lambda p: p.stat().st_mtime)
def read_events(directory: Path = AUDIT_DIR, prefix: str = "adapter-audit.jsonl", event_name: str = "adapter_rpc") -> tuple[list[dict], int]:
events: list[dict] = []
malformed = 0
for path in audit_files(directory, prefix):
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line in handle:
try:
row = json.loads(line)
except json.JSONDecodeError:
malformed += 1
continue
if isinstance(row, dict) and row.get("event") == event_name:
events.append(row)
except OSError:
continue
return events[-MAX_ROWS:], malformed
def event_view(row: dict, source: str = "rest") -> dict:
request = row.get("request") if isinstance(row.get("request"), dict) else {}
return {
"source": source, "time": row.get("time"), "request_id": row.get("request_id"),
"method": row.get("method"), "base_id": request.get("base_id"),
"selector": {key: request.get(key) for key in ("ref", "kind", "name", "object_type", "object_name", "extension", "mode", "execution_mode") if request.get(key) not in (None, "")},
"status": row.get("status") or "unknown", "error": row.get("error") or "",
"exception_type": row.get("exception_type") or "", "duration_ms": number(row.get("duration_ms")),
"result_duration_ms": row.get("result_duration_ms"),
"result_summary": row.get("result_summary") if isinstance(row.get("result_summary"), dict) else {},
}
def correlations(rest: list[dict], mcp: list[dict]) -> list[dict]:
rest_by_id = {str(row.get("request_id")): row for row in rest if row.get("request_id")}
rows = []
for row in reversed(mcp):
request_id = str(row.get("request_id") or "")
if not request_id:
continue
rest_row = rest_by_id.get(request_id)
mcp_view = event_view(row, "mcp")
rows.append({"request_id": request_id, "mcp": mcp_view, "rest": event_view(rest_row, "rest") if rest_row else None, "correlation_status": "matched" if rest_row else "not_reached_rest"})
return rows[:1000]
def adapter_rpc(method: str, payload: dict) -> dict:
if not ADAPTER_URL:
raise RuntimeError("adapter_url_not_configured")
request = Request(f"{ADAPTER_URL}/rpc", data=json.dumps({"method": method, "payload": payload}).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, method="POST")
try:
with urlopen(request, timeout=45) as response:
value = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError) as exc:
raise RuntimeError(f"adapter_read_failed:{type(exc).__name__}") from exc
if not isinstance(value, dict):
raise RuntimeError("adapter_response_not_object")
return value
def coverage_snapshot(base_id: str) -> dict:
methods = adapter_rpc("help.methods", {})
audit = adapter_rpc("metadata.adapter.audit", {"base_id": base_id, "include_missing": True, "include_unmapped": True, "timeout_seconds": 45})
snapshot = {"schema": "onec_adapter_observer_coverage.v1", "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "base_id": base_id, "methods": methods.get("methods") or [], "audit": audit}
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
latest = STATE_DIR / f"coverage-{base_id}.json"
previous = json.loads(latest.read_text(encoding="utf-8")) if latest.exists() else None
if isinstance(previous, dict):
old_methods = {str(item.get("name")) for item in previous.get("methods") or [] if isinstance(item, dict)}
new_methods = {str(item.get("name")) for item in snapshot["methods"] if isinstance(item, dict)}
def kind_counts(value: dict) -> dict[str, int]:
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
return {str(item.get("kind")): number(item.get("count")) for item in audit_value.get("metadata_kinds") or [] if isinstance(item, dict)}
old_kinds, new_kinds = kind_counts(previous), kind_counts(snapshot)
changed_kinds = [{"kind": kind, "before": old_kinds.get(kind, 0), "after": new_kinds.get(kind, 0)} for kind in sorted(set(old_kinds) | set(new_kinds)) if old_kinds.get(kind, 0) != new_kinds.get(kind, 0)]
def unresolved(value: dict) -> set[str]:
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
return {json.dumps(item, ensure_ascii=False, sort_keys=True) if isinstance(item, dict) else str(item) for item in audit_value.get("not_yet_decoded") or []}
old_unresolved, new_unresolved = unresolved(previous), unresolved(snapshot)
snapshot["comparison"] = {"previous_captured_at": previous.get("captured_at"), "methods_added": sorted(new_methods - old_methods), "methods_removed": sorted(old_methods - new_methods), "kind_count_changes": changed_kinds, "undecoded_added": sorted(new_unresolved - old_unresolved), "undecoded_removed": sorted(old_unresolved - new_unresolved)}
temporary = STATE_DIR / "coverage-latest.json.tmp"
temporary.write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8")
temporary.replace(latest)
history_path = STATE_DIR / f"coverage-{base_id}.history.jsonl"
history = history_path.read_text(encoding="utf-8", errors="replace").splitlines()[-49:] if history_path.exists() else []
history.append(json.dumps(snapshot, ensure_ascii=False))
history_path.write_text("\n".join(history) + "\n", encoding="utf-8")
except OSError:
snapshot["persistence_status"] = "unavailable"
return snapshot
def coverage_worker() -> None:
"""Best-effort periodic read-only snapshot; failure must not stop the UI."""
while True:
try:
snapshot = coverage_snapshot(AUTO_COVERAGE_BASE)
LAST_COVERAGE.update({"status": "ok", "captured_at": snapshot.get("captured_at"), "base_id": AUTO_COVERAGE_BASE})
except RuntimeError as exc:
LAST_COVERAGE.update({"status": "error", "base_id": AUTO_COVERAGE_BASE, "error": str(exc), "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
time.sleep(AUTO_COVERAGE_INTERVAL)
def recommendation(event: dict) -> str:
error = str(event.get("error") or "")
status = str(event.get("status") or "")
if error == "time_budget_exhausted":
return "Сузить публичный selector (ref, форма или routine) либо выполнить тяжёлую операцию как job."
if error == "public_write_route_unresolved":
return "Передать request_id и resolver summary разработчикам адаптера; не подбирать storage coordinates вручную."
if error == "ambiguous_fragment":
return "Уточнить routine_name или заменить модуль целиком; фрагмент не должен подбираться по совпадению."
if error == "base_id_required":
return "Передать base_id из списка сконфигурированных баз; не пытаться подставлять SQL-параметры."
if status in {"unsupported", "blocked", "invalid_argument"}:
return "Это безопасная остановка. Проверить публичный контракт метода и next_action в результате."
if status == "exception":
return "Найти совпадающий request_id в REST/MCP telemetry и воспроизвести только на upo_test."
return "Повторить read-операцию с тем же публичным selector-ом и сравнить длительность/статус."
def build_summary(events: list[dict], malformed: int) -> dict:
durations = [number(row.get("duration_ms")) for row in events]
failures = [row for row in events if str(row.get("status")) == "exception"]
groups: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
for row in events:
groups[(str(row.get("method") or "<none>"), str(row.get("status") or "unknown"), str(row.get("error") or ""))].append(row)
findings = []
normal_lifecycle = {"ok", "accepted", "running", "done", "cancelled", "not_found", "unknown"}
for (method, status, error), rows in sorted(groups.items(), key=lambda item: len(item[1]), reverse=True):
if status in normal_lifecycle and not error:
continue
finding = event_view(rows[-1])
findings.append({"method": method, "status": status, "error": error, "count": len(rows), "last_request_id": finding["request_id"], "recommendation": recommendation(finding)})
if len(findings) >= 20:
break
per_method: dict[str, list[int]] = defaultdict(list)
for row in events:
per_method[str(row.get("method") or "<none>")].append(number(row.get("duration_ms")))
methods = [{"method": name, "calls": len(values), "p50_ms": percentile(values, .5), "p95_ms": percentile(values, .95), "max_ms": max(values)} for name, values in per_method.items()]
slow = sorted((event_view(row) for row in events if number(row.get("duration_ms")) >= 5000), key=lambda row: row["duration_ms"], reverse=True)[:20]
return {"schema": "onec_adapter_observer_summary.v1", "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "events": len(events), "malformed_rows": malformed, "exceptions": len(failures), "p50_ms": percentile(durations, .5), "p95_ms": percentile(durations, .95), "max_ms": max(durations, default=0), "methods": sorted(methods, key=lambda row: row["p95_ms"], reverse=True), "findings": findings, "slow_events": slow}
class Handler(BaseHTTPRequestHandler):
server_version = "AdapterObserver/1.0"
def log_message(self, _format: str, *_args: object) -> None:
return
def send_json(self, status: int, value: object) -> None:
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def serve_file(self, relative: str) -> None:
target = (WEB_ROOT / relative).resolve()
if WEB_ROOT not in target.parents and target != WEB_ROOT or not target.is_file():
self.send_error(HTTPStatus.NOT_FOUND)
return
body = target.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", mimetypes.guess_type(str(target))[0] or "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_json(200, {"status": "ok", "service": "adapter-observer", "audit_dir": str(AUDIT_DIR), "files": [path.name for path in audit_files(AUDIT_DIR, "adapter-audit.jsonl")], "mcp_files": [path.name for path in audit_files(MCP_AUDIT_DIR, "mcp-audit.jsonl")], "coverage": LAST_COVERAGE})
return
events, malformed = read_events()
mcp_events, mcp_malformed = read_events(MCP_AUDIT_DIR, "mcp-audit.jsonl", "mcp_adapter_call")
if parsed.path == "/api/summary":
self.send_json(200, build_summary(events, malformed))
return
if parsed.path == "/api/events":
query = parse_qs(parsed.query)
method, status, base_id = query.get("method", [""])[0], query.get("status", [""])[0], query.get("base_id", [""])[0]
minimum_duration = number(query.get("min_duration_ms", [0])[0])
since, until = query.get("since", [""])[0], query.get("until", [""])[0]
rows = [event_view(row) for row in reversed(events)]
if method: rows = [row for row in rows if row["method"] == method]
if status: rows = [row for row in rows if row["status"] == status]
if base_id: rows = [row for row in rows if row["base_id"] == base_id]
if minimum_duration: rows = [row for row in rows if row["duration_ms"] >= minimum_duration]
if since: rows = [row for row in rows if str(row.get("time") or "") >= since]
if until: rows = [row for row in rows if str(row.get("time") or "") <= until]
limit = min(max(number(query.get("limit", [200])[0]), 1), 1000)
self.send_json(200, {"schema": "onec_adapter_observer_events.v1", "events": rows[:limit], "total": len(rows), "malformed_rows": malformed})
return
if parsed.path == "/api/mcp-events":
self.send_json(200, {"schema": "onec_adapter_observer_mcp_events.v1", "events": [event_view(row, "mcp") for row in reversed(mcp_events[-1000:])], "total": len(mcp_events), "malformed_rows": mcp_malformed})
return
if parsed.path == "/api/correlations":
self.send_json(200, {"schema": "onec_adapter_observer_correlations.v1", "correlations": correlations(events, mcp_events), "mcp_events": len(mcp_events), "mcp_malformed_rows": mcp_malformed})
return
if parsed.path == "/api/coverage":
base_id = parse_qs(parsed.query).get("base_id", ["upo_test"])[0]
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё0-9_.-]{1,80}", base_id):
self.send_json(400, {"error": "invalid_base_id"})
return
try:
self.send_json(200, coverage_snapshot(base_id))
except RuntimeError as exc:
self.send_json(502, {"error": str(exc)})
return
if parsed.path == "/api/objects":
query = parse_qs(parsed.query)
base_id, kind = query.get("base_id", ["upo"])[0], query.get("kind", [""])[0]
if not kind:
self.send_json(400, {"error": "kind_required"})
return
try:
started = time.monotonic()
result = adapter_rpc("metadata.objects.list", {"base_id": base_id, "kind": kind, "limit": min(max(number(query.get("limit", [200])[0]), 1), 1000), "offset": max(number(query.get("offset", [0])[0]), 0), "refresh_cache": True, "exact_counts": True})
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": "metadata.objects.list"}})
except RuntimeError as exc:
self.send_json(502, {"error": str(exc)})
return
if parsed.path == "/api/object":
query = parse_qs(parsed.query)
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
if not ref:
self.send_json(400, {"error": "ref_required"})
return
started = time.monotonic()
sections = {}
for name, method in (("attributes", "metadata.object.attributes"), ("forms", "metadata.object.forms"), ("modules", "metadata.object.modules"), ("templates", "metadata.object.templates")):
section_started = time.monotonic()
try:
result = adapter_rpc(method, {"base_id": base_id, "ref": ref})
sections[name] = {"status": result.get("status", "unknown"), "data": result, "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
except RuntimeError as exc:
sections[name] = {"status": "error", "error": str(exc), "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
self.send_json(200, {"schema": "onec_adapter_observer_object_node.v1", "base_id": base_id, "ref": ref, "status": "ok", "duration_ms": int((time.monotonic() - started) * 1000), "sections": sections})
return
if parsed.path == "/api/object/action":
query = parse_qs(parsed.query)
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
action = query.get("action", [""])[0]
actions = {
"card": "metadata.object.get", "properties": "metadata.object.properties",
"attributes": "metadata.object.attributes", "forms": "metadata.object.forms",
"commands": "metadata.object.commands", "modules": "metadata.object.modules",
"templates": "metadata.object.templates", "related": "metadata.object.related",
}
if not ref:
self.send_json(400, {"error": "ref_required"})
return
if action not in actions:
self.send_json(400, {"error": "unsupported_action", "supported_actions": list(actions)})
return
started = time.monotonic()
try:
result = adapter_rpc(actions[action], {"base_id": base_id, "ref": ref})
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": actions[action]}})
except RuntimeError as exc:
self.send_json(502, {"error": str(exc)})
return
if parsed.path in {"/", "/index.html"}:
self.serve_file("index.html")
return
if parsed.path.startswith("/assets/"):
self.serve_file(parsed.path.lstrip("/"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_HEAD(self) -> None: # noqa: N802
parsed = urlparse(self.path)
known = parsed.path in {"/", "/index.html", "/health", "/api/summary", "/api/events", "/api/mcp-events", "/api/correlations", "/api/coverage", "/api/objects", "/api/object", "/api/object/action"} or parsed.path.startswith("/assets/")
self.send_response(HTTPStatus.OK if known else HTTPStatus.NOT_FOUND)
self.send_header("Cache-Control", "no-store")
self.end_headers()
if __name__ == "__main__":
if ADAPTER_URL:
threading.Thread(target=coverage_worker, name="coverage-snapshot", daemon=True).start()
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
+54
View File
@@ -0,0 +1,54 @@
const $ = (selector) => document.querySelector(selector);
let allEvents = [];
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
const duration = (value) => {
const seconds = Math.max(0, Number(value || 0) / 1000);
const format = (number) => Number(number.toFixed(1)).toString().replace(".", ",");
if (seconds < 60) return `${format(seconds)} с`;
const minutes = Math.floor(seconds / 60), restSeconds = seconds - minutes * 60;
if (minutes < 60) return `${minutes} мин ${format(restSeconds)} с`;
return `${Math.floor(minutes / 60)} ч ${minutes % 60} мин ${format(restSeconds)} с`;
};
async function api(path) { const response = await fetch(path); if (!response.ok) throw new Error(await response.text()); return response.json(); }
function displayDetails(value) { if (Array.isArray(value)) return value.map(displayDetails); if (!value || typeof value !== "object") return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => /^(duration|result_duration|p50|p95|max)_ms$/.test(key) ? [key.slice(0, -3), duration(item)] : [key, displayDetails(item)])); }
function showDetails(row) { const received = row.received || row, observer = row.observer || received.observer || {}, method = row.action || observer.method || received.method || "Детали"; $("#detail-title").textContent = actionLabels?.[method] || method; $("#detail-meta").textContent = [row.ref || received.ref || received.base_id, received.status, observer.duration_ms === undefined ? "" : duration(observer.duration_ms)].filter(Boolean).join(" · "); $("#detail-json").textContent = JSON.stringify(displayDetails(row), null, 2); $("#detail").showModal(); }
function renderEvents() {
const method = $("#method").value.trim(), status = $("#status").value, base = $("#base").value.trim(), minimum = Number($("#min-duration").value || 0) * 1000, since = $("#since").value, until = $("#until").value;
const rows = allEvents.filter((event) => (!method || event.method.includes(method)) && (!status || event.status === status) && (!base || event.base_id === base) && event.duration_ms >= minimum && (!since || event.time >= since) && (!until || event.time <= until));
$("#events").innerHTML = rows.map((event, index) => `<tr><td class="muted">${esc(event.time)}</td><td><b>${esc(event.method)}</b><br><span class="muted">${esc(event.selector.ref || event.selector.object_name || event.base_id || "—")}</span></td><td class="status ${esc(event.status)}">${esc(event.status)}</td><td>${duration(event.duration_ms)}</td><td>${esc(event.error || event.exception_type || "—")}</td><td><button data-row="${index}">Детали</button></td></tr>`).join("") || '<tr><td colspan="6" class="muted">Запросов по выбранному фильтру нет.</td></tr>';
$("#events").querySelectorAll("button").forEach((button) => { button.onclick = () => showDetails(rows[Number(button.dataset.row)]); });
}
const treeGroups = [["Общие",["CommonModule","CommonForm","CommonCommand","CommonAttribute","CommonPicture","CommonTemplate","Constant","DefinedType","Role","Subsystem","ScheduledJob","EventSubscription","FunctionalOption","SessionParameter"]],["Справочники",["Catalog"]],["Документы",["Document","DocumentJournal","DocumentNumerator","Sequence"]],["Перечисления",["Enum"]],["Отчёты и обработки",["Report","DataProcessor"]],["Планы",["ChartOfAccounts","ChartOfCharacteristicTypes","ChartOfCalculationTypes","ExchangePlan"]],["Регистры",["InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister"]],["Бизнес-процессы и задачи",["BusinessProcess","Task"]],["Сервисы и интеграции",["WebService","HTTPService","IntegrationService","ExternalDataSource","XDTOPackage"]]];
const catalogActions = [["card", "Карточка"], ["properties", "Свойства"], ["attributes", "Реквизиты"], ["forms", "Формы"], ["commands", "Команды"], ["modules", "Модули"], ["templates", "Макеты"], ["related", "Связи"]];
const actionLabels = Object.fromEntries(catalogActions);
document.addEventListener("click", (event) => { const button = event.target.closest("summary button[data-kind]"); if (button) { event.preventDefault(); button.closest("details").open = true; } }, true);
function objectActions(base, object) { const ref = object.ref || `Catalog.${object.name}`; return `<span class="object-actions">${catalogActions.map(([action, label]) => `<button data-action="${action}" data-base="${esc(base)}" data-ref="${esc(ref)}">${label}</button>`).join("")}</span>`; }
async function runObjectAction(button) { const { action, base, ref } = button.dataset; button.disabled = true; const label = button.textContent; button.textContent = "…"; try { const result = await api(`/api/object/action?base_id=${encodeURIComponent(base)}&ref=${encodeURIComponent(ref)}&action=${encodeURIComponent(action)}`); showDetails({ action, ref, duration: duration(result.observer?.duration_ms), received: result }); } catch (error) { showDetails({ action, ref, error: error.message }); } finally { button.disabled = false; button.textContent = label; } }
function bindObjectActions(root) { root.querySelectorAll("button[data-action]").forEach((button) => { button.onclick = () => runObjectAction(button); }); if (!root.querySelector(".object-actions")) return; const search = document.createElement("input"), status = document.createElement("small"), rows = [...root.querySelectorAll(".object-row")]; search.className = "catalog-search"; search.type = "search"; search.placeholder = "Найти справочник"; search.setAttribute("aria-label", "Найти справочник"); status.className = "catalog-filter-status muted"; root.prepend(status); root.prepend(search); const filter = () => { const query = search.value.trim().toLocaleLowerCase(); let visible = 0; rows.forEach((row) => { const matches = !query || row.textContent.toLocaleLowerCase().includes(query); row.hidden = !matches; if (matches) visible += 1; }); status.textContent = `Показано: ${visible} из ${rows.length}`; }; search.oninput = filter; filter(); }
async function loadAllKindObjects(base, kind) {
const pageSize = 1000, first = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=0`), objects = [...(first.objects || [])], total = Number(first.counts?.total_visible ?? first.counts?.total ?? objects.length);
let elapsed = Number(first.observer?.duration_ms || 0);
for (let offset = objects.length; offset < total; offset += pageSize) { const page = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=${offset}`), rows = page.objects || []; elapsed += Number(page.observer?.duration_ms || 0); objects.push(...rows); if (!rows.length) break; }
return { ...first, objects, observer: { ...(first.observer || {}), duration_ms: elapsed } };
}
async function tree() {
const root = $("#tree-result"), base = $("#tree-base").value.trim() || "upo"; root.textContent = "Читаю структуру конфигурации…";
try {
const data = await api(`/api/coverage?base_id=${encodeURIComponent(base)}`), kinds = (data.audit || {}).metadata_kinds || [], byKind = Object.fromEntries(kinds.map((x) => [x.kind, x]));
root.innerHTML = `<h2>Конфигурация: ${esc(base)}</h2>${treeGroups.map(([title, names]) => { const rows = names.map((name) => byKind[name]).filter(Boolean); if (!rows.length) return ""; if (rows.length === 1) { const item = rows[0]; return `<details class="finding"><summary><b>▸ ${title}</b> · ${item.count || 0} объектов · <button data-kind="${esc(item.kind)}">↻ Читать</button></summary><div id="kind-${esc(item.kind)}"></div></details>`; } return `<details class="finding"><summary><b> ${title}</b> · ${rows.reduce((sum, item) => sum + Number(item.count || 0), 0)} объектов</summary>${rows.map((item) => `<div>  ${esc(item.kind_ru || item.kind)} · ${item.count || 0} <button data-kind="${esc(item.kind)}"> Читать</button><div id="kind-${esc(item.kind)}"></div></div>`).join("")}</details>`; }).join("")}`;
root.querySelectorAll("button[data-kind]").forEach((button) => { button.onclick = async () => { const box = $("#kind-" + button.dataset.kind); box.textContent = "⏳ чтение всех записей…"; try { const result = await loadAllKindObjects(base, button.dataset.kind), objects = result.objects || [], observer = result.observer || {}; box.innerHTML = `<small class="muted">${objects.length} объектов · ${duration(observer.duration_ms)} · ${observer.method || ""} · <button class="node-log">Журнал</button></small>${objects.map((object) => `<div class="object-row"> └─ <span>${esc(object.name || object.ref || "—")}</span>${button.dataset.kind === "Catalog" ? objectActions(base, object) : ""}</div>`).join("") || "Нет объектов."}`; box.querySelector(".node-log").onclick = () => showDetails({ received: result, observer }); bindObjectActions(box); } catch (error) { box.textContent = `Не удалось загрузить объекты: ${error.message}`; } }; });
} catch (error) { root.textContent = error.message; }
}
function renderSummary(summary) { $("#cards").innerHTML = [["Запросов", summary.events], ["Исключений", summary.exceptions], ["p50", duration(summary.p50_ms)], ["p95", duration(summary.p95_ms)]].map(([label, value]) => `<div class="card">${label}<b>${value}</b></div>`).join(""); const maximum = Math.max(...summary.methods.map((item) => item.p95_ms), 1); $("#methods").innerHTML = summary.methods.slice(0, 12).map((item) => `<div class="bar"><span>${esc(item.method)} <small class="muted">${item.calls}</small></span><i style="width:${Math.max(2, item.p95_ms / maximum * 100)}%"></i><span>${duration(item.p95_ms)}</span></div>`).join(""); const findings = summary.findings.map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${esc(item.status)} · ${item.count} раз<br><span class="muted">${esc(item.error || "без кода")}</span><p>${esc(item.recommendation)}</p></div>`).join("") || '<p class="muted">Отклонений нет.</p>'; const slow = (summary.slow_events || []).map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${duration(item.duration_ms)} · <span class="muted">${esc(item.request_id || "—")}</span><br>${esc(item.selector.ref || item.selector.object_name || item.base_id || "без selector-а")}</div>`).join("") || '<p class="muted">Нет.</p>'; $("#findings").innerHTML = `${findings}<h2>Выбросы ≥ 5 сек</h2>${slow}`; }
function renderCorrelations(data) { $("#correlations").innerHTML = data.correlations.slice(0, 250).map((item) => `<tr><td class="muted">${esc(item.request_id)}</td><td>${esc(item.mcp.method)}</td><td class="${esc(item.mcp.status)}">${esc(item.mcp.status)}</td><td class="${item.rest ? "ok" : "exception"}">${item.rest ? esc(item.rest.status) : "не достиг REST"}</td><td>${duration(item.mcp.duration_ms)}${item.rest ? ` / ${duration(item.rest.duration_ms)}` : ""}</td></tr>`).join("") || '<tr><td colspan="5" class="muted">Коррелируемых событий нет.</td></tr>'; }
async function openKind(kind, baseId) { const root = $("#object-list"); root.textContent = `Загружаю ${kind}`; try { const data = await api(`/api/objects?base_id=${encodeURIComponent(baseId)}&kind=${encodeURIComponent(kind)}`), objects = data.objects || []; root.innerHTML = `<h2>${esc(kind)} · ${objects.length}</h2><div class="table-wrap"><table><thead><tr><th>Объект</th><th>Синоним</th><th>Происхождение</th></tr></thead><tbody>${objects.map((item) => `<tr><td>${esc(item.name || item.ref || "—")}</td><td>${esc(item.synonym || "—")}</td><td>${esc((item.origin || {}).source || "—")}</td></tr>`).join("") || '<tr><td colspan="3" class="muted">Объектов нет.</td></tr>'}</tbody></table></div>`; } catch (error) { root.textContent = `Не удалось загрузить объекты: ${error.message}`; } }
async function coverage() { const root = $("#coverage-result"); root.textContent = "Читаю контракт и coverage snapshot…"; try { const baseId = $("#coverage-base").value.trim() || "upo", data = await api(`/api/coverage?base_id=${encodeURIComponent(baseId)}`), audit = data.audit || {}; if (audit.status && audit.status !== "ok") { root.innerHTML = `<div class="finding"><b>База недоступна для metadata coverage: ${esc(audit.status)}</b><p>Это не нулевое покрытие.</p></div>`; return; } const kinds = audit.metadata_kinds || []; root.innerHTML = `<h2>База ${esc(baseId)} → типы метаданных</h2><div class="table-wrap"><table><thead><tr><th>Тип</th><th>Объектов</th><th></th></tr></thead><tbody>${kinds.map((item) => `<tr><td>└ ${esc(item.kind || "—")}</td><td>${esc(item.count || 0)}</td><td><button class="open-kind" data-kind="${esc(item.kind)}">Открыть</button></td></tr>`).join("")}</tbody></table></div><div id="object-list" class="muted"></div>`; root.querySelectorAll(".open-kind").forEach((button) => { button.onclick = () => openKind(button.dataset.kind, baseId); }); } catch (error) { root.textContent = `Не удалось получить coverage: ${error.message}`; } }
async function load() { const [events, summary, correlations] = await Promise.all([api("/api/events?limit=500"), api("/api/summary"), api("/api/correlations")]); allEvents = events.events; renderEvents(); renderSummary(summary); renderCorrelations(correlations); }
document.querySelectorAll(".tab").forEach((button) => { button.onclick = () => { document.querySelectorAll(".tab,.panel").forEach((node) => node.classList.remove("active")); button.classList.add("active"); $(`#${button.dataset.tab}`).classList.add("active"); }; });
["method", "base", "min-duration", "since", "until"].forEach((id) => { $(`#${id}`).oninput = renderEvents; });
$("#status").onchange = renderEvents; $("#refresh").onclick = load; $("#load-coverage").onclick = coverage; $("#close").onclick = () => $("#detail").close(); $("#load-tree").onclick = tree;
load().catch((error) => { $("#events").innerHTML = `<tr><td colspan="6">Ошибка загрузки: ${esc(error.message)}</td></tr>`; });
+13
View File
@@ -0,0 +1,13 @@
:root{--ink:#e8edf1;--muted:#8f9ba6;--ground:#11161a;--panel:#182126;--line:#2b383f;--accent:#65d0b0;--warn:#f0bc65;--bad:#f27f77;--radius:7px}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);font:14px ui-monospace,"Cascadia Code",monospace}
header{min-height:92px;padding:22px max(24px,5vw);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:#141b1f}
h1{margin:0;font:600 27px Georgia,serif;letter-spacing:.02em}.eyebrow{margin:0 0 5px;color:var(--accent);font-size:11px;letter-spacing:.12em}
button,input,select{font:inherit;color:inherit;background:#202c31;border:1px solid var(--line);border-radius:5px;padding:9px 11px}button{cursor:pointer}button:hover,.tab.active{border-color:var(--accent);color:var(--accent)}button:disabled{cursor:wait;opacity:.65}
main{max-width:1500px;margin:auto;padding:22px}nav{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px}.panel{display:none;padding-top:20px}.panel.active{display:block}.filters{display:flex;gap:10px;margin-bottom:14px}.filters input{min-width:280px}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius)}table{width:100%;border-collapse:collapse}th{text-align:left;color:var(--muted);font-weight:400;background:#141b1f}th,td{padding:11px 12px;border-bottom:1px solid #243137;vertical-align:top}tr:last-child td{border:0}
.status{font-size:12px}.ok{color:var(--accent)}.partial,.blocked,.unsupported,.invalid_argument{color:var(--warn)}.exception{color:var(--bad)}.muted{color:var(--muted)}
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.card{background:var(--panel);border-left:3px solid var(--accent);padding:16px;border-radius:0 var(--radius) var(--radius) 0}.card b{font-size:25px;display:block;margin-top:7px}h2{font:600 18px Georgia,serif;margin:30px 0 12px}.bar{display:grid;grid-template-columns:220px 1fr 80px;gap:12px;align-items:center;margin:8px 0}.bar i{height:8px;background:linear-gradient(90deg,var(--accent),var(--warn));display:block}.finding{padding:12px;border:1px solid var(--line);margin:8px 0;background:var(--panel)}
.catalog-search{display:block;width:min(460px,100%);margin:10px 0;padding:7px 9px}.object-row{display:flex;align-items:flex-start;gap:10px;padding:5px 0}.object-actions{display:flex;flex-wrap:wrap;gap:5px}.object-actions button{padding:4px 7px;font-size:11px}
dialog{width:min(850px,94vw);color:var(--ink);background:#11181c;border:1px solid var(--accent);border-radius:var(--radius)}dialog pre{white-space:pre-wrap;overflow:auto;max-height:70vh}dialog button{float:right}
@media(max-width:720px){main{padding:14px}.cards{grid-template-columns:repeat(2,1fr)}.bar{grid-template-columns:1fr}.filters input{min-width:0;width:100%}header{padding:18px}.filters{flex-direction:column}.object-row{display:block}.object-actions{margin:6px 0 0 18px}}
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Adapter Observer</title><link rel="stylesheet" href="/assets/style.css"></head>
<body><header><div><p class="eyebrow">1C / SQL-ONLY / READ-ONLY</p><h1>Adapter Observer</h1></div><button id="refresh">Обновить</button></header>
<main><nav><button class="tab active" data-tab="journal">Журнал запросов</button><button class="tab" data-tab="tree">Дерево объектов</button><button class="tab" data-tab="analytics">Аналитика</button><button class="tab" data-tab="transport">MCP ↔ REST</button><button class="tab" data-tab="coverage">Покрытие</button></nav>
<section id="journal" class="panel active"><div class="filters"><input id="method" placeholder="Метод"><input id="base" placeholder="base_id"><input id="min-duration" type="number" min="0" step="0.1" placeholder="Мин. длительность, с"><input id="since" type="datetime-local" title="С"><input id="until" type="datetime-local" title="По"><select id="status"><option value="">Все статусы</option><option>ok</option><option>partial</option><option>blocked</option><option>unsupported</option><option>invalid_argument</option><option>exception</option></select></div><div class="table-wrap"><table><thead><tr><th>Время</th><th>Метод / объект</th><th>Статус</th><th>Длительность</th><th>Причина</th><th></th></tr></thead><tbody id="events"></tbody></table></div></section>
<section id="tree" class="panel"><div class="filters"><input id="tree-base" value="upo" placeholder="base_id"><button id="load-tree">Загрузить базу</button></div><div id="tree-result" class="muted">База ещё не загружена.</div></section>
<section id="analytics" class="panel"><div id="cards" class="cards"></div><h2>Медленные методы</h2><div id="methods"></div><h2>Требуют внимания</h2><div id="findings"></div></section>
<section id="transport" class="panel"><h2>Корреляция транспорта</h2><p class="muted">MCP-событие без REST-пары означает, что вызов не дошёл до адаптера.</p><div class="table-wrap"><table><thead><tr><th>request_id</th><th>Метод</th><th>MCP</th><th>REST</th><th>Время</th></tr></thead><tbody id="correlations"></tbody></table></div></section>
<section id="coverage" class="panel"><div class="filters"><input id="coverage-base" value="upo" placeholder="base_id"><button id="load-coverage">Обновить снимок</button></div><div id="coverage-result" class="muted">Снимок ещё не загружен.</div></section></main>
<dialog id="detail"><button id="close" aria-label="Закрыть">×</button><h2 id="detail-title">Детали</h2><p id="detail-meta" class="muted"></p><pre id="detail-json"></pre></dialog><script src="/assets/app.js"></script></body></html>