const state = { tab: "operator", period: "today", links: null, readiness: null, reports: null }; function apiBase() { const path = window.location.pathname; return path.startsWith("/portal") ? "/portal/api" : "/api"; } async function loadJson(path) { const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); return response.json(); } async function postJson(path, payload) { const response = await fetch(`${apiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); return response.json(); } function statusClass(status) { const s = String(status || "UNKNOWN").toLowerCase(); if (s === "ok" || s === "true" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok"; if (s === "warn" || s === "warning" || 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 === "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("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("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("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, "нет") .replaceAll("daily", "день") .replaceAll("weekly", "неделя") .replaceAll("monthly", "месяц") .replaceAll("OK", "OK") .replaceAll("FAIL", "FAIL") .replaceAll("WARN", "WARN"); } function ui(value) { return escapeHtml(displayText(value)); } function renderSummary(summary, readiness) { const global = document.getElementById("globalStatus"); global.className = `status-pill ${statusClass(summary.severity)}`; global.textContent = `Сбор данных ${summary.operator_ok ? "OK" : "NO"} · ${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 ? "OK" : "FAIL"}
Контрольная сумма${checksumOk ? "OK" : "FAIL"}

Отпечаток ключа: ${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 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 `

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

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

${ui(dashboard.forensics_readiness || "UNKNOWN")}
Trust KPI${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(summary.main_risk || "нет данных")}
Главное улучшение${ui(summary.main_improvement || "нет данных")}
Пробел в данных${ui(summary.main_data_gap || "нет данных")}
${highRisk.length ? `

Риск-подразделения: ${ui(highRisk.slice(0, 5).map(item => `${item.department} (${item.risk_level})`).join("; "))}

` : ""} ${candidates.length ? `

Кандидаты для проверки: ${ui(candidates.slice(0, 5).map(item => `${item.id} (${item.risk_level})`).join("; "))}

` : ""}
`; } 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 || "Без подразделения", 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" ? "FAIL — требуется действие" : status === "WARN" ? "WARN — требуется внимание" : "LOW — все нормально", 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("WARN подразделений", rows.filter(row => row.status === "WARN").length, rows.some(row => row.status === "WARN") ? "WARN" : "OK", "требуется внимание"), metricCard("FAIL подразделений", 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 = `
Нет данныхПодразделения пока не рассчитаны.UNKNOWN
`; 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)}
Нет данныхПодразделения пока не рассчитаны. - - UNKNOWN -
`; } 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 `

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

Read-only срез для поручения разбора ответственному.

${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 renderOverviewAnalytics(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 || "нет данных")} ${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}
`).join("")}
`; } function renderOperator(data, report) { report = periodReport(report); const incidents = Array.isArray(data.incidents) ? data.incidents : []; const workforce = findSection(report, "Работа"); const insights = findSection(report, "Выводы Workforce"); const security = findSection(report, "ИБ"); const actions = findSection(report, "Действия"); return `

Управленческая сводка

За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.

${escapeHtml(report?.headline || "Оперативный срез")}
${renderPeriodBanner(report)} ${renderExecutiveDashboard(report)} ${renderExecutiveMetrics(report, incidents)} ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} ${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)} ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} ${renderAgentCoverageSla(report?.agent_coverage_sla)} ${renderRiskHeatmap(report?.risk_heatmap)} ${renderSecurityCorrelation(report?.security_correlation)} ${renderBusinessRisk(report?.business_risk)} ${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)} ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} ${renderOverviewAnalytics(report)}

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

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

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

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

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

операторские действия и открытые вопросы
${renderIncidentsList(incidents)} ${renderSectionItems(actions, "Рекомендаций нет.")}

Технический статус источников

вторичный слой для оператора
${renderSourceList(data)}
`; } function renderManager(data, policyExplain) { 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("")}
${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) { 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("") || `

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

`}
${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 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 || "workforce-ueba-risk", 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, RDP/1C активность, процессы и сетевые сигналы", recommended_actions: [ "назначить ответственного за ручную проверку", "сопоставить риск с журналами активности и бизнес-задачей", "зафиксировать вывод в отчете по инциденту" ], evidence: [ "RDP activity: проверяется по событиям рабочего времени", "process activity: проверяется по процессам и приложениям", "network activity: проверяется по сетевым сигналам", "proxy activity: подключается как внешний источник", "pfSense events: учитываются при наличии интеграционного слоя" ], generated_at: generated }; } function renderAutoInvestigationCard(report) { const card = buildAutoInvestigation(report); return `

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

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

${ui(card.status)}
incident_id${ui(card.incident_id)}
risk_id${ui(card.risk_id)}
department${ui(card.department)}
owner${ui(card.owner)}
activity_index${ui(card.activity_index)}
deviation${ui(card.deviation)}
generated_at${ui(card.generated_at)}
summary${ui(card.summary)}
why_it_is_risk${ui(card.why_it_is_risk)}
what_to_check${ui(card.what_to_check)}
recommended_actions${ui(card.recommended_actions.join("; "))}
evidence${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)}
Нет дел - OK - Создайте дело из подтвержденного кандидата. -
`; } function renderCaseActions(item) { const id = item?.case_id || ""; const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`; 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 baselineReady = `user: ${risk.user_baseline_available ? "yes" : "no"} · dept: ${risk.department_baseline_available ? "yes" : "no"}`; 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
${reasons.length ? reasons.map(item => `
${ui(item.label || item.code || "-")} ${ui(item.value || "")} · ${ui(item.recommendation || "")} +${escapeHtml(item.points || 0)}
`).join("") : `
СигналыСущественных риск-сигналов в текущем срезе нет.OK
`}
`; } 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: "Данные агента подтверждают KPI", summary: "Сессии собраны основным способом через Windows WTS API; индекс активности можно использовать как рабочий управленческий KPI.", recommendation: "Использовать отчет как подтвержденный оперативный срез.", kpi_accepted: true }; } if (source === "local_fallback") { return { status: "DEGRADED", title: "Диагностический режим агента", summary: "Диагностический режим, данные не засчитываются в KPI.", recommendation: "Проверить доступность WTS API и права запуска агента.", kpi_accepted: false }; } if (hasError) { return { status: "DEGRADED", title: "Достоверность данных снижена", summary: `Коллектор передал ошибку: ${q.collector_error}`, recommendation: "Восстановить основной путь WTS API перед использованием отчета как доказательной базы.", 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 || "")}

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

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

Технические детали
Источник коллектора${ui(source)}
Всего сессий${escapeHtml(q.sessions_collected_total ?? 0)}
Активных сессий${escapeHtml(q.active_sessions_total ?? 0)}
RDP-сессий${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 дней

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

${ui(status)}
OK дней${escapeHtml(s.ok_days ?? 0)}
Проблемных дней${escapeHtml(unstableDays)}
KPI принят${escapeHtml(s.kpi_accepted_pct ?? 0)}%
${Number(s.ok_days || 0) < 5 ? `
KPI требует валидации: нестабильный сбор данных агента.
` : ""}
История по дням
${items.length ? items.map(item => `
${escapeHtml(item.date || "-")} source=${ui(item.source || "unknown")} · KPI=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""} ${ui(item.status || "UNKNOWN")}
`).join("") : `
ИсторияИстория качества агента за период отсутствует.UNKNOWN
`}
`; } 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 `

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

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

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

Покрытие агентов

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

${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" ? `
Покрытие агентов критически недостаточно: KPI не может считаться репрезентативным.
` : ""} ${status === "WARNING" ? `
KPI требует проверки: часть рабочих мест не присылает свежую телеметрию.
` : ""} ${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 renderBusinessRisk(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return `

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

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

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

Карта рисков подразделений

Где одновременно слабый Trust KPI, низкая активность, плохое покрытие агентов, кандидаты и открытые дела.

${ui(worst)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Подразделение Trust KPI Активность Покрытие Риск Дела
${ui(item.department || "Без подразделения")} ${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.open_cases ?? 0)}
Нет данных UNKNOWN UNKNOWN UNKNOWN UNKNOWN 0
`; } 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 `

Корреляция Workforce и Security

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

${ui(status)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
Подразделение Trust KPI Активность Бизнес-риск Security Корреляция Причина
${ui(item.department || "Без подразделения")} ${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(Number(item.correlation_score || 0))}/100 ${ui(item.correlation_reason || "связь не выражена")}
Нет данных UNKNOWN UNKNOWN UNKNOWN кандидаты 0 · дела 0 0/100 недостаточно данных по подразделениям
`; } function businessRiskReasons(item) { const reasons = Array.isArray(item?.reasons) && item.reasons.length ? item.reasons.join("; ") : "существенных причин не найдено"; return `${reasons}. Trust ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных узлов ${item?.problem_nodes_count ?? 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 `

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

Как менялся бизнес-риск подразделений по накопленной daily history.

${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 || "Без подразделения")} ${ui(item.risk_level || "UNKNOWN")} ${ui(Array.isArray(item.reasons) && item.reasons.length ? item.reasons.join("; ") : "нет существенных причин")}
- История не накоплена UNKNOWN Нужно дождаться daily history.
`; } function renderRiskIncidentCandidates(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return `

Кандидаты в инциденты

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

${ui(worst)}
${rows.length ? rows.map(item => ` `).join("") : ` `}
ID Подразделение Узел Риск Проверка Причина Рекомендация Действие
${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)}
- Нет кандидатов - OK 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`; 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 ? ` Evidence: ${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)} ${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)} ${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)} ${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"}`], ["Порог WARN", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"], ["Порог FAIL", ">= 70 баллов", "высокий риск требует приоритетного разбора"], ["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `UEBA policy: ${basename(risk.policy_path)}` : "UEBA policy: встроенная модель"], ["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`], ]; } function renderSettings(report) { const rows = settingRows(periodReport(report || state.reports || {})); return `

Настройки

Read-only параметры расчета и границы интерпретации данных.

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

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

${rows.map(([name, value, note]) => ` `).join("")}
ПараметрЗначениеКомментарий
${ui(name)}${ui(value)}${ui(note)}
`; } async function refresh() { if (!state.links) state.links = await loadJson("/links"); const summary = await loadJson("/summary"); state.readiness = { bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })), verify: state.readiness?.verify || null }; renderSummary(summary, state.readiness); const content = document.getElementById("content"); if (state.tab === "operator") { const data = await loadJson("/operator"); state.reports = await loadJson("/reports").catch(() => state.reports); content.innerHTML = renderOperator(data, state.reports); updateFilters(state.reports); } if (state.tab === "manager") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); content.innerHTML = renderManager(data, policyExplain); } if (state.tab === "departments") { const data = await loadJson("/reports"); state.reports = data; content.innerHTML = renderDepartments(data); updateFilters(data); } if (state.tab === "employees") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); content.innerHTML = renderEmployees(data, policyExplain); } if (state.tab === "owner") { const data = await loadJson("/owner"); content.innerHTML = 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; content.innerHTML = 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; content.innerHTML = renderPerimeter(data, reports); } if (state.tab === "reports") { const data = await loadJson("/reports"); state.reports = data; content.innerHTML = renderReports(data); updateFilters(data); } if (state.tab === "settings") { const data = await loadJson("/reports").catch(() => state.reports || {}); state.reports = data; content.innerHTML = renderSettings(data); updateFilters(data); } } function setTab(tab) { state.tab = tab; applySecurityMode(tab); document.querySelectorAll(".tab").forEach(btn => { btn.classList.toggle("is-active", btn.dataset.tab === tab); }); document.getElementById("content").innerHTML = `

Загрузка...

`; refresh().catch(showError); } function applySecurityMode(tab) { document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents" || tab === "perimeter"); } function showError(error) { document.getElementById("content").innerHTML = `
${escapeHtml(error.stack || error.message || error)}
`; } document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); document.getElementById("periodFilter")?.addEventListener("change", event => { state.period = periodFromSelectValue(event.target.value); document.getElementById("content").innerHTML = `

Обновление периода...

`; refresh().catch(showError); }); 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-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 ? "OK" : "FAIL"} · подпись ${verify.signature_verified ? "OK" : "FAIL"}`; } 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(); } 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(); } 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(); } 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(); } applySecurityMode(state.tab); refresh().catch(showError); setInterval(() => refresh().catch(showError), 60000);