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") return "status-ok";
if (s === "warn" || s === "warning") return "status-warn";
if (s === "fail" || s === "false") return "status-fail";
return "status-unknown";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function 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:", "подразделение:")
.replaceAll("yes", "да")
.replaceAll("no", "нет")
.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) {
const activity = findKpi(report, "Индекс активности");
const activeTime = findKpi(report, "Активное время");
const employees = findKpi(report, "Сотрудники");
const risk = findKpi(report, "UEBA риск");
const open = findKpi(report, "Открытые вопросы");
const openCount = Array.isArray(incidents) ? incidents.filter(item => !item.acknowledged).length : 0;
const anomalies = Array.isArray(report?.workforce?.insights) ? report.workforce.insights.filter(item => item.status !== "OK").length : 0;
return `
${metricCard("Активность", activity?.value, activity?.status, activity?.context || "к выбранному периоду")}
${metricCard("Полезное время", activeTime?.value || "нет данных", activeTime?.status || "UNKNOWN", "на сотрудника")}
${metricCard("Сотрудников онлайн", employees?.value, employees?.status, employees?.context || "в текущем срезе")}
${metricCard("Риск-индекс", risk?.value, risk?.status, risk?.context || "низкий / средний / высокий")}
${metricCard("Аномалии", anomalies, anomalies ? "WARN" : "OK", "требуют проверки")}
${metricCard("Инциденты ИБ", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "за выбранный период")}
`;
}
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 renderDepartmentTable(report) {
const items = (report?.workforce?.department_comparison || []).slice(0, 8);
if (!items.length) return `
Подразделения пока не рассчитаны.
`;
return `
Подразделение Активность Тренд Риск
${items.map(item => `
${ui(item.label)}
${ui(item.value || "-")}
${escapeHtml(trendArrow(item.status))}
${ui(statusRiskLabel(item.status))}
`).join("")}
`;
}
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 `${items.map(item => `
${ui(statusRiskLabel(item.status))}
${ui(item.label || item.title || "Аномалия")}
${ui(item.value || item.subject || "")}
`).join("")} `;
}
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) => `
${index + 1}
${ui(item.label || item.code || "Риск")}
+${escapeHtml(item.points || 0)}
`).join("")} `;
}
function renderOverviewAnalytics(report) {
return `
Подразделения сегодня
${renderDepartmentTable(report)}
Аномалии
${renderAnomalies(report)}
Топ рисков
${renderTopRisks(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 `
${link("Журнал активности", links.detmir_activitywatch)}
${link("Графики", links.grafana_dashboards)}
${link("События рабочих мест", links.aw_ui)}
${link("Рабочее время", links.worktime_report)}
${link("1С сводка", links.file1c_brief)}
${link("1С действия", links.file1c_actions)}
`;
}
function renderDlpLinks(links) {
const link = (text, href) => `${escapeHtml(text)} `;
return `
${link("ИБ-графики", links.dlp_security_dashboard)}
${link("ИБ для руководства", links.dlp_management_dashboard)}
${link("Обзор рисков данных", links.dlp_overview_dashboard)}
${link("Все графики", links.grafana_dashboards)}
`;
}
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)}
${renderExecutiveMetrics(report, incidents)}
${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 renderIncidents(data) {
const links = state.links || {};
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
Расследования и доказательная база
Кто, когда, откуда, куда, связанные хосты, материалы и выгрузка для проверки.
${escapeHtml((incidents || []).length)} записей
Карточки инцидентов
${renderDlpIncidentsList(incidents)}
Связанные графики
${renderDlpLinks(links)}
Материалы: скриншоты, хеши, файлы
${renderDlpEvidence(evidence)}
`;
}
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 renderReports(data) {
data = periodReport(data);
return `
Отчеты
Ежедневные, недельные, месячные, по подразделению, сотруднику, инциденту и пилотной эксплуатации.
${escapeHtml(data.severity)}
${renderPeriodBanner(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)}
${renderUebaRisk(data.ueba_risk)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
Срезы отчета
${renderReportSections(data.sections)}
Текст для отчета
${ui(data.markdown || "")}
`;
}
function renderSettings() {
return `
Настройки
Параметры отображения портала и границы интерпретации данных.
только чтение
Периоды
Сегодня текущий дневной срез готово
7 дней сводка по накопленной дневной истории зависит от истории
30 дней месячная управленческая интерпретация зависит от истории
Модули продукта
Workforce активность, рабочее время, подразделения, сотрудники активно
UEBA правила риска, аномалии, обычный профиль v1
DLP-lite события данных, материалы проверки, расследования read-only
`;
}
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: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
}
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") {
content.innerHTML = renderSettings();
}
}
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-incident-action]");
if (!button) return;
incidentAction(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-print-report]");
if (!button) return;
window.print();
});
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 => `${escapeHtml(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();
}
applySecurityMode(state.tab);
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);