Initial project import
This commit is contained in:
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -0,0 +1,54 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
let allEvents = [];
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>`; });
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user