const state = { tab: "operator", viewMode: initialViewMode(), period: "today", links: null, readiness: null, operatorData: null, reports: null, cases: null, kpiExplain: null, pendingScrollSelector: null, load: { status: "LOADING", stage: "Инициализация портала", progress: 0, lastUpdatedAt: null, lastError: null, }, }; const DEFAULT_DEPARTMENT_LABEL = "Не привязано к подразделению"; const VIEW_MODES = { executive: { label: "Руководитель", title: "Роль руководителя", heading: "Представление руководителя", description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.", stage: "Формирование главного вывода", }, manager: { label: "Менеджер", title: "Роль менеджера", heading: "Представление менеджера", description: "Workforce, сравнение подразделений, ответственные, тренды и отчет.", stage: "Расчет Workforce и подразделений", }, security: { label: "Безопасность", title: "Роль безопасности", heading: "Представление безопасности", description: "Очередь проверки, расследования, аудит решений и пакеты расследований.", stage: "Подготовка разделов проверки и расследований", }, forensics: { label: "Расследования", title: "Роль расследований", heading: "Представление расследований", description: "Карточки расследований, timeline, связка user / host / app / network event и экспорт.", stage: "Подготовка timeline расследований", }, admin: { label: "Администратор", title: "Роль администратора", heading: "Представление администратора", description: "Настройки, качество данных, источники и эксплуатационные ошибки.", stage: "Проверка настроек и источников", }, operations: { label: "Эксплуатация", title: "Роль эксплуатации", heading: "Представление эксплуатации", description: "Полнота данных, качество сбора, ошибки и телеметрия рабочих мест.", stage: "Подготовка разделов эксплуатации", }, }; function initialViewMode() { try { const stored = window.localStorage?.getItem("detmir.portal.viewMode"); return ["executive", "manager", "security", "forensics", "admin", "operations"].includes(stored) ? stored : "executive"; } catch { return "executive"; } } function apiBase() { const path = window.location.pathname; return path.startsWith("/portal") ? "/portal/api" : "/api"; } function apiRole() { if (state.tab === "employees" || state.tab === "departments") return "manager"; if (state.tab === "owner" || state.tab === "perimeter") return "security"; if (state.tab === "incidents") return "forensics"; if (state.tab === "settings") return "admin"; const mode = currentViewMode(); if (mode === "operations") return "admin"; return ["executive", "manager", "security", "forensics", "admin"].includes(mode) ? mode : "executive"; } function roleHeaders() { return { "X-AWatch-Role": apiRole() }; } function withRole(path, role = apiRole()) { const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}role=${encodeURIComponent(role)}`; } async function loadJson(path) { const response = await fetch(`${apiBase()}${path}`, { cache: "no-store", headers: roleHeaders() }); 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", ...roleHeaders() }, 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 === "ready" || s === "true" || s === "normal" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok"; if (s === "loading" || s === "warn" || s === "warning" || s === "attention" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed" || s === "open" || s === "in_progress") return "status-warn"; if (s === "degraded" || s === "high" || s === "high_risk" || s === "confirmed" || s === "rejected" || s === "archived") return "status-degraded"; if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail"; return "status-unknown"; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function displayText(value) { return String(value ?? "") .replaceAll("Risk Narrative", "Главный вывод") .replaceAll("Risk Heatmap", "Карта рисков") .replaceAll("Business Risk", "Риски подразделений") .replaceAll("Security Events", "События безопасности") .replaceAll("Security Correlation", "Связь рисков и активности") .replaceAll("Incident Candidates", "Требует проверки") .replaceAll("Incident Candidate", "Требует проверки") .replaceAll("Critical Candidates", "Срочно проверить") .replaceAll("Open Cases", "Активные расследования") .replaceAll("Agent Coverage", "Полнота данных") .replaceAll("Coverage SLA", "Полнота данных") .replaceAll("Agent Quality", "Качество данных") .replaceAll("Trust Score", "Уровень доверия") .replaceAll("Trust KPI", "Достоверность показателей") .replaceAll("Correlation Score", "Уровень взаимосвязи") .replaceAll("Forensics Readiness", "Готовность к расследованию") .replaceAll("Executive Dashboard", "Сводка руководителя") .replaceAll("Executive Summary", "Краткий вывод") .replaceAll("Cases", "Расследования") .replaceAll("Workforce", "Работа сотрудников") .replaceAll("workforce", "работа сотрудников") .replaceAll("Security", "Безопасность") .replaceAll("Forensics", "Материалы расследования") .replaceAll("UEBA риск", "Индекс риска") .replaceAll("UEBA", "оценка риска") .replaceAll("DLP/ИБ", "ИБ") .replaceAll("DLP", "Проверки ИБ") .replaceAll("Evidence metadata", "Материалы проверки") .replaceAll("Evidence", "Материалы") .replaceAll("evidence", "материалы") .replaceAll("Grafana dashboards", "графики") .replaceAll("Grafana data", "данные графиков") .replaceAll("Grafana", "Графики") .replaceAll("SECURITY_EVENTS_BACKEND", "настройка источника событий") .replaceAll("CLICKHOUSE_*", "параметры источника событий") .replaceAll("ClickHouse", "источник событий") .replaceAll("local_fallback", "резервный локальный источник") .replaceAll("DetMir ActivityWatch", "Журнал активности") .replaceAll("AW UI", "Журнал активности") .replaceAll("bundle", "пакет проверки") .replaceAll("Checksum", "Контрольная сумма") .replaceAll("Markdown", "Текстовый отчет") .replaceAll("JSON", "Данные") .replaceAll("proxy:", "расчет:") .replaceAll("rule-based", "по правилам") .replaceAll("Read-only risk score", "Оценка риска без автоматического воздействия") .replaceAll("Confidence", "Достоверность") .replaceAll("sources", "источники") .replaceAll("baseline", "обычный профиль") .replaceAll("policy", "правила") .replaceAll("default_weight", "нет явного правила") .replaceAll("App time", "Время приложений") .replaceAll("Weighted", "С учетом правил") .replaceAll("Worktime", "Рабочее время") .replaceAll("Management report", "Сводка руководителя") .replaceAll("Drill-down", "Разбор") .replaceAll("KPI", "показатель") .replaceAll("SLA", "полнота данных") .replaceAll("items", "записи") .replaceAll("medium risk", "средний риск") .replaceAll("low risk", "низкий риск") .replaceAll("high risk", "высокий риск") .replaceAll("critical risk", "критический риск") .replaceAll("weighted_seconds", "время с учетом правил") .replaceAll("planned_seconds", "плановое время") .replaceAll("active_seconds", "активное время") .replaceAll("app-weight", "вес приложений") .replaceAll("per-user", "по сотруднику") .replaceAll("user:", "сотрудник:") .replaceAll("dept:", "подразделение:") .replace(/\byes\b/g, "да") .replace(/\bno\b/g, "нет") .replace(/\bsource\b/g, "источник") .replace(/\bunknown\b/g, "неизвестно") .replace(/\baccepted\b/g, "принято") .replace(/\bopen_cases\b/g, "активные расследования") .replace(/\bcritical_candidates\b/g, "срочно проверить") .replaceAll("daily", "день") .replaceAll("weekly", "неделя") .replaceAll("monthly", "месяц") .replace(/\bCRITICAL\b/g, "критично") .replace(/\bWARNING\b/g, "требует внимания") .replace(/\bUNKNOWN\b/g, "нет данных") .replace(/\bDISABLED\b/g, "отключено") .replace(/\bREADY\b/g, "готово") .replace(/\bOPEN\b/g, "открыто") .replace(/\bINFO\b/g, "информация") .replace(/\bHIGH\b/g, "высокий риск") .replace(/\bMEDIUM\b/g, "средний риск") .replace(/\bLOW\b/g, "низкий риск") .replace(/\bFAIL\b/g, "критично") .replace(/\bWARN\b/g, "требует внимания") .replace(/\bNO\b/g, "нет") .replace(/\bOK\b/g, "в норме"); } function ui(value) { return escapeHtml(displayText(value)); } function tooltip(value) { return `title="${ui(value)}"`; } function currentViewMode() { return VIEW_MODES[state.viewMode] ? state.viewMode : "executive"; } function currentViewMeta() { return VIEW_MODES[currentViewMode()]; } function updateViewModeButtons() { document.querySelectorAll("[data-view-mode]").forEach(button => { button.classList.toggle("is-active", button.dataset.viewMode === currentViewMode()); }); document.querySelectorAll("[data-demo-view-mode]").forEach(button => { button.classList.toggle("is-active", button.dataset.demoViewMode === currentViewMode()); }); } function setViewMode(mode) { if (!VIEW_MODES[mode]) return; state.viewMode = mode; try { window.localStorage?.setItem("detmir.portal.viewMode", mode); } catch { // View mode is a UI preference only; ignore storage failures. } updateViewModeButtons(); if (state.tab !== "operator") { setTab("operator"); return; } refresh({ stage: VIEW_MODES[mode].stage }); } function renderSummary(summary, readiness) { const global = document.getElementById("globalStatus"); global.className = `status-pill ${statusClass(summary.severity)}`; global.textContent = `Сбор данных ${summary.operator_ok ? "в норме" : "нет данных"} · ${displayText(summary.severity)}`; const blocks = Object.entries(summary.blocks || {}); const readinessCard = renderReadinessSummaryCard(readiness); document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
${escapeHtml(block.status)}

${ui(label(name))}

${ui(block.text)}

`).join(""); } function renderReadinessSummaryCard(readiness) { const bundle = readiness?.bundle || {}; const verify = readiness?.verify || {}; const status = bundle.status?.status || bundle.status || (verify.ok ? "OK" : "UNKNOWN"); const generated = bundle.status?.generated_at_utc || bundle.generated_at_utc || "-"; const signature = bundle.status?.signature || {}; const signatureOk = Boolean(verify.signature_verified || signature.verified); const checksumOk = Boolean(verify.checksum_verified || bundle.status?.checksum_verified || verify.ok); const fingerprint = signature.public_key_fingerprint_sha256 || "-"; const verificationText = verify.generated_at_utc ? `Проверено: ${verify.generated_at_utc}` : "Проверка не запускалась"; return `
${escapeHtml(status)}

Готовность системы

Дата${escapeHtml(generated)}
Подпись${signatureOk ? "подтверждена" : "не подтверждена"}
Контрольная сумма${checksumOk ? "подтверждена" : "не подтверждена"}

Отпечаток ключа: ${escapeHtml(shortFingerprint(fingerprint))}

${escapeHtml(verificationText)}

`; } function shortFingerprint(value) { const text = String(value || "-"); if (text.length <= 24) return text; return `${text.slice(0, 12)}…${text.slice(-12)}`; } function label(name) { return { collection: "Сбор данных", grafana: "Графики", dlp: "Проверки ИБ", worktime: "Работа сегодня", one_c: "1С", work: "Работа сотрудников", security: "Безопасность" }[name] || name; } function setLoadStatus(status, stage, progress, options = {}) { state.load.status = status; state.load.stage = stage || state.load.stage || "Обновление данных"; state.load.progress = Math.max(0, Math.min(100, Number(progress) || 0)); if (status === "READY" || status === "EMPTY") { state.load.lastUpdatedAt = new Date().toISOString(); state.load.lastError = null; } if (status === "ERROR" || status === "STALE") { state.load.lastError = options.error || state.load.lastError || null; } renderLoadStatus(); } function loadStatusLabel(status) { return { LOADING: "Загрузка данных", READY: "Данные готовы", EMPTY: "Данные отсутствуют", STALE: "Данные устарели", ERROR: "Ошибка получения данных", UNKNOWN: "Состояние неизвестно", }[status] || "Состояние неизвестно"; } function renderLoadStatus() { const box = document.getElementById("loadingStatus"); if (!box) return; const status = state.load.status || "UNKNOWN"; const stage = state.load.stage || "Состояние неизвестно"; const progress = Math.max(0, Math.min(100, Number(state.load.progress) || 0)); const stateText = document.getElementById("loadingStateText"); const stageText = document.getElementById("loadingStageText"); const updatedText = document.getElementById("loadingUpdatedText"); const bar = document.getElementById("loadingProgressBar"); box.className = `loading-status loading-status-${status.toLowerCase()}`; box.dataset.loadStatus = status; if (stateText) stateText.textContent = loadStatusLabel(status); if (stageText) stageText.textContent = stage; if (updatedText) updatedText.textContent = `последнее обновление: ${formatLoadTime(state.load.lastUpdatedAt)}`; if (bar) bar.style.width = `${progress}%`; } function formatLoadTime(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } function renderLoadingContent(stage = "Данные загружаются") { return `
Загрузка данных

${ui(stage)}

${Array.from({ length: 6 }).map(() => `
`).join("")}
`; } function renderEmptyState(stage = "Данных за выбранный период нет") { return `
Данные отсутствуют

Данных пока нет

${ui(stage)}

`; } function renderErrorState(error) { const message = error?.message || error?.stack || String(error || "Неизвестная ошибка"); return `
Ошибка получения данных

Ошибка получения данных

Портал не получил актуальные данные. Подробности ниже.

${escapeHtml(message)}
`; } function staleBanner(error) { const message = error?.message || String(error || "ошибка обновления"); return `
Данные устарели Показаны ранее загруженные данные.

Последнее обновление: ${escapeHtml(formatLoadTime(state.load.lastUpdatedAt))}. Новая загрузка не завершилась: ${escapeHtml(message)}

`; } function hasTabData(tab, payload) { if (!payload) return false; if (tab === "operator") { const report = payload.report || {}; return (Array.isArray(report.kpis) && report.kpis.length > 0) || (Array.isArray(report.executive_points) && report.executive_points.length > 0) || (Array.isArray(payload.data?.workforce) && payload.data.workforce.length > 0) || Boolean(report.workforce && Object.keys(report.workforce).length > 0) || Boolean(report.security_events_summary) || (Array.isArray(report.risk_incident_candidates) && report.risk_incident_candidates.length > 0) || (Array.isArray(report.forensics?.investigations) && report.forensics.investigations.length > 0); } if (tab === "employees") { return Array.isArray(payload.data?.workforce) && payload.data.workforce.length > 0; } if (tab === "departments") { return Array.isArray(payload.data?.workforce?.department_comparison) && payload.data.workforce.department_comparison.length > 0; } if (tab === "owner" || tab === "perimeter") { return Boolean(payload.data && Object.keys(payload.data).length > 0); } if (tab === "incidents") { return (Array.isArray(payload.data?.incidents) && payload.data.incidents.length > 0) || (Array.isArray(payload.data?.reports?.risk_incident_candidates) && payload.data.reports.risk_incident_candidates.length > 0) || (Array.isArray(payload.data?.cases?.cases) && payload.data.cases.cases.length > 0); } if (tab === "reports" || tab === "settings") return Boolean(payload.data && Object.keys(payload.data).length > 0); return true; } function findKpi(report, needle) { const text = String(needle || "").toLowerCase(); return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text)); } function findSection(report, title) { const text = String(title || "").toLowerCase(); return (report?.sections || []).find(item => String(item.title || "").toLowerCase().includes(text)); } function periodConfig(period = state.period) { if (period === "week") return { key: "week", label: "неделя", days: 7 }; if (period === "month") return { key: "month", label: "месяц", days: 30 }; return { key: "today", label: "сегодня", days: 1 }; } function periodFromSelectValue(value) { const text = String(value || "").toLowerCase(); if (text === "week") return "week"; if (text === "month") return "month"; if (text.includes("нед") || text.includes("7")) return "week"; if (text.includes("мес") || text.includes("30")) return "month"; return "today"; } function trendPoints(report, days) { const trend = Array.isArray(report?.workforce?.trend) ? report.workforce.trend : []; return trend.slice(Math.max(0, trend.length - days)); } function averageCoverage(points) { const values = points .map(item => Number(item.portfolio_coverage_pct)) .filter(value => Number.isFinite(value)); if (values.length === 0) return null; return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length); } function periodStatus(points, config) { if (config.key === "today") return "OK"; if (points.length >= config.days) return "OK"; if (points.length > 0) return "WARN"; return "UNKNOWN"; } function periodReadinessText(report, config = periodConfig()) { if (config.key === "today") return "дневной срез"; const points = trendPoints(report, config.days); if (points.length >= config.days) return `готово: ${config.label}`; return `накоплено ${points.length}/${config.days} дней`; } function periodReport(report) { if (!report || state.period === "today") return report; const config = periodConfig(); const points = trendPoints(report, config.days); const average = averageCoverage(points); const next = JSON.parse(JSON.stringify(report)); next.period = `управленческий срез за период: ${config.label}`; next.period_view = { key: config.key, label: config.label, required_days: config.days, available_days: points.length, status: periodStatus(points, config), average_coverage_pct: average }; next.kpis = (next.kpis || []).map(item => { if (item.label === "Индекс активности") { return { ...item, value: average === null ? "нет данных" : `${average}%`, status: periodStatus(points, config), context: `${config.label}: среднее по накопленной истории, ${points.length}/${config.days} дней` }; } if (item.label === "Активное время") { return { ...item, value: state.period === "week" ? "по дням" : "по истории", status: periodStatus(points, config), context: "агрегация времени требует накопленных дневных срезов" }; } if (item.label === "Подразделения") { return { ...item, context: `${config.label}: текущий состав групп, тренд по истории` }; } return item; }); return next; } function renderPeriodBanner(report) { const config = periodConfig(); if (config.key === "today") return ""; const view = periodReport(report)?.period_view || {}; const status = view.status || "UNKNOWN"; const avg = Number.isFinite(Number(view.average_coverage_pct)) ? `${view.average_coverage_pct}%` : "нет данных"; return `
Период: ${ui(config.label)} Накоплено ${escapeHtml(view.available_days ?? 0)} из ${escapeHtml(config.days)} дневных срезов. Средняя активность: ${escapeHtml(avg)}.
${ui(periodReadinessText(report, config))}
`; } function renderDailyDetailNotice() { const config = periodConfig(); if (config.key === "today") return ""; return `
Период: ${ui(config.label)} Этот детальный экран показывает последний дневной срез. Сводная интерпретация за период доступна в разделах "Пульс организации", "Подразделения" и "Отчеты".
дневная детализация
`; } function metricCard(labelText, value, status, context, trend) { const trendText = trend || context || ""; const trendClass = statusClass(status).replace("status-", ""); return `
${escapeHtml(status || "INFO")} ${ui(labelText)} ${ui(value ?? "Нет данных")} ${ui(trendText)}
`; } function renderExecutiveMetrics(report, incidents) { void incidents; return `
${executiveDashboardKpis(report)}
`; } function renderExecutiveDashboard(report) { const dashboard = report?.executive_dashboard; if (!dashboard) return ""; const highRisk = Array.isArray(dashboard.high_risk_departments) ? dashboard.high_risk_departments : []; const candidates = Array.isArray(dashboard.critical_candidates) ? dashboard.critical_candidates : []; const summary = dashboard.summary || {}; return `

Сводка руководителя

Что происходит в организации прямо сейчас: достоверность показателей, полнота данных, риски, активные расследования и готовность к расследованию.

${ui(dashboard.forensics_readiness || "UNKNOWN")}
Достоверность показателей${ui(optionalPercent(dashboard.trust_kpi_score))}
Полнота данных${ui(optionalPercent(dashboard.agent_coverage_pct))}
Высокий риск${ui(highRisk.length)}
Срочно проверить${ui(candidates.length)}
Активные расследования${ui(dashboard.open_cases ?? 0)}
Закрыто за 30 дней${ui(dashboard.resolved_cases_30d ?? 0)}
События безопасности${ui(dashboard.security_events_24h ?? "откл.")}
Главный риск${ui(summary.main_risk || "нет данных")}
Главное улучшение${ui(summary.main_improvement || "нет данных")}
Пробел в данных${ui(summary.main_data_gap || "нет данных")}
`; } function renderRiskNarrative(report) { const narrative = report?.risk_narrative; if (narrative) { const why = Array.isArray(narrative.why) ? narrative.why.slice(0, 5) : []; const evidence = Array.isArray(narrative.evidence) ? narrative.evidence.slice(0, 6) : []; const actions = Array.isArray(narrative.recommended_actions) ? narrative.recommended_actions.slice(0, 4) : []; const limitations = Array.isArray(narrative.limitations) ? narrative.limitations.slice(0, 3) : []; return `

Риск-нарратив

${ui(narrative.summary || "Система связывает показатели активности, полноту данных, оценку риска и очередь проверки.")}

${ui(narrative.risk_level || "low")} · ${ui(narrative.risk_score ?? 0)}/100
Главный вывод ${ui(narrative.title || "Риск не рассчитан")}
Уверенность ${ui(narrative.confidence || "unknown")}
Классификация ${ui(narrative.classification || "insufficient_data")}
Модель ${ui(narrative.model?.type || "rule_based")}
Уровень ${ui(narrative.risk_level || "low")}

Почему

    ${(why.length ? why : ["Существенных негативных признаков не выявлено"]).map(item => `
  • ${ui(item)}
  • `).join("")}

Подтверждения

    ${evidence.map(item => `
  • ${ui(item.label || item.source || "-")}: ${ui(item.value || "-")} ${ui(item.severity || "low")}
  • `).join("") || "
  • Подтверждающие сигналы пока не рассчитаны
  • "}

Дальше

    ${actions.map(item => `
  • ${ui(item)}
  • `).join("") || "
  • Продолжить наблюдение
  • "}
${limitations.length ? `

Ограничения: ${limitations.map(ui).join(" · ")}

` : ""}
`; } const dashboard = report?.executive_dashboard; const summary = dashboard?.summary || {}; const narrativeStatus = summary.risk_narrative_status || executiveDashboardStatus(dashboard || {}); return `

Главный вывод

Главный управленческий вывод: что происходит, почему это риск и какие слои это подтверждают.

${ui(narrativeStatus)}
Главная причина риска${ui(summary.main_risk_cause || "связанный риск не выражен")}${ui(narrativeStatus)}
Подтверждающие слоиДостоверность показателей · Полнота данных · Риски подразделений · Карта рисков · Связь рисков и активности · Требует проверки · Расследования
`; } function renderActionCenter(actions, options = {}) { const items = Array.isArray(actions) ? actions.slice(0, 6) : []; if (!items.length) { return `

${ui(options.title || "Рекомендуемые действия")}

Критичных действий по текущему срезу не требуется.

low
`; } return `

${ui(options.title || "Рекомендуемые действия")}

Рекомендации сформированы по Workforce KPI, UEBA, покрытию данных, корреляции безопасности и кандидатам на проверку.

${ui(items[0]?.priority || "low")}
${items.map(action => `
${ui(action.priority || "low")} ${ui(action.title || "Действие не указано")}

${ui(action.summary || "Описание действия не указано")}

Срок ${ui(action.recommended_deadline || "72h")} Адресат ${ui(actionOwnerLabel(action.owner_role))}
${(Array.isArray(action.reason_codes) ? action.reason_codes : []).slice(0, 4).map(code => `${ui(code)}`).join("")}
    ${(Array.isArray(action.evidence) ? action.evidence : []).slice(0, 3).map(item => `
  • ${ui(item)}
  • `).join("")}
`).join("")}
`; } function actionOwnerLabel(value) { const map = { executive: "Руководитель", manager: "Руководитель подразделения", security: "ИБ", forensics: "Расследования", admin: "Эксплуатация" }; return map[value] || value || "Ответственный"; } function optionalPercent(value) { const number = Number(value); return Number.isFinite(number) ? `${Math.round(number)}%` : "нет данных"; } function executiveDashboardStatus(dashboard) { if ((dashboard.critical_candidates || []).length > 0) return "WARN"; if ((dashboard.high_risk_departments || []).length > 0) return "WARN"; if (Number(dashboard.agent_coverage_pct) < 75) return "FAIL"; return "OK"; } function statusRiskLabel(status) { const value = String(status || "INFO").toUpperCase(); if (value === "FAIL") return "high"; if (value === "WARN") return "medium"; if (value === "OK") return "low"; return "info"; } function trendArrow(status) { const value = String(status || "INFO").toUpperCase(); if (value === "OK") return "→"; if (value === "WARN") return "↓"; if (value === "FAIL") return "↓"; return "→"; } function firstPercent(value) { const match = String(value || "").match(/-?\d+(?:[.,]\d+)?\s*%/); return match ? match[0].replace(",", ".").replace(/\s+/g, "") : "-"; } function percentNumber(value) { const text = firstPercent(value); if (text === "-") return null; const number = Number(text.replace("%", "")); return Number.isFinite(number) ? number : null; } function signedPercent(value) { if (!Number.isFinite(value)) return "нет истории"; const rounded = Math.round(value); if (rounded > 0) return `+${rounded}%`; return `${rounded}%`; } function parseActivityValue(value) { const text = String(value || ""); const activeMatch = text.match(/active\s+(\d+)\s*\/\s*(\d+)/i); const hhmmMatch = text.match(/(\d{1,3}:\d{2})/); return { activity: percentNumber(text), active: activeMatch ? Number(activeMatch[1]) : null, total: activeMatch ? Number(activeMatch[2]) : null, hhmm: hhmmMatch ? hhmmMatch[1] : "00:00", }; } function statusWeight(status) { const value = String(status || "INFO").toUpperCase(); if (value === "FAIL") return 3; if (value === "WARN") return 2; if (value === "OK") return 1; return 0; } function departmentDeviation(item) { const status = String(item?.status || "INFO").toUpperCase(); const value = item?.value || ""; if (status === "OK") return "в норме"; if (status === "WARN") return `отклонение: ${firstPercent(value)}`; if (status === "FAIL") return `критическая просадка: ${firstPercent(value)}`; return "требует данных"; } function departmentResponsible(owners, index) { const item = Array.isArray(owners) ? owners[index] : null; return item?.label || item?.title || "не назначен"; } function latestTrend(report) { const trend = Array.isArray(report?.workforce?.trend) ? report.workforce.trend : []; return trend.length ? trend[trend.length - 1] : {}; } function weeklyTrendPct(report) { const trend = Array.isArray(report?.workforce?.trend) ? report.workforce.trend : []; if (trend.length < 2) return null; const first = Number(trend[0]?.portfolio_coverage_pct); const last = Number(trend[trend.length - 1]?.portfolio_coverage_pct); if (!Number.isFinite(first) || !Number.isFinite(last)) return null; return last - first; } function departmentTrendPct(report, name) { const trend = Array.isArray(report?.workforce?.trend) ? report.workforce.trend : []; const points = trend .map(day => (day.department_rollups || []).find(item => item.name === name)) .filter(Boolean); if (points.length < 2) return null; const first = Number(points[0]?.portfolio_coverage_pct); const last = Number(points[points.length - 1]?.portfolio_coverage_pct); if (!Number.isFinite(first) || !Number.isFinite(last)) return null; return last - first; } function departmentRows(report) { const departments = report?.workforce?.department_comparison || []; const owners = report?.workforce?.owner_comparison || []; return departments.map((item, index) => { const parsed = parseActivityValue(item.value); const activity = item.index_activity ? percentNumber(item.index_activity) : parsed.activity; const trend = departmentTrendPct(report, item.label); const status = item.status || "INFO"; const responsible = item.responsible || departmentResponsible(owners, index); return { label: item.label || DEFAULT_DEPARTMENT_LABEL, activity, activityText: Number.isFinite(activity) ? `${Math.round(activity)}%` : firstPercent(item.value), deviation: item.deviation || departmentDeviation(item), status, responsible, active: parsed.active, total: parsed.total, hhmm: parsed.hhmm, trend, risk: status === "FAIL" ? "критично — требуется действие" : status === "WARN" ? "требует внимания" : "низкий риск — все нормально", reason: departmentRiskReason({ item, parsed, activity, status, trend }), check: departmentCheckText({ parsed, status, trend }), }; }); } function departmentRiskReason({ parsed, activity, status, trend }) { const reasons = []; if (Number.isFinite(activity)) reasons.push(`индекс активности ${Math.round(activity)}%`); if (Number.isFinite(parsed.active) && Number.isFinite(parsed.total)) reasons.push(`активны ${parsed.active}/${parsed.total} сотрудников`); if (status === "WARN") reasons.push("подразделение ниже рабочего порога"); if (status === "FAIL") reasons.push("требуется управленческое действие"); if (Number.isFinite(trend) && trend < 0) reasons.push(`недельный тренд ${signedPercent(trend)}`); if (parsed.hhmm === "00:00") reasons.push("нет подтвержденного рабочего времени"); return reasons.join("; ") || "недостаточно данных для уверенного вывода"; } function departmentCheckText({ parsed, status, trend }) { if (status === "OK") return "держать под наблюдением, действий не требуется"; if (parsed.hhmm === "00:00") return "проверить входы в рабочие системы, RDP/1C активность и свежесть данных коллектора"; if (Number.isFinite(trend) && trend < 0) return "сверить план задач, нагрузку ответственного и причины падения за неделю"; return "поручить ответственному проверить план работ и первичные события ActivityWatch"; } function executiveDashboardKpis(report) { const rows = departmentRows(report); const latest = latestTrend(report); const activityValues = rows.map(row => row.activity).filter(Number.isFinite); const average = activityValues.length ? Math.round(activityValues.reduce((sum, value) => sum + value, 0) / activityValues.length) : null; const criticalRisks = (report?.ueba_risk?.reasons || []).filter(item => String(item.status || item.severity).toUpperCase() === "FAIL" || Number(item.points || 0) >= 25).length; return [ metricCard("Сотрудников в работе", latest.active_users ?? findKpi(report, "Сотрудники")?.value ?? "нет данных", "OK", "активны сегодня"), metricCard("Средний индекс активности", Number.isFinite(average) ? `${average}%` : findKpi(report, "Индекс активности")?.value, average === null ? "UNKNOWN" : workforceIndexStatus(average), "по подразделениям"), metricCard("Требуют внимания", rows.filter(row => row.status === "WARN").length, rows.some(row => row.status === "WARN") ? "WARN" : "OK", "подразделения"), metricCard("Критичные подразделения", rows.filter(row => row.status === "FAIL").length, rows.some(row => row.status === "FAIL") ? "FAIL" : "OK", "требуется действие"), metricCard("Критических рисков", criticalRisks, criticalRisks ? "FAIL" : "OK", "приоритетный разбор"), metricCard("Тренд недели", signedPercent(weeklyTrendPct(report)), Number(weeklyTrendPct(report)) < 0 ? "WARN" : "OK", "динамика активности"), ].join(""); } function renderDepartmentTable(report) { const rows = departmentRows(report).slice(0, 12); if (!rows.length) return `

Подразделения пока не рассчитаны.

`; return `
${rows.map(row => ` `).join("")}
ПодразделениеИндекс активностиОтклонениеСтатусОтветственный
${ui(row.label)} ${ui(row.activityText || "-")} ${ui(row.deviation)} ${ui(row.status || "INFO")} ${ui(row.responsible)}
`; } function anomalyExplanation(item) { const title = item?.label || item?.title || "Аномалия"; const evidence = item?.value || item?.subject || "система отметила отклонение от обычного среза"; const status = String(item?.status || "INFO").toUpperCase(); const text = `${title} ${evidence}`.toLowerCase(); let why = "это может указывать на изменение загрузки, дисциплины процесса или качества сбора данных"; let check = "проверить первичные события ActivityWatch, рабочий план и контекст подразделения"; if (text.includes("копирован") || text.includes("dlp") || text.includes("файл")) { why = "есть риск неконтролируемого движения данных или подготовки выгрузки"; check = "открыть материалы проверки, сверить файл, пользователя, время и разрешенную бизнес-задачу"; } else if (text.includes("ноч") || text.includes("вне рабочего")) { why = "активность вне обычного окна может быть переработкой, удаленным доступом или нарушением регламента"; check = "сверить RDP-сессию, задачу сотрудника и журнал входов"; } else if (text.includes("недогруз") || text.includes("просад") || text.includes("coverage")) { why = "просадка активности может означать простой, неверный план работ или сбой сбора"; check = "сравнить план задач, присутствие в системе и свежесть данных коллектора"; } else if (status === "FAIL") { why = "событие имеет высокий приоритет и может влиять на безопасность или управляемость"; check = "назначить ручную проверку и сопоставить событие с журналами источников"; } return { title, evidence, why, check }; } function renderAnomalies(report) { const insights = Array.isArray(report?.workforce?.insights) ? report.workforce.insights : []; const items = insights.filter(item => item.status !== "OK").slice(0, 8); if (!items.length) return `

Существенных аномалий за выбранный период нет.

`; return ``; } function renderTopRisks(report) { const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : []; if (!reasons.length) return `

Существенных риск-сигналов нет.

`; return `
    ${reasons.slice(0, 8).map((item, index) => `
  1. ${index + 1} ${ui(item.label || item.code || "Риск")}${ui(item.recommendation || item.value || "")} +${escapeHtml(item.points || 0)}
  2. `).join("")}
`; } function renderDepartmentRanking(report) { const rows = departmentRows(report); const best = [...rows] .sort((a, b) => (b.activity ?? -1) - (a.activity ?? -1) || statusWeight(a.status) - statusWeight(b.status)) .slice(0, 5); const problem = [...rows] .sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101)) .slice(0, 5); const renderRows = items => `
${items.map(row => `
${ui(row.label)} ${ui(row.activityText)} · ${ui(row.deviation)} · ${ui(row.responsible)} ${ui(row.status)}
`).join("")}
`; const emptyRows = `
Нет данныхПодразделения пока не рассчитаны.нет данных
`; return `

ТОП-5 лучших подразделений

${best.length ? renderRows(best) : emptyRows}

ТОП-5 проблемных подразделений

${problem.length ? renderRows(problem) : emptyRows}
`; } function attentionItems(report) { const rows = departmentRows(report); const items = []; rows .filter(row => row.status === "FAIL" || row.status === "WARN") .slice(0, 3) .forEach(row => items.push({ title: `${row.status === "FAIL" ? "Требуется действие" : "Требуется внимание"}: ${row.label}`, why: row.reason, check: row.check, action: `Поручить разбор: ${row.responsible}. Открыть расследование и сверить первичные события.`, status: row.status, })); (report?.workforce?.insights || []) .filter(item => item.status !== "OK") .slice(0, Math.max(0, 5 - items.length)) .forEach(item => { const explanation = anomalyExplanation(item); items.push({ title: explanation.title, why: explanation.why, check: explanation.check, action: "Назначить владельца проверки и сверить с планом работ.", status: item.status || "WARN", }); }); return items.slice(0, 5); } function renderAttentionBlock(report) { const items = attentionItems(report); if (!items.length) return ""; return `

Требует внимания

что руководителю нужно поручить сегодня
${items.map(item => `
${ui(item.status || "INFO")}
${ui(item.title)}

Почему это важно: ${ui(item.why)}

Что проверить: ${ui(item.check)}

Рекомендуемое действие: ${ui(item.action)}

`).join("")}
`; } function renderDepartmentHeatMap(report) { const rows = departmentRows(report); return `

Heat Map подразделений

риск понятным языком для руководителя
${rows.length ? rows.map(row => ` `).join("") : ` `}
ПодразделениеАктивностьОтклонениеРискОтветственныйДействие
${ui(row.label)}${ui(row.reason)} ${ui(row.activityText)} ${ui(row.deviation)} ${ui(row.risk)} ${ui(row.responsible)}
Нет данныхПодразделения пока не рассчитаны. - - нет данных -
`; } function renderDepartmentLeaderCard(report) { const rows = departmentRows(report); const row = rows.length ? [...rows].sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))[0] : { label: "Нет данных", responsible: "-", total: "-", active: "-", activityText: "-", trend: null, status: "UNKNOWN", risk: "UNKNOWN", check: "Дождаться расчета подразделений и проверить источник worktime management." }; const best = rows.length ? [...rows].sort((a, b) => (b.activity ?? -1) - (a.activity ?? -1))[0] : row; const worst = rows.length ? [...rows].sort((a, b) => (a.activity ?? 101) - (b.activity ?? 101))[0] : row; return `

Карточка руководителя подразделения

Срез только для чтения, чтобы поручить разбор ответственному.

${ui(row.risk)}
Подразделение${ui(row.label)}
Ответственный${ui(row.responsible)}
Сотрудников всего${ui(row.total ?? "-")}
Активны сегодня${ui(row.active ?? "-")}
Средний индекс${ui(row.activityText)}
Лучший показатель${ui(best?.activityText || "-")}
Худший показатель${ui(worst?.activityText || "-")}
Недельный тренд${ui(signedPercent(row.trend))}

Что проверить: ${ui(row.check)}

`; } function renderOperatorDetailBands(report) { return ` ${renderDepartmentRanking(report)} ${renderAttentionBlock(report)}

Подразделения сегодня

${renderDepartmentTable(report)}

Аномалии

${renderAnomalies(report)}

Топ рисков

${renderTopRisks(report)}
${renderDepartmentHeatMap(report)} ${renderDepartmentLeaderCard(report)} `; } function incidentStatusFromCount(count) { const n = Number(count || 0); if (n === 0) return "OK"; if (n <= 3) return "WARN"; return "FAIL"; } function reportReadinessText(report) { const trend = report?.workforce?.trend_status || "daily_only"; if (trend === "monthly_ready") return "день / неделя / месяц"; if (trend === "weekly_ready") return "день / неделя"; return "день"; } function renderSectionItems(section, emptyText) { const items = Array.isArray(section?.items) ? section.items : []; if (items.length === 0) { return `

${escapeHtml(emptyText || "Данных для среза пока нет.")}

`; } return `
${items.slice(0, 12).map(item => `
${ui(item.label || "-")} ${ui(item.value || "")} ${escapeHtml(item.status || "INFO")}
`).join("")}
`; } function renderReportTypeCards() { const types = [ ["Ежедневный отчет", "Оперативный срез за сегодня", "OK"], ["Недельный отчет", "Динамика после накопления истории по дням", "INFO"], ["Месячный отчет", "Готов для управленческой аналитики после накопления истории", "INFO"], ["По подразделению", "Сравнение загрузки и просадок", "OK"], ["По сотруднику", "Разбор показателя с осторожной трактовкой", "WARN"], ["По инциденту", "Материалы для служебной проверки", "OK"], ["Акт пилота", "Итоги опытной эксплуатации", "OK"] ]; return `
${types.map(([title, text, status]) => `
${escapeHtml(status)}

${ui(title)}

${ui(text)}

`).join("")}
`; } 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], ["Сервисы", data.failed_units], ["Данные графиков", data.grafana_data] ]; return `
${sources.map(([name, source]) => `
${ui(name)} ${ui(source?.summary || source?.error || "нет данных")} ${ui(source?.status || (source?.ok ? "OK" : "FAIL"))}
`).join("")}
`; } function renderOperator(data, report, extras = {}) { report = periodReport(report); const incidents = Array.isArray(data.incidents) ? data.incidents : []; const meta = currentViewMeta(); return `

${ui(meta.heading)}

${ui(meta.description)}

${ui(meta.label)}
${renderPeriodBanner(report)} ${renderRoleViewSummary(meta)} ${renderOperatorRoleContent(currentViewMode(), data, report, { ...extras, incidents })} `; } function renderRoleViewSummary(meta) { return `

${ui(meta.heading)}

${ui(meta.description)}

${ui(meta.label)}
`; } function renderOperatorRoleContent(mode, data, report, extras = {}) { if (mode === "manager") return renderManagerView(report); if (mode === "security") return renderSecurityView(data, report, extras); if (mode === "forensics") return renderForensicsView(data, report, extras); if (mode === "admin") return renderOperationsView(data, report); if (mode === "operations") return renderOperationsView(data, report); return renderExecutiveView(report, extras.incidents || []); } function renderExecutiveView(report, incidents) { return ` ${renderRiskNarrative(report)} ${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия" })} ${renderExecutiveDashboard(report)} ${renderKpiExplain(report?.workforce_kpi_explain)} ${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })} ${renderBusinessRisk(report?.business_risk)} ${renderRiskHeatmap(report?.risk_heatmap)} ${renderOverviewAnalytics(report)} `; } function renderSecurityView(data, report, extras = {}) { const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : []; return ` ${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия ИБ", security: true })} ${renderSecurityEventsSummary(report?.security_events_summary)} ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} ${renderSecurityCorrelation(report?.security_correlation)} ${renderCases(cases)} ${renderIncidentReviewAuditSummary(report)} ${renderInvestigationPacks(report?.risk_incident_candidates)}

Расследования

ручная проверка, решения и материалы
${renderDlpIncidentsList(extras.incidents || data.incidents || [])}
`; } function renderManagerView(report) { return ` ${renderExecutiveDashboard(report)} ${renderKpiExplain(report?.workforce_kpi_explain)} ${renderDepartmentRanking(report)} ${renderDepartmentHeatMap(report)} ${renderOverviewAnalytics(report)}

Markdown-отчет

экспорт управленческого среза Workforce
${ui((report?.markdown || "").slice(0, 2000))}
`; } function renderForensicsView(data, report, extras = {}) { const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : []; return ` ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} ${renderCases(cases)} ${renderInvestigationTimeline(report)} ${renderInvestigationPacks(report?.risk_incident_candidates)} ${renderIncidentReviewAuditSummary(report)} `; } function renderOperationsView(data, report) { return ` ${renderAgentCoverageSla(report?.agent_coverage_sla)} ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} ${renderSecurityEventsSummary(report?.security_events_summary, { operations: true })} ${renderOperationsErrors(data, report)}

Телеметрия

свежесть, стабильность и источники данных
${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)} ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} ${renderSourceList(data)}
`; } function renderOperationsErrors(data, report) { const rows = []; const collectorError = report?.agent_quality?.collector_error; if (collectorError) { rows.push(["Ошибка коллектора", collectorError, report?.agent_quality?.quality_status || "DEGRADED"]); } if (report?.security_events_summary?.fallback_used) { rows.push([ "События безопасности", report.security_events_summary.error || "Источник событий недоступен", "WARN", ]); } const problemNodes = Array.isArray(report?.agent_coverage_sla?.problem_nodes) ? report.agent_coverage_sla.problem_nodes : []; for (const node of problemNodes.slice(0, 8)) { rows.push([ node.hostname || "unknown", `${node.status || "UNKNOWN"} · ${node.recommendation || "Проверить рабочее место"}`, node.status || "WARNING", ]); } for (const [name, source] of Object.entries(data || {})) { if (!source || typeof source !== "object") continue; const status = source.status || (source.ok === false ? "FAIL" : ""); if (!status || ["OK", "READY", "INFO", "DISABLED"].includes(String(status).toUpperCase())) continue; rows.push([label(name), source.summary || source.error || "требуется проверка", status]); } return `

Ошибки

Что мешает достоверному сбору и эксплуатационной готовности.

${rows.length}
${rows.length ? rows.map(([name, text, status]) => `
${ui(name)} ${ui(text)} ${ui(status)}
`).join("") : `
Критичных ошибок нет Портал не видит ошибок коллектора или проблемных рабочих мест в текущем срезе. в норме
`}
`; } function renderIncidentReviewAuditSummary(report) { const summary = report?.incident_review_audit_summary || {}; const rows = [ ["Всего изменений", summary.total_changes ?? 0], ["Подтверждено", summary.confirmed_count ?? 0], ["Ложные срабатывания", summary.false_positive_count ?? 0], ["Отложено", summary.postponed_count ?? 0], ["Последнее изменение", summary.last_change_utc || "-"], ]; return `

Аудит

Кто и когда менял статус записей, требующих проверки.

${ui(summary.total_changes ?? 0)}
${rows.map(([name, value]) => `
${ui(name)}${ui(value)}
`).join("")}
`; } function renderInvestigationPacks(candidates) { const rows = Array.isArray(candidates) ? candidates.slice(0, 10) : []; return `

Материалы расследования

Выгружаемые пакеты материалов по каждой записи для ручной проверки.

${rows.length}
${rows.length ? rows.map(item => `
${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")} Скачать
`).join("") : `
Пакетов нет Нет записей, требующих выгрузки материалов. в норме
`}
`; } function renderInvestigationTimeline(report) { const investigations = Array.isArray(report?.forensics?.investigations) ? report.forensics.investigations : []; const timelines = investigations.flatMap(item => Array.isArray(item.timeline) ? item.timeline.map(event => ({ ...event, investigation_id: item.investigation_id })) : []); return `

Timeline событий

Связка user / host / app / network event для ручного расследования.

${timelines.length}
${timelines.length ? timelines.slice(0, 20).map(event => `
${ui(event.timestamp || "-")} ${ui(event.kind || "event")} · ${ui(event.entity || "-")} · ${ui(event.summary || "")} ${ui(event.source || "portal")}
`).join("") : `
Timeline пуст Нет кандидатов для расследования в текущем срезе. в норме
`}
`; } function renderOverviewAnalytics(report) { const workforce = findSection(report, "Работа"); const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce"); const security = findSection(report, "ИБ"); const actions = findSection(report, "Действия"); return `

Рабочая активность сотрудников

загрузка, простои, перегруз и дисциплина процессов
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")} ${renderSectionItems(insights, "Отклонений по работе сотрудников пока не найдено.")}

Контроль безопасности

подозрительные события и приоритет реакции
${renderSectionItems(security, "ИБ-событий для реакции пока нет.")}

Что требует внимания

операторские действия и открытые вопросы
${renderSectionItems(actions, "Рекомендаций нет.")}
`; } function renderManager(data, policyExplain, kpiExplain) { const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds); return `

Рабочая активность сотрудников

Оценка загрузки, простоев, перегруза и использования рабочих приложений.

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

Индекс активности

${escapeHtml(workforceIndex)}

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

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

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

RDP / 1C / рабочие приложения

${(data.applications || []).slice(0, 8).map(app => `
${escapeHtml(app.application)} ${escapeHtml(app.proved_work_human || "")} ${escapeHtml(app.evidence_events || 0)}
`).join("")}
${renderKpiExplain(kpiExplain)} ${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 renderDepartments(report) { report = periodReport(report); const departments = report?.workforce?.department_comparison || []; const owners = report?.workforce?.owner_comparison || []; const insights = report?.workforce?.insights || []; return `

Подразделения

Сравнение активности, тренда, риска и ответственных по группам.

${ui(report?.severity || "INFO")}
${renderPeriodBanner(report)}

Рейтинг подразделений

${renderSimpleItems(departments, "Подразделения пока не рассчитаны.")}

Ответственные

${renderSimpleItems(owners, "Ответственные пока не рассчитаны.")}

Просадки и отклонения

что требует управленческого внимания
${renderSimpleItems(insights, "Существенных отклонений по подразделениям пока нет.")}
`; } function renderEmployees(data, policyExplain, kpiExplain) { const employees = Array.isArray(policyExplain?.employee_details) ? policyExplain.employee_details : []; const users = Array.isArray(data.users) ? data.users : []; const selected = users.slice(0, 12); return `

Карточки сотрудников

Рабочий день, приложения, активность, последнее событие и риск-сигналы.

${escapeHtml(data.users_count || 0)} сотрудников
${renderDailyDetailNotice()}
${selected.map(user => renderEmployeeCard(user, employees)).join("") || `

Сотрудники пока не найдены.

`}
${renderKpiExplain(kpiExplain)} ${renderWorkforceIndexExplanation(policyExplain)} `; } function renderEmployeeCard(user, employeeDetails) { const detail = employeeDetails.find(item => item.user === user.user) || {}; return `

${ui(user.user || "Сотрудник")}

Последняя активность: ${ui(user.last_activity || "-")}

${ui(workforceIndexTextFromValue(detail.index))}
Активность${ui(user.active_hhmm || "00:00")}
Сессии${escapeHtml(user.sessions_count || 0)}
План${escapeHtml(humanSeconds(detail.planned_seconds))}

${ui(detail.reason || "Персональный разбор по приложениям будет точнее после накопления данных по ролям.")}

`; } function renderSimpleItems(items, emptyText) { if (!Array.isArray(items) || items.length === 0) return `

${ui(emptyText)}

`; return `
${items.slice(0, 16).map(item => `
${ui(item.label || item.title || "-")} ${ui(item.value || item.subject || "")} ${ui(item.status || "INFO")}
`).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 renderKpiExplain(explain) { if (!explain || explain.ok === false) return ""; const coverage = explain.coverage || {}; const factors = Array.isArray(explain.factors) ? explain.factors : []; const positive = factors.filter(item => String(item.impact || "").startsWith("+")); const negative = factors.filter(item => String(item.impact || "").startsWith("-")); const apps = Array.isArray(explain.top_applications) ? explain.top_applications.slice(0, 6) : []; const warnings = Array.isArray(explain.warnings) ? explain.warnings : []; const recommendations = Array.isArray(explain.recommendations) ? explain.recommendations : []; return `

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

Детерминированное объяснение: активность, рабочие приложения, простой, полнота и свежесть данных.

confidence ${ui(explain.confidence || "low")}
KPI${ui(explain.kpi_score ?? 0)}%
Покрытие${ui(coverage.agent_coverage_percent ?? 0)}%
Свежесть${ui(coverage.data_freshness || "missing")}
Пропуски${ui((coverage.missing_sources || []).length)}

Что повышает индекс

${renderKpiFactorRows(positive, "Положительных факторов нет.")}

Что снижает индекс

${renderKpiFactorRows(negative, "Отрицательных факторов нет.")}
${apps.length ? `
${apps.map(app => `
${ui(app.name || "Приложение")} ${ui(app.category || "other")} · ${ui(app.active_minutes ?? 0)} мин. ${ui(app.contribution || "neutral")}
`).join("")}
` : ""} ${renderKpiNotes("Предупреждения", warnings)} ${renderKpiNotes("Рекомендации", recommendations)}
`; } function confidenceStatusClass(confidence) { const value = String(confidence || "low").toLowerCase(); if (value === "high") return "status-ok"; if (value === "medium") return "status-warn"; return "status-degraded"; } function renderKpiFactorRows(items, emptyText) { if (!items.length) { return `
Нет${ui(emptyText)}
`; } return items.map(item => `
${ui(item.label || item.name || "Фактор")} ${ui(item.explanation || "")} ${ui(item.impact || "0")}
`).join(""); } function renderKpiNotes(title, items) { if (!items.length) return ""; return `
${ui(title)} ${items.map(item => ui(item)).join(" · ")}
`; } function renderWorkforceIndexExplanation(policy) { if (!policy || !policy.configured) { return `

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

Правила ролей и приложений не настроены. Доступен только нейтральный индекс активности.

`; } 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 ? `
Нет приложенийНет разбора по приложениям для взвешенного показателя
` : 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))} · правило ${ui(item.matched_rule || "нет явного правила")} ${escapeHtml(humanSeconds(item.weighted_seconds))} · ${contribution}%
`; }).join(""); return `

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

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

Формула: индекс = время с учетом правил / плановое время × 100.

${escapeHtml(workforceIndexTextFromValue(policy.index))}
Роль${escapeHtml(policy.role_label || policy.role || "-")}
План${escapeHtml(humanSeconds(policy.planned_seconds))}
Время приложений${escapeHtml(humanSeconds(policy.app_seconds))}
С учетом правил${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 `
Проверка правилКлючевые приложения попали под явные правила или данных для проверки нет.
`; } return `

Проверка правил: приложения без явного правила

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

${items.map(item => `
${escapeHtml(item.application || "-")} ${escapeHtml(humanSeconds(item.seconds))} · вес по умолчанию ${escapeHtml(pctText(item.weight))} проверить
`).join("")}
`; } function renderEmployeeIndexDetails(items) { if (!items.length) return ""; return `

Разбор по сотрудникам

Это не персональный взвешенный показатель: индекс сотрудника сейчас считается по активному времени; разбор по весам приложений доступен только на уровне общего среза.

${items.map(item => `
${escapeHtml(item.user || "-")} ${ui(item.reason || `индекс сотрудника = активное время / плановое время × 100 · активно ${humanSeconds(item.active_seconds)} / план ${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 `

Контроль безопасности

Приоритеты ИБ: подозрительные подключения, сигналы по данным, RDP-аномалии и состояние периметра.

${escapeHtml(data.summary?.severity || "INFO")}
${cards.map(([name, block]) => `
${escapeHtml(block.status)}

${ui(label(name))}

${ui(block.text)}

`).join("")}

Риски с приоритетом

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

Графики безопасности

${renderLinks(data.links)}
`; } function renderPerimeter(data, report) { report = periodReport(report); const risk = report?.ueba_risk || {}; const cards = Object.entries(data.cards || {}); return `

Сетевой периметр

Режим наблюдения: состояние внешних сигналов, необычные направления и связь с рисками сотрудников. Управление сетью отсюда не выполняется.

наблюдение
${renderPeriodBanner(report)}
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")} ${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")} ${metricCard("Связь с рисками", `${risk.score ?? 0}/100`, risk.status || "UNKNOWN", risk.summary || "оценка по правилам")} ${metricCard("Автоматическое воздействие", "выключено", "OK", "портал работает только на чтение")}

Состояние доступных сигналов

без изменения правил периметра
${renderSimpleItems(cards.map(([name, block]) => ({ label: label(name), value: block.text, status: block.status })), "Сигналы периметра пока не подключены.")}
`; } function renderIncidentsList(items) { if (!items || items.length === 0) return `

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

`; return `
${items.map(item => `
${ui(item.source)}
${ui(item.kind)} · ${escapeHtml(item.id)}
${ui(item.summary)} ${item.acknowledged ? `
В работе: ${ui(item.assigned_to || item.actor || "оператор")} · ${ui(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 `

Активных ИБ-инцидентов по данным нет.

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

Материалы проверки загружаются.

`; if (!evidence.ok) return `

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

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

Материалы по ИБ-инцидентам пока не найдены.

`; 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 ? "Контрольный хеш: " + escapeHtml(item.screenshot_sha256) : ui(item.blocked_reason || "без скрина")}
${item.screenshot_available ? "скриншот" : "метаданные"}
${item.preview_url ? `Открыть` : ""} ${item.download_url ? `Скачать` : ""}
`).join("")}
`; } function buildAutoInvestigation(report) { report = periodReport(report || state.reports || {}); const rows = departmentRows(report); const risky = [...rows].sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))[0]; const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : []; const risk = reasons[0] || {}; const status = risky?.status || risk.status || report?.ueba_risk?.status || "INFO"; const department = risky?.label || "Портфель"; const owner = risky?.responsible || "ответственный не назначен"; const generated = report?.generated_at_utc || new Date().toISOString(); const summary = risk.label || risk.code || risky?.reason || "Система сформировала риск-сигнал для ручной проверки"; return { incident_id: `auto-${String(department).toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-").replace(/^-|-$/g, "") || "risk"}`, risk_id: risk.code || risk.label || "risk-check", department, owner, activity_index: risky?.activityText || "нет данных", deviation: risky?.deviation || "нет данных", status, summary, why_it_is_risk: risky?.reason || risk.value || "риск может указывать на просадку активности, отклонение от нормы или событие безопасности", what_to_check: risky?.check || risk.recommendation || "проверить первичные события ActivityWatch, удаленный доступ, активность в 1С, процессы и сетевые сигналы", recommended_actions: [ "назначить ответственного за ручную проверку", "сопоставить риск с журналами активности и бизнес-задачей", "зафиксировать вывод в отчете по инциденту" ], evidence: [ "удаленные сеансы: проверяются по событиям рабочего времени", "процессы: проверяются по приложениям", "сеть: проверяется по сетевым сигналам", "внешний шлюз: подключается как дополнительный источник", "периметр: учитывается при наличии интеграционного слоя" ], generated_at: generated }; } function renderAutoInvestigationCard(report) { const card = buildAutoInvestigation(report); return `

Автоматическая карточка расследования

Расследование сформировано автоматически. Решение принимает ответственный сотрудник.

${ui(card.status)}
Номер${ui(card.incident_id)}
Риск${ui(card.risk_id)}
Подразделение${ui(card.department)}
Ответственный${ui(card.owner)}
Индекс активности${ui(card.activity_index)}
Отклонение${ui(card.deviation)}
Сформировано${ui(card.generated_at)}
Краткое описание${ui(card.summary)}
Почему это риск${ui(card.why_it_is_risk)}
Что проверить${ui(card.what_to_check)}
Рекомендуемые действия${ui(card.recommended_actions.join("; "))}
Материалы расследования${ui(card.evidence.join("; "))}
`; } function renderIncidents(data) { const links = state.links || {}; const incidents = Array.isArray(data) ? data : data.incidents; const evidence = Array.isArray(data) ? null : data.evidence; const reports = Array.isArray(data) ? state.reports : data.reports; const cases = Array.isArray(data?.cases?.cases) ? data.cases.cases : []; return `

Расследования и доказательная база

Кто, когда, откуда, куда, связанные хосты, материалы и выгрузка для проверки.

${escapeHtml((incidents || []).length)} записей

Карточки инцидентов

${renderDlpIncidentsList(incidents)}

Связанные графики

${renderDlpLinks(links)}
${renderAutoInvestigationCard(reports)} ${renderCases(cases)}

Материалы: скриншоты, хеши, файлы

${renderDlpEvidence(evidence)}
`; } function renderCases(cases) { const rows = Array.isArray(cases) ? cases.slice(0, 20) : []; return `

Дела

Ручные дела, созданные из подтвержденных кандидатов. Автоматически дела не создаются.

${rows.length}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Дело Кандидат Статус Ответственный Решение Действие
${ui(item.title || item.case_id)}
${ui(item.case_id || "-")}
${ui(item.created_at_utc || "-")} · ${ui(item.updated_at_utc || "-")}
${ui(item.candidate_id || "-")}
${ui(item.summary || "резюме не задано")}
${ui(caseStatusText(item.status))} ${ui(item.owner || "ответственный не указан")} ${ui(item.decision || "решение не зафиксировано")} ${renderCaseActions(item)}
Нет дел - в норме - Создайте дело из подтвержденного кандидата. -
`; } function renderCaseActions(item) { const id = item?.case_id || ""; const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown&role=forensics`; return `
Скачать карточку дела `; } function caseStatusText(status) { const value = String(status || "OPEN").toUpperCase(); if (value === "IN_PROGRESS") return "В работе"; if (value === "RESOLVED") return "Решено"; if (value === "REJECTED") return "Отклонено"; if (value === "ARCHIVED") return "Архив"; return "Открыто"; } function renderKpiCards(items) { return `
${(items || []).map(item => `
${escapeHtml(item.status || "INFO")}

${ui(item.label)}

${ui(item.value)}

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

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

${ui(section.title)}

${(section.items || []).map(item => `
${ui(item.label)} ${ui(item.value)} ${escapeHtml(item.status || "INFO")}
`).join("")}
`).join("")}
`; } function renderUebaRisk(risk) { if (!risk) return ""; const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : []; const sources = Array.isArray(risk.risk_sources) ? risk.risk_sources.join(", ") : "-"; const confidence = Number.isFinite(Number(risk.confidence)) ? `${Math.round(Number(risk.confidence) * 100)}%` : "0%"; const confidenceLevel = risk.confidence_level || "unknown"; const classification = risk.classification || "insufficient_data"; const evidenceStatus = risk.evidence_status || "not_available"; const baselineReady = `пользователь: ${risk.user_baseline_available ? "да" : "нет"} · подразделение: ${risk.department_baseline_available ? "да" : "нет"}`; return `

Индекс риска

${ui(risk.note || "Оценка риска без автоматического воздействия.")}

Формула: сумма факторов риска, максимум 100.

Достоверность: ${escapeHtml(confidence)} · источники: ${ui(sources)} · обычный профиль: ${ui(risk.baseline_status || "-")} · версия правил: ${ui(risk.policy_version || "-")}

Окно сравнения: ${escapeHtml(risk.baseline_window_days || "-")} дн. · доступно: ${ui(baselineReady)} · отклонение: ${escapeHtml(risk.deviation_score ?? 0)}

${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100
Уровень${ui(risk.level || "unknown")}
Уверенность${ui(confidenceLevel)}
Классификация${ui(classification)}
Материалы${ui(evidenceStatus === "available" ? "доступны" : "нет")}
${(Array.isArray(risk.confidence_reasons) && risk.confidence_reasons.length) ? `

Причины уверенности: ${risk.confidence_reasons.slice(0, 4).map(ui).join(" · ")}

` : ""}
${reasons.length ? reasons.map(item => `
${ui(item.label || item.code || "-")} ${ui(item.value || "")} · ${ui(item.recommendation || "")} +${escapeHtml(item.points || 0)}
`).join("") : `
СигналыСущественных риск-сигналов в текущем срезе нет.в норме
`}
`; } function fallbackAgentQualityExplain(quality) { const q = quality || {}; const source = q.collector_source || "unknown"; const hasError = Boolean(q.collector_error); if (source === "wts_api" && !hasError) { return { status: "OK", title: "Данные агента подтверждают показатели", summary: "Сессии собраны основным способом Windows; индекс активности можно использовать как рабочий управленческий показатель.", recommendation: "Использовать отчет как подтвержденный оперативный срез.", kpi_accepted: true }; } if (source === "local_fallback") { return { status: "DEGRADED", title: "Диагностический режим агента", summary: "Диагностический режим, данные не засчитываются в показатели активности.", recommendation: "Проверить доступность WTS API и права запуска агента.", kpi_accepted: false }; } if (hasError) { return { status: "DEGRADED", title: "Достоверность данных снижена", summary: `Коллектор передал ошибку: ${q.collector_error}`, recommendation: "Восстановить основной способ сбора Windows перед использованием отчета как доказательной базы.", kpi_accepted: false }; } return { status: "UNKNOWN", title: "Достоверность данных неизвестна", summary: "Агент не передал диагностику качества данных.", recommendation: "Обновить Rust agent до версии с diagnostics и проверить telemetry.jsonl.", kpi_accepted: false }; } function renderAgentQuality(quality, explain) { const q = quality || {}; const e = explain || fallbackAgentQualityExplain(q); const status = e.status || q.quality_status || "UNKNOWN"; const source = q.collector_source || "unknown"; const warn = ["warning", "fallback", "degraded", "error"].includes(String(status).toLowerCase()); const accepted = Boolean(e.kpi_accepted); return `

Качество данных

${ui(e.title || "Оценка доверия к данным агента")}

${ui(status)}

${ui(e.summary || "")}

Участвует в показателях${accepted ? "да" : "нет"}
Источник${ui(source)}
${warn ? `
Внимание. Данные активности собраны не основным способом. Точность определения активности и удаленных сеансов может быть снижена.
` : ""}

${ui(e.recommendation || "")}

Технические детали
Источник коллектора${ui(source)}
Всего сессий${escapeHtml(q.sessions_collected_total ?? 0)}
Активных сессий${escapeHtml(q.active_sessions_total ?? 0)}
Удаленных сеансов${escapeHtml(q.rdp_sessions_total ?? 0)}
Ошибка коллектора${q.collector_error ? ui(q.collector_error) : "нет"}
`; } function renderAgentQualityHistory(history, summary) { const items = Array.isArray(history) ? history : []; const s = summary || {}; const unstableDays = Number(s.warning_days || 0) + Number(s.degraded_days || 0) + Number(s.unknown_days || 0); const status = items.length === 0 ? "UNKNOWN" : (Number(s.ok_days || 0) >= 5 ? "OK" : "WARNING"); return `

Стабильность агента за 7 дней

Показывает, можно ли доверять недельным показателям, а не только текущему срезу.

${ui(status)}
Дней в норме${escapeHtml(s.ok_days ?? 0)}
Проблемных дней${escapeHtml(unstableDays)}
Показатели подтверждены${escapeHtml(s.kpi_accepted_pct ?? 0)}%
${Number(s.ok_days || 0) < 5 ? `
Показатели требуют проверки: нестабильный сбор данных агента.
` : ""}
История по дням
${items.length ? items.map(item => `
${escapeHtml(item.date || "-")} источник=${ui(item.source || "unknown")} · показатели=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""} ${ui(item.status || "UNKNOWN")}
`).join("") : `
ИсторияИстория качества агента за период отсутствует.нет данных
`}
`; } function renderAgentQualityNodes(nodes, summary) { const items = Array.isArray(nodes) ? nodes : []; const s = summary || {}; const problematic = items.filter(item => String(item.status || "UNKNOWN") !== "OK" || !item.kpi_accepted); const rows = (problematic.length ? problematic : items).slice(0, 10); const status = Number(s.total_nodes || 0) === 0 ? "UNKNOWN" : (Number(s.accepted_kpi_nodes_pct || 0) >= 80 ? "OK" : "WARNING"); return `

Качество данных по рабочим местам

Какие рабочие места подтверждают показатели, а какие снижают доверие к управленческой аналитике.

${ui(status)}
Всего узлов${escapeHtml(s.total_nodes ?? 0)}
В норме${escapeHtml(s.ok_nodes ?? 0)}
Проблемных${escapeHtml(Number(s.degraded_nodes || 0) + Number(s.unknown_nodes || 0))}
Показатели подтверждены${escapeHtml(s.accepted_kpi_nodes_pct ?? 0)}%
${Number(s.total_nodes || 0) > 0 && Number(s.accepted_kpi_nodes_pct || 0) < 80 ? `
Показатели требуют проверки: менее 80% рабочих мест дают подтвержденные данные.
` : ""}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Узел Статус Источник Последняя телеметрия Показатели Рекомендация
${ui(item.hostname || "unknown")} ${ui(item.status || "UNKNOWN")} ${ui(item.source || "unknown")} ${escapeHtml(item.last_seen_utc || "-")} ${item.kpi_accepted ? "да" : "нет"} ${ui(item.recommendation || "")}
Нет данных нет данных unknown - нет История качества по рабочим местам отсутствует.
${problematic.length > 10 ? `

Показаны первые 10 проблемных узлов из ${escapeHtml(problematic.length)}.

` : ""}
`; } function renderAgentCoverageSla(sla) { const s = sla || {}; const rows = Array.isArray(s.problem_nodes) ? s.problem_nodes.slice(0, 10) : []; const status = s.sla_status || "UNKNOWN"; return `

Полнота данных

Показывает, насколько показатели репрезентативны по всему парку рабочих мест.

${ui(status)}
Ожидается узлов${escapeHtml(s.expected_nodes ?? 0)}
Данные за 24 часа${escapeHtml(s.reporting_nodes_24h ?? 0)}
Устаревшие${escapeHtml(s.stale_nodes ?? 0)}
Отсутствующие${escapeHtml(s.missing_nodes ?? 0)}
Покрытие${escapeHtml(s.coverage_pct ?? 0)}%
Свежесть${escapeHtml(s.freshness_pct ?? 0)}%
${status === "CRITICAL" ? `
Полнота данных критически недостаточна: показатели нельзя считать репрезентативными.
` : ""} ${status === "WARNING" ? `
Показатели требуют проверки: часть рабочих мест не присылает свежую телеметрию.
` : ""} ${status === "UNKNOWN" ? `

Список ожидаемых рабочих мест не настроен.

` : ""}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Узел Подразделение Ответственный Последняя телеметрия Статус Рекомендация
${ui(item.hostname || "unknown")} ${ui(item.department || "-")} ${ui(item.owner || "-")} ${escapeHtml(item.last_seen_utc || "-")} ${ui(item.status || "UNKNOWN")} ${ui(item.recommendation || "")}
${Number(s.expected_nodes || 0) === 0 ? "Нет списка" : "Проблем нет"} - - - ${ui(status)} ${Number(s.expected_nodes || 0) === 0 ? "Настроить expected_nodes.json." : "Действий не требуется."}
${Array.isArray(s.problem_nodes) && s.problem_nodes.length > 10 ? `

Показаны первые 10 проблемных узлов из ${escapeHtml(s.problem_nodes.length)}.

` : ""}
`; } function renderSecurityEventsSummary(summary, options = {}) { const s = summary || {}; const backend = s.backend || "disabled"; const disabled = backend === "disabled" || s.status === "disabled" || !summary; const fallback = Boolean(s.fallback_used); const status = disabled ? "UNKNOWN" : fallback ? "WARN" : Number(s.events_24h || 0) > 0 ? "WARN" : "OK"; const stateText = disabled ? "Источник событий безопасности отключён" : fallback ? "События безопасности временно недоступны" : "События безопасности доступны"; const localModeText = disabled ? "Локальный режим" : ""; const title = options.compact ? "События безопасности" : "События безопасности за 24 часа"; const compactStateText = stateText; const compactModeText = disabled && options.compact ? "Без ИБ-детализации" : disabled ? localModeText : "Агрегированная сводка"; const subtitle = disabled ? `${stateText}. ${localModeText}.` : fallback ? "Портал работает в резервном режиме без событий безопасности." : "Агрегированная сводка без сырых журналов и без автоматического создания инцидентов."; const top = Array.isArray(s.top_departments) ? s.top_departments.slice(0, 5) : []; const warning = fallback ? `
События безопасности временно недоступны. Проверьте подключение источника событий.
` : ""; if (options.compact) { return `

${ui(title)}

${ui(compactStateText)}

${ui(status)}
Состояние${ui(compactStateText)}
Событий за 24 часа${ui(s.events_24h ?? 0)}
Режим${ui(compactModeText)}
`; } return `

${ui(title)}

${ui(subtitle)}

${ui(status)}
Состояние${ui(stateText)}
Источник${ui(backend)}
Событий${ui(s.events_24h ?? 0)}
Неуспешные входы${ui(s.failed_logins_24h ?? 0)}
Подозрительные входы${ui(s.suspicious_logins_24h ?? 0)}
RDP-сессии${ui(s.rdp_sessions_24h ?? 0)}
Ошибки агентов${ui(s.agent_errors_24h ?? 0)}
${warning} ${s.error ? `

Причина: ${ui(s.error)}

` : ""} ${disabled ? `

Источник событий безопасности не включен в текущем режиме.

` : ""} ${options.compact ? "" : `
${top.length ? top.map(item => ` `).join("") : ` `}
Подразделение События
${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(item.events ?? 0)}
${disabled ? "Источник отключён" : "Нет данных"} 0

${ui(DEFAULT_DEPARTMENT_LABEL)} — событие есть в источнике, но в нем не передана связь с оргструктурой. Последнее событие: ${ui(s.last_event_utc || "нет данных")} · запрос ${ui(s.query_ms ?? 0)} ms.

`}
`; } function renderBusinessRisk(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return `

Риски подразделений

Организационные зоны риска по достоверности показателей, активности, тренду и проблемным рабочим местам.

${ui(worst)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Подразделение Риск Причины События Рекомендация
${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(item.risk_level || "UNKNOWN")} ${ui(businessRiskReasons(item))} ${ui(item.security_events_24h ?? 0)} ${ui(item.recommendation || "Проверить первичные данные подразделения.")}
Нет данных нет данных нет данных 0 Дождаться расчета подразделений.
`; } function renderRiskHeatmap(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.heat_level || "UNKNOWN"; return `

Карта рисков

Где одновременно снижена достоверность показателей, низкая активность, неполные данные, записи на проверку и активные расследования.

${ui(worst)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Подразделение Достоверность Активность Покрытие Риск События Расследования Связи
${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(riskPercentText(item.trust_kpi_score))} ${ui(riskPercentText(item.activity_score))} ${ui(riskPercentText(item.agent_coverage_pct))} ${ui(item.heat_level || "UNKNOWN")}
${ui(item.business_risk_level || "UNKNOWN")} · проверить ${ui(item.critical_candidates ?? 0)}
${ui(item.security_events_24h ?? 0)} ${ui(item.open_cases ?? 0)} ${renderRiskLayerLinks(item.links)}
Нет данных нет данных нет данных нет данных нет данных 0 0 -
`; } function renderRiskLayerLinks(links) { const items = Array.isArray(links) ? links : []; if (!items.length) return "-"; return `
${items.map(link => { const target = riskLayerTarget(link.target); return ``; }).join("")}
`; } function riskLayerTarget(target) { const value = String(target || ""); if (value === "risk_heatmap") return { tab: "operator", selector: ".risk-heatmap-card" }; if (value === "business_risk") return { tab: "operator", selector: "#business-risk-section" }; if (value === "security_correlation") return { tab: "operator", selector: ".security-correlation-card" }; if (value === "security_events") return { tab: "operator", selector: ".security-events-card" }; if (value === "incident_candidates") return { tab: "operator", selector: "#risk-candidates-section" }; if (value === "cases") return { tab: "incidents", selector: "#cases-section" }; if (value === "agent_coverage") return { tab: "operator", selector: "#agent-coverage-section" }; return { tab: "operator", selector: "#trust-kpi-section" }; } function riskPercentText(value) { const number = Number(value); return Number.isFinite(number) ? `${Math.round(number)}%` : "UNKNOWN"; } function renderSecurityCorrelation(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const score = Number(rows[0]?.correlation_score || 0); const status = score >= 80 ? "CRITICAL" : score >= 60 ? "HIGH" : score >= 35 ? "MEDIUM" : rows.length ? "LOW" : "UNKNOWN"; return `

Связь рисков и активности

Связь между падением активности, достоверностью показателей, записями на проверку и активными расследованиями. Инциденты автоматически не создаются.

${ui(status)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Подразделение Достоверность Активность Риск подразделения Проверки События Взаимосвязь Причина
${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(riskPercentText(item.trust_kpi_score))} ${ui(riskPercentText(item.activity_score))} ${ui(item.business_risk_level || "UNKNOWN")} проверить ${ui(item.critical_candidates ?? 0)} · расследования ${ui(item.open_cases ?? 0)} ${ui(item.security_events_24h ?? 0)} ${ui(Number(item.correlation_score || 0))}/100 ${ui(item.explanation || item.correlation_reason || "связь не выражена")}
Нет данных нет данных нет данных нет данных проверить 0 · расследования 0 0 0/100 недостаточно данных по подразделениям
`; } function businessRiskReasons(item) { const reasons = Array.isArray(item?.reasons) && item.reasons.length ? item.reasons.join("; ") : "существенных причин не найдено"; return `${reasons}. Доверие ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных рабочих мест ${item?.problem_nodes_count ?? 0}, событий безопасности за 24 часа ${item?.security_events_24h ?? 0}`; } function renderBusinessRiskTimeline(history, summary) { const s = summary || {}; const rows = Array.isArray(history) ? history.slice(-10).reverse() : []; const status = Number(s.stable_high_risk || 0) > 0 || Number(s.new_high_risk || 0) > 0 ? "WARN" : "OK"; const counters = [ ["Ухудшились", s.departments_worsened ?? 0, "рост уровня риска за период"], ["Улучшились", s.departments_improved ?? 0, "снижение уровня риска"], ["Стабильно высокий риск", s.stable_high_risk ?? 0, "3+ дня HIGH/CRITICAL"], ["Новый высокий риск", s.new_high_risk ?? 0, "последняя точка стала HIGH/CRITICAL"], ]; return `

Динамика рисков

Как менялись риски подразделений по накопленной ежедневной истории.

${ui(status)}
${counters.map(([label, value, hint]) => `
${ui(label)} ${escapeHtml(value)} ${ui(hint)}
`).join("")}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Дата Подразделение Риск Причины
${ui(item.date || "-")} ${ui(item.department || DEFAULT_DEPARTMENT_LABEL)} ${ui(item.risk_level || "UNKNOWN")} ${ui(Array.isArray(item.reasons) && item.reasons.length ? item.reasons.join("; ") : "нет существенных причин")}
- История не накоплена нет данных Нужно дождаться daily history.
`; } function renderRiskIncidentCandidates(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return `

Требует проверки

Очередь ручной проверки. Реальные инциденты автоматически не создаются.

${ui(worst)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Номер Подразделение Узел Риск Проверка Причина Рекомендация Действие
${ui(item.id || "-")} ${ui(item.department || "-")}
${ui(item.owner || "ответственный не указан")}
${ui(item.hostname || "-")}
${ui(candidateSeenText(item))}
${ui(item.risk_level || "UNKNOWN")} ${renderCandidateReview(item)} ${ui(candidateReasonText(item))} ${ui(item.recommendation || "Назначить ответственную ручную проверку.")} ${renderCandidateReviewActions(item)}
- Нет кандидатов - в норме NEW очередь проверки пуста Действий не требуется. -
${rows.length ? `

Показаны записи для проверки, а не автоматически подтвержденные инциденты.

` : ""}
`; } function renderCandidateReview(item) { const review = item?.incident_review || {}; const status = review.status || "NEW"; const comment = review.comment || "комментария нет"; const reviewer = review.reviewer || "проверяющий не указан"; const updated = review.updated_at || "не обновлялось"; const audit = Array.isArray(item?.incident_review_audit) ? item.incident_review_audit.slice(-4).reverse() : []; const history = audit.length ? `
История изменений
` : `История изменений отсутствует`; return ` ${ui(reviewStatusText(status))}
Изменил: ${ui(reviewer)}
Когда: ${ui(updated)}
Комментарий: ${ui(comment)} ${history} `; } function renderCandidateReviewActions(item) { const id = item?.id || ""; const reviewStatus = String(item?.incident_review?.status || "NEW").toUpperCase(); const actions = [ ["IN_REVIEW", "В проверку"], ["CONFIRMED", "Подтвердить"], ["FALSE_POSITIVE", "Ложный"], ["POSTPONED", "Отложить"], ]; const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown&role=forensics`; const createCase = reviewStatus === "CONFIRMED" ? `` : ""; return `
${actions.map(([status, label]) => ` `).join("")}
Скачать пакет расследования ${createCase} `; } function reviewStatusText(status) { const value = String(status || "NEW").toUpperCase(); if (value === "IN_REVIEW") return "В проверке"; if (value === "CONFIRMED") return "Подтвержден"; if (value === "FALSE_POSITIVE") return "Ложный"; if (value === "POSTPONED") return "Отложен"; return "Новый"; } function candidateReasonText(item) { const evidence = Array.isArray(item?.evidence) && item.evidence.length ? ` Материалы: ${item.evidence.join("; ")}` : ""; return `${item?.reason || "требуется проверка"}.${evidence}`; } function candidateSeenText(item) { const first = item?.first_seen_utc || "-"; const last = item?.last_seen_utc || "-"; return `первое: ${first}; последнее: ${last}`; } function businessTrendText(value) { const trend = String(value || "UNKNOWN").toUpperCase(); if (trend === "FALLING") return "падает"; if (trend === "RISING") return "растет"; if (trend === "STABLE") return "стабильно"; return "нет данных"; } function renderReports(data) { data = periodReport(data); return `

Отчеты

Ежедневные, недельные, месячные, по подразделению, сотруднику, инциденту и пилотной эксплуатации.

${escapeHtml(data.severity)}
${renderPeriodBanner(data)} ${renderRiskNarrative(data)} ${renderActionCenter(data.recommended_actions, { title: "Рекомендуемые действия" })} ${renderExecutiveDashboard(data)} ${renderReportTypeCards()}
${escapeHtml(data.severity)} ${data.anonymized ? `обезличено` : ""}

${ui(data.headline)}

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

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

${(data.executive_points || []).map(point => `
Итог${ui(point)}
`).join("")}
Скачать данные

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

${renderKpiCards(data.kpis)} ${renderKpiExplain(data.workforce_kpi_explain)} ${renderAgentQuality(data.agent_quality, data.agent_quality_explain)} ${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)} ${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)} ${renderAgentCoverageSla(data.agent_coverage_sla)} ${renderSecurityEventsSummary(data.security_events_summary)} ${renderRiskHeatmap(data.risk_heatmap)} ${renderSecurityCorrelation(data.security_correlation)} ${renderBusinessRisk(data.business_risk)} ${renderBusinessRiskTimeline(data.business_risk_history, data.business_risk_history_summary)} ${renderRiskIncidentCandidates(data.risk_incident_candidates)} ${renderUebaRisk(data.ueba_risk)} ${renderWorkforceIndexExplanation(data.workforce_policy)}

Срезы отчета

${renderReportSections(data.sections)}

Текст для отчета

${ui(data.markdown || "")}
`; } function basename(value) { const text = String(value || "").trim(); if (!text) return "-"; return text.split(/[\\/]/).filter(Boolean).pop() || text; } function settingRows(report) { const policy = report?.workforce_policy || {}; const risk = report?.ueba_risk || {}; const roles = Array.isArray(policy.available_roles) ? policy.available_roles : []; const activeRole = roles.find(item => item.role === policy.default_role || item.role === policy.role) || roles[0] || {}; const workdayHours = activeRole.planned_hours_per_day || (Number(policy.planned_seconds || 0) > 0 ? (Number(policy.planned_seconds) / 3600).toFixed(1) : 8); const period = periodConfig(); return [ ["Период расчета", period.label, period.key === "today" ? "оперативный дневной срез" : `${period.days} календарных дней`], ["Рабочий день", `${workdayHours} ч`, `роль: ${policy.role_label || activeRole.label || policy.default_role || "default"}`], ["Порог внимания", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"], ["Порог критического риска", ">= 70 баллов", "высокий риск требует приоритетного разбора"], ["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `правила оценки риска: ${basename(risk.policy_path)}` : "правила оценки риска: встроенная модель"], ["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`], ]; } function renderSettings(report) { const rows = settingRows(periodReport(report || state.reports || {})); return `

Настройки

Параметры расчета и границы интерпретации данных. Экран открыт только для чтения.

только чтение

Параметры расчета

${rows.map(([name, value, note]) => ` `).join("")}
ПараметрЗначениеКомментарий
${ui(name)}${ui(value)}${ui(note)}
`; } async function refresh(options = {}) { const content = document.getElementById("content"); const background = Boolean(options.background); const stage = options.stage || "Получение данных"; const progress = (status, label, value) => { if (!background) setLoadStatus(status, label, value); }; try { progress("LOADING", stage, 8); if (!background && content) content.innerHTML = renderLoadingContent(stage); if (!state.links) { progress("LOADING", "Получение данных", 18); state.links = await loadJson("/links"); } progress("LOADING", "Расчёт показателей", 34); const summary = await loadJson("/summary"); progress("LOADING", "Расчёт показателей", 46); state.readiness = { bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })), verify: state.readiness?.verify || null }; renderSummary(summary, state.readiness); progress("LOADING", "Формирование главного вывода", 68); const tabResult = await loadCurrentTab(); progress("LOADING", "Подготовка разделов", 88); if (!hasTabData(state.tab, tabResult)) { setLoadStatus("EMPTY", "Данные отсутствуют", 100); if (content) { content.innerHTML = `${renderEmptyState("Источники ответили, но полезные записи для текущего раздела пока не найдены.")}${tabResult.html || ""}`; } return; } if (content) content.innerHTML = tabResult.html; setLoadStatus("READY", "Данные готовы", 100); consumePendingScroll(); } catch (error) { showError(error); } } async function loadCurrentTab() { if (state.tab === "operator") { const data = await loadJson("/operator"); state.operatorData = data; state.reports = await loadJson("/reports").catch(() => state.reports); if (currentViewMode() === "security" || currentViewMode() === "forensics") { state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] })); } updateFilters(state.reports); return { data, report: state.reports, cases: state.cases, html: renderOperator(data, state.reports, { cases: state.cases }) }; } if (state.tab === "manager") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null); state.kpiExplain = kpiExplain; return { data, policyExplain, kpiExplain, html: renderManager(data, policyExplain, kpiExplain) }; } if (state.tab === "departments") { const data = await loadJson("/reports"); state.reports = data; updateFilters(data); return { data, html: renderDepartments(data) }; } if (state.tab === "employees") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null); state.kpiExplain = kpiExplain; return { data, policyExplain, kpiExplain, html: renderEmployees(data, policyExplain, kpiExplain) }; } if (state.tab === "owner") { const data = await loadJson("/owner"); return { data, html: renderOwner(data) }; } if (state.tab === "incidents") { const data = await loadJson("/incidents"); const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); const reports = await loadJson("/reports").catch(() => state.reports || {}); const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] })); state.reports = reports; return { data: { incidents: data, evidence, reports, cases }, html: renderIncidents({ incidents: data, evidence, reports, cases }) }; } if (state.tab === "perimeter") { const data = await loadJson("/owner"); const reports = await loadJson("/reports").catch(() => state.reports); state.reports = reports; return { data, report: reports, html: renderPerimeter(data, reports) }; } if (state.tab === "reports") { const data = await loadJson("/reports"); state.reports = data; updateFilters(data); return { data, html: renderReports(data) }; } if (state.tab === "settings") { const data = await loadJson("/reports").catch(() => state.reports || {}); state.reports = data; updateFilters(data); return { data, html: renderSettings(data) }; } return { data: {}, html: renderEmptyState("Раздел не найден.") }; } function tabLoadingStage(tab) { if (tab === "operator") return currentViewMeta().stage; return { employees: "Получение данных сотрудников", departments: "Расчёт показателей подразделений", owner: "Формирование главного вывода по рискам", incidents: "Подготовка разделов расследований", perimeter: "Подготовка разделов сетевого периметра", reports: "Подготовка разделов отчета", settings: "Подготовка раздела настроек", }[tab] || "Подготовка разделов"; } function setTab(tab) { state.tab = tab; applySecurityMode(tab); document.querySelectorAll(".tab").forEach(btn => { btn.classList.toggle("is-active", btn.dataset.tab === tab); }); refresh({ stage: tabLoadingStage(tab) }); } function applySecurityMode(tab) { document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents" || tab === "perimeter"); } function consumePendingScroll() { const selector = state.pendingScrollSelector; if (!selector) return; state.pendingScrollSelector = null; window.setTimeout(() => { const target = document.querySelector(selector); if (target) target.scrollIntoView({ behavior: "smooth", block: "start" }); }, 0); } function showError(error) { const hasPreviousData = Boolean(state.load.lastUpdatedAt); setLoadStatus( hasPreviousData ? "STALE" : "ERROR", hasPreviousData ? "Не удалось обновить данные" : "Ошибка загрузки данных", hasPreviousData ? 100 : 0, { error: error?.message || String(error) }, ); const content = document.getElementById("content"); if (!content) return; if (hasPreviousData) { if (!content.querySelector("[data-loading-state='STALE']")) { content.insertAdjacentHTML("afterbegin", staleBanner(error)); } return; } content.innerHTML = renderErrorState(error); } document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); document.querySelectorAll("[data-view-mode]").forEach(btn => { btn.addEventListener("click", () => setViewMode(btn.dataset.viewMode)); }); document.querySelectorAll("[data-demo-view-mode]").forEach(btn => { btn.addEventListener("click", () => setViewMode(btn.dataset.demoViewMode)); }); document.getElementById("periodFilter")?.addEventListener("change", event => { state.period = periodFromSelectValue(event.target.value); refresh({ stage: "Получение данных за выбранный период" }); }); document.addEventListener("click", event => { const button = event.target.closest("[data-open-reports]"); if (!button) return; setTab("reports"); }); document.addEventListener("click", event => { const button = event.target.closest("[data-open-investigation]"); if (!button) return; state.investigationRequested = true; setTab("incidents"); }); document.addEventListener("click", event => { const button = event.target.closest("[data-risk-layer-tab]"); if (!button) return; const selector = button.dataset.riskLayerSelector || ""; state.pendingScrollSelector = selector; const tab = button.dataset.riskLayerTab || state.tab; if (tab === state.tab) { consumePendingScroll(); } else { setTab(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-review-status]"); if (!button) return; candidateReviewAction(button).catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-create-case]"); if (!button) return; createCaseAction(button).catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-case-status]"); if (!button) return; caseStatusAction(button).catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-print-report]"); if (!button) return; window.print(); }); document.addEventListener("click", event => { const button = event.target.closest("[data-anonymize-report]"); if (!button) return; anonymizeReport(button).catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-export-markdown]"); if (!button) return; exportMarkdown().catch(showError); }); document.addEventListener("click", event => { const button = event.target.closest("[data-readiness-verify]"); if (!button) return; verifyReadinessBundle(button).catch(showError); }); async function verifyReadinessBundle(button) { button.disabled = true; button.textContent = "Проверка..."; const verify = await loadJson("/readiness/verify"); state.readiness = { ...(state.readiness || {}), verify }; const status = document.getElementById("readinessVerifyStatus"); if (status) { status.textContent = `Проверено: контрольная сумма ${verify.checksum_verified ? "подтверждена" : "не подтверждена"} · подпись ${verify.signature_verified ? "подтверждена" : "не подтверждена"}`; } button.disabled = false; button.textContent = "Проверить пакет"; } async function anonymizeReport(button) { button.disabled = true; const data = await loadJson("/reports?anonymize=1"); state.reports = data; document.getElementById("content").innerHTML = renderReports(data); } async function exportMarkdown() { const data = state.reports || await loadJson("/reports"); const markdown = displayText(periodReport(data)?.markdown || data.markdown || ""); const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "awatch-rus-report.md"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function updateFilters(report) { updateSelect("departmentFilter", (report?.workforce?.department_comparison || []).map(item => item.label)); updateSelect("ownerFilter", (report?.workforce?.owner_comparison || []).map(item => item.label)); } function updateSelect(id, values) { const select = document.getElementById(id); if (!select || select.dataset.loaded === "true") return; const unique = [...new Set((values || []).filter(Boolean))].slice(0, 50); select.innerHTML = `` + unique.map(item => ``).join(""); select.dataset.loaded = "true"; } 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({ stage: "Обновление статуса инцидента" }); } async function candidateReviewAction(button) { const candidateId = button.dataset.candidateId; const status = button.dataset.reviewStatus; const reviewer = window.prompt("Проверяющий", ""); if (reviewer === null || reviewer.trim() === "") return; const comment = window.prompt("Комментарий к проверке", ""); if (comment === null) return; button.disabled = true; await postJson("/incident-review", { candidate_id: candidateId, status, reviewer, comment, }); await refresh({ stage: "Обновление проверки кандидата" }); } async function createCaseAction(button) { const candidateId = button.dataset.candidateId; const title = window.prompt("Название дела", `Дело по кандидату ${candidateId}`); if (title === null || title.trim() === "") return; const owner = window.prompt("Ответственный по делу", ""); if (owner === null) return; const summary = window.prompt("Краткое резюме дела", ""); if (summary === null) return; button.disabled = true; await postJson("/cases", { candidate_id: candidateId, title, owner, summary, }); state.tab = "incidents"; await refresh({ stage: "Создание дела и обновление расследований" }); } async function caseStatusAction(button) { const caseId = button.dataset.caseId; const status = button.dataset.caseStatus; const decision = window.prompt("Решение или комментарий по делу", ""); if (decision === null) return; button.disabled = true; await postJson(`/cases/${encodeURIComponent(caseId)}/status`, { status, decision, }); await refresh({ stage: "Обновление статуса дела" }); } updateViewModeButtons(); applySecurityMode(state.tab); renderLoadStatus(); refresh({ stage: "Первичная загрузка портала" }); setInterval(() => refresh({ background: true, stage: "Фоновое обновление данных" }), 60000);