`;
}
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)}Этот детальный экран показывает последний дневной срез. Сводная интерпретация за период доступна в разделах "Пульс организации", "Подразделения" и "Отчеты".
Кандидаты для проверки: ${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 => `
${ui(row.label)}
${ui(row.activityText || "-")}
${ui(row.deviation)}
${ui(row.status || "INFO")}
${ui(row.responsible)}
`).join("")}
`;
}
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 `
загрузка, простои, перегруз и дисциплина процессов
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")}
${renderSectionItems(insights, "Отклонений по работе сотрудников пока не найдено.")}
Контроль безопасности
подозрительные события и приоритет реакции
${renderSectionItems(security, "ИБ-событий для реакции пока нет.")}
Проверка правилКлючевые приложения попали под явные правила или данных для проверки нет.
`;
}
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-аномалии и состояние периметра.
СигналыСущественных риск-сигналов в текущем срезе нет.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)}