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) => `
| ${esc(event.time)} | ${esc(event.method)} ${esc(event.selector.ref || event.selector.object_name || event.base_id || "—")} | ${esc(event.status)} | ${duration(event.duration_ms)} | ${esc(event.error || event.exception_type || "—")} | |
`).join("") || '| Запросов по выбранному фильтру нет. |
';
$("#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 `${catalogActions.map(([action, label]) => ``).join("")}`; }
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 = `Конфигурация: ${esc(base)}
${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 `▸ ${title} · ${item.count || 0} объектов ·
`; } return `▸ ${title} · ${rows.reduce((sum, item) => sum + Number(item.count || 0), 0)} объектов
${rows.map((item) => ` ├─ ${esc(item.kind_ru || item.kind)} · ${item.count || 0}
`).join("")} `; }).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 = `${objects.length} объектов · ${duration(observer.duration_ms)} · ${observer.method || ""} · ${objects.map((object) => ` └─ ${esc(object.name || object.ref || "—")}${button.dataset.kind === "Catalog" ? objectActions(base, object) : ""}
`).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]) => `${label}${value}
`).join(""); const maximum = Math.max(...summary.methods.map((item) => item.p95_ms), 1); $("#methods").innerHTML = summary.methods.slice(0, 12).map((item) => `${esc(item.method)} ${item.calls}${duration(item.p95_ms)}
`).join(""); const findings = summary.findings.map((item) => `${esc(item.method)} · ${esc(item.status)} · ${item.count} раз
${esc(item.error || "без кода")}${esc(item.recommendation)}
`).join("") || 'Отклонений нет.
'; const slow = (summary.slow_events || []).map((item) => `${esc(item.method)} · ${duration(item.duration_ms)} · ${esc(item.request_id || "—")}
${esc(item.selector.ref || item.selector.object_name || item.base_id || "без selector-а")}
`).join("") || 'Нет.
'; $("#findings").innerHTML = `${findings}Выбросы ≥ 5 сек
${slow}`; }
function renderCorrelations(data) { $("#correlations").innerHTML = data.correlations.slice(0, 250).map((item) => `| ${esc(item.request_id)} | ${esc(item.mcp.method)} | ${esc(item.mcp.status)} | ${item.rest ? esc(item.rest.status) : "не достиг REST"} | ${duration(item.mcp.duration_ms)}${item.rest ? ` / ${duration(item.rest.duration_ms)}` : ""} |
`).join("") || '| Коррелируемых событий нет. |
'; }
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 = `${esc(kind)} · ${objects.length}
| Объект | Синоним | Происхождение |
${objects.map((item) => `| ${esc(item.name || item.ref || "—")} | ${esc(item.synonym || "—")} | ${esc((item.origin || {}).source || "—")} |
`).join("") || '| Объектов нет. |
'}
`; } 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 = `База недоступна для metadata coverage: ${esc(audit.status)}Это не нулевое покрытие.
`; return; } const kinds = audit.metadata_kinds || []; root.innerHTML = `База ${esc(baseId)} → типы метаданных
| Тип | Объектов | |
${kinds.map((item) => `| └ ${esc(item.kind || "—")} | ${esc(item.count || 0)} | |
`).join("")}
`; 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 = `| Ошибка загрузки: ${esc(error.message)} |
`; });