const state = { tab: "operator", links: null }; function apiBase() { const path = window.location.pathname; return path.startsWith("/portal") ? "/portal/api" : "/api"; } async function loadJson(path) { const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); return response.json(); } async function postJson(path, payload) { const response = await fetch(`${apiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); return response.json(); } function statusClass(status) { const s = String(status || "UNKNOWN").toLowerCase(); if (s === "ok" || s === "true") return "status-ok"; if (s === "warn" || s === "warning") return "status-warn"; if (s === "fail" || s === "false") return "status-fail"; return "status-unknown"; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function renderSummary(summary) { const global = document.getElementById("globalStatus"); global.className = `status-pill ${statusClass(summary.severity)}`; global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`; const blocks = Object.entries(summary.blocks || {}); document.getElementById("summary").innerHTML = blocks.map(([name, block]) => `
${escapeHtml(block.status)}

${escapeHtml(label(name))}

${escapeHtml(block.text)}

`).join(""); } function label(name) { return { collection: "Сбор данных", grafana: "Grafana", dlp: "DLP", worktime: "Работа сегодня", one_c: "1С" }[name] || name; } function renderLinks(links) { const link = (text, href) => `${escapeHtml(text)}`; return ``; } function renderDlpLinks(links) { const link = (text, href) => `${escapeHtml(text)}`; return ``; } function renderSourceList(data) { const sources = [ ["DetMir", data.detmir_status], ["Проверки", data.detmir_check], ["Systemd", data.failed_units], ["Grafana data", data.grafana_data] ]; return `
${sources.map(([name, source]) => `
${escapeHtml(name)} ${escapeHtml(source?.summary || source?.error || "нет данных")} ${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}
`).join("")}
`; } function renderOperator(data) { return `

Оператор

Контур

${renderSourceList(data)}

Быстрые переходы

${renderLinks(data.links)}

Проблемы

${renderIncidentsList(data.incidents)} `; } function renderManager(data, policyExplain) { const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds); return `

Руководитель

Работа сегодня

${escapeHtml(workforceIndex)}

proxy: активное время / плановое рабочее время

Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}

${escapeHtml(data.status?.text || "")}

Приложения

${(data.applications || []).slice(0, 8).map(app => `
${escapeHtml(app.application)} ${escapeHtml(app.proved_work_human || "")} ${escapeHtml(app.evidence_events || 0)}
`).join("")}
${renderWorkforceIndexExplanation(policyExplain)}

Сотрудники

${(data.users || []).map(user => `
${escapeHtml(user.user)} Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")} ${escapeHtml(user.sessions_count || 0)} сесс.
`).join("")}
`; } function workforceIndexText(usersCount, activeSeconds) { const users = Number(usersCount || 0); const seconds = Number(activeSeconds || 0); if (users <= 0 || seconds <= 0) return "Нет данных"; const pct = Math.max(0, Math.min(100, Math.round(seconds / (users * 8 * 3600) * 100))); return `${pct}%`; } function humanSeconds(seconds) { const value = Math.max(0, Number(seconds || 0)); const totalMinutes = Math.round(value / 60); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${hours}:${String(minutes).padStart(2, "0")}`; } function pctText(value) { const n = Number(value); if (!Number.isFinite(n)) return "0%"; return `${Math.round(n * 100)}%`; } function renderWorkforceIndexExplanation(policy) { if (!policy || !policy.configured) { return `

Почему такой индекс?

Role/application policy не настроена. Доступен только нейтральный индекс активности.

`; } const details = Array.isArray(policy.app_details) ? policy.app_details.slice(0, 12) : []; const employees = Array.isArray(policy.employee_details) ? policy.employee_details.slice(0, 12) : []; const weightedTotal = Math.max(1, Number(policy.weighted_seconds || 0)); const appRows = details.length === 0 ? `
Нет приложенийНет top breakdown для weighted KPI
` : details.map(item => { const contribution = Math.round(Number(item.weighted_seconds || 0) / weightedTotal * 100); return `
${escapeHtml(item.application || "-")} ${escapeHtml(humanSeconds(item.seconds))} · вес ${escapeHtml(pctText(item.weight))} · правило ${escapeHtml(item.matched_rule || "default_weight")} ${escapeHtml(humanSeconds(item.weighted_seconds))} · ${contribution}%
`; }).join(""); return `

Почему такой индекс?

${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}

Формула: ${escapeHtml(policy.formula || "index = weighted_seconds / planned_seconds × 100")}.

${escapeHtml(workforceIndexTextFromValue(policy.index))}
Роль${escapeHtml(policy.role_label || policy.role || "-")}
План${escapeHtml(humanSeconds(policy.planned_seconds))}
App time${escapeHtml(humanSeconds(policy.app_seconds))}
Weighted${escapeHtml(humanSeconds(policy.weighted_seconds))}
${appRows}
${renderPolicyAudit(policy.policy_audit)} ${renderEmployeeIndexDetails(employees)}
`; } function renderPolicyAudit(audit) { const items = Array.isArray(audit?.needs_review) ? audit.needs_review.slice(0, 12) : []; if (items.length === 0) { return `
Аудит policyВсе top приложения попали под явные правила или данных для аудита нет.
`; } return `

Аудит policy: default_weight

Эти приложения не нашли явного правила и требуют проверки классификации.

${items.map(item => `
${escapeHtml(item.application || "-")} ${escapeHtml(humanSeconds(item.seconds))} · default ${escapeHtml(pctText(item.weight))} review
`).join("")}
`; } function renderEmployeeIndexDetails(items) { if (!items.length) return ""; return `

Drill-down по сотрудникам

Per-user индекс сейчас считается по активному времени; app-weight breakdown доступен на уровне портфеля.

${items.map(item => `
${escapeHtml(item.user || "-")} ${escapeHtml(item.reason || `${item.formula || "employee_index = active_seconds / planned_seconds × 100"} · active ${humanSeconds(item.active_seconds)} / plan ${humanSeconds(item.planned_seconds)}`)} ${escapeHtml(workforceIndexTextFromValue(item.index))}
`).join("")}
`; } function workforceIndexTextFromValue(value) { const n = Number(value); return Number.isFinite(n) ? `${Math.round(n)}%` : "Нет данных"; } function workforceIndexStatus(value) { const n = Number(value); if (!Number.isFinite(n)) return "UNKNOWN"; if (n >= 80) return "OK"; if (n >= 45) return "WARN"; return "FAIL"; } function renderOwner(data) { const cards = Object.entries(data.cards || {}); return `

Владелец

${cards.map(([name, block]) => `
${escapeHtml(block.status)}

${escapeHtml(label(name))}

${escapeHtml(block.text)}

`).join("")}

Что сделать

${(data.recommendations || []).map(item => `
Рекомендация${escapeHtml(item)}
`).join("")}

Переходы

${renderLinks(data.links)}
`; } function renderIncidentsList(items) { if (!items || items.length === 0) return `

Активных проблем нет.

`; return `
${items.map(item => `
${escapeHtml(item.source)}
${escapeHtml(item.kind)} · ${escapeHtml(item.id)}
${escapeHtml(item.summary)} ${item.acknowledged ? `
В работе: ${escapeHtml(item.assigned_to || item.actor || "оператор")} · ${escapeHtml(item.comment || "")}
` : ""}
${escapeHtml(item.status)}
`).join("")}
`; } function isDlpIncident(item) { const text = `${item?.kind || ""} ${item?.source || ""} ${item?.summary || ""}`.toLowerCase(); return text.includes("dlp") || text.includes("incident") || text.includes("case") || text.includes("иб"); } function renderDlpIncidentsList(items) { const dlpItems = (items || []).filter(isDlpIncident); if (dlpItems.length === 0) return `

Активных DLP/ИБ-инцидентов нет.

`; return renderIncidentsList(dlpItems); } function renderDlpEvidence(evidence) { if (!evidence) return `

Данные evidence загружаются.

`; if (!evidence.ok) return `

Evidence недоступны: ${escapeHtml(evidence.error || "ошибка чтения")}

`; const items = evidence.items || []; if (items.length === 0) return `

DLP evidence пока не найдены.

`; return `
${items.map(item => `
${escapeHtml(item.signal_type || item.source || item.stream_type)}
${escapeHtml(item.event_ts)} · ${escapeHtml(item.hostname)}${item.username ? " · " + escapeHtml(item.username) : ""}
${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)}
${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "SHA-256: " + escapeHtml(item.screenshot_sha256) : escapeHtml(item.blocked_reason || "без скрина")}
${item.screenshot_available ? "СКРИН" : "МЕТА"}
${item.preview_url ? `Открыть` : ""} ${item.download_url ? `Скачать` : ""}
`).join("")}
`; } function renderIncidents(data) { const links = state.links || {}; const incidents = Array.isArray(data) ? data : data.incidents; const evidence = Array.isArray(data) ? null : data.evidence; return `

Инциденты ИБ

DLP-инциденты

${renderDlpIncidentsList(incidents)}

Графики и дашборды

${renderDlpLinks(links)}

Доказательства

${renderDlpEvidence(evidence)}
`; } function renderKpiCards(items) { return `
${(items || []).map(item => `
${escapeHtml(item.status || "INFO")}

${escapeHtml(item.label)}

${escapeHtml(item.value)}

${escapeHtml(item.context || "")}

`).join("")}
`; } function renderReportSections(sections) { return `
${(sections || []).map(section => `

${escapeHtml(section.title)}

${(section.items || []).map(item => `
${escapeHtml(item.label)} ${escapeHtml(item.value)} ${escapeHtml(item.status || "INFO")}
`).join("")}
`).join("")}
`; } function renderReports(data) { return `

Отчеты

${escapeHtml(data.severity)}

${escapeHtml(data.headline)}

${escapeHtml(data.period || "")} · обновлено ${escapeHtml(data.generated_at_utc || "")}

Для руководителя

${(data.executive_points || []).map(point => `
Итог${escapeHtml(point)}
`).join("")}

Ключевые показатели

${renderKpiCards(data.kpis)} ${renderWorkforceIndexExplanation(data.workforce_policy)}

Срезы отчета

${renderReportSections(data.sections)}

Markdown для отчета

${escapeHtml(data.markdown || "")}
`; } async function refresh() { if (!state.links) state.links = await loadJson("/links"); const summary = await loadJson("/summary"); renderSummary(summary); const content = document.getElementById("content"); const data = await loadJson(`/${state.tab}`); if (state.tab === "operator") content.innerHTML = renderOperator(data); if (state.tab === "manager") { const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); content.innerHTML = renderManager(data, policyExplain); } if (state.tab === "owner") content.innerHTML = renderOwner(data); if (state.tab === "incidents") { const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); content.innerHTML = renderIncidents({ incidents: data, evidence }); } if (state.tab === "reports") content.innerHTML = renderReports(data); } function setTab(tab) { state.tab = tab; document.querySelectorAll(".tab").forEach(btn => { btn.classList.toggle("is-active", btn.dataset.tab === tab); }); document.getElementById("content").innerHTML = `

Загрузка...

`; refresh().catch(showError); } function showError(error) { document.getElementById("content").innerHTML = `
${escapeHtml(error.stack || error.message || error)}
`; } document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); document.addEventListener("click", event => { const button = event.target.closest("[data-incident-action]"); if (!button) return; incidentAction(button).catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-print-report]"); if (!button) return; window.print(); }); async function incidentAction(button) { const id = button.dataset.incidentId; const action = button.dataset.incidentAction; const payload = { id, action }; if (action === "ack") { const comment = window.prompt("Комментарий к взятию в работу", ""); if (comment === null) return; payload.comment = comment; } if (action === "assign") { const assignedTo = window.prompt("Кому назначить", ""); if (assignedTo === null || assignedTo.trim() === "") return; payload.assigned_to = assignedTo; const comment = window.prompt("Комментарий", ""); if (comment === null) return; payload.comment = comment; } button.disabled = true; await postJson("/incidents/action", payload); await refresh(); } refresh().catch(showError); setInterval(() => refresh().catch(showError), 60000);