const state = { tab: "operator", links: null };
function apiBase() {
const path = window.location.pathname;
return path.startsWith("/portal") ? "/portal/api" : "/api";
}
async function loadJson(path) {
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
async function postJson(path, payload) {
const response = await fetch(`${apiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
function statusClass(status) {
const s = String(status || "UNKNOWN").toLowerCase();
if (s === "ok" || s === "true") return "status-ok";
if (s === "warn" || s === "warning") return "status-warn";
if (s === "fail" || s === "false") return "status-fail";
return "status-unknown";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function renderSummary(summary) {
const global = document.getElementById("globalStatus");
global.className = `status-pill ${statusClass(summary.severity)}`;
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
const blocks = Object.entries(summary.blocks || {});
document.getElementById("summary").innerHTML = blocks.map(([name, block]) => `
${escapeHtml(block.status)}
${escapeHtml(label(name))}
${escapeHtml(block.text)}
`).join("");
}
function label(name) {
return {
collection: "Сбор данных",
grafana: "Grafana",
dlp: "DLP",
worktime: "Работа сегодня",
one_c: "1С"
}[name] || name;
}
function renderLinks(links) {
const link = (text, href) => `${escapeHtml(text)}`;
return `
${link("DetMir ActivityWatch", links.detmir_activitywatch)}
${link("Grafana", links.grafana_dashboards)}
${link("AW UI", 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("DLP обзор", links.dlp_overview_dashboard)}
${link("Все Grafana dashboards", links.grafana_dashboards)}
`;
}
function renderSourceList(data) {
const sources = [
["DetMir", data.detmir_status],
["Проверки", data.detmir_check],
["Systemd", data.failed_units],
["Grafana data", data.grafana_data]
];
return `${sources.map(([name, source]) => `
${escapeHtml(name)}
${escapeHtml(source?.summary || source?.error || "нет данных")}
${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}
`).join("")}
`;
}
function renderOperator(data) {
return `
Оператор
Контур
${renderSourceList(data)}
Быстрые переходы
${renderLinks(data.links)}
Проблемы
${renderIncidentsList(data.incidents)}
`;
}
function renderManager(data, policyExplain) {
const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds);
return `
Руководитель
Работа сегодня
${escapeHtml(workforceIndex)}
proxy: активное время / плановое рабочее время
Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}
${escapeHtml(data.status?.text || "")}
Приложения
${(data.applications || []).slice(0, 8).map(app => `
${escapeHtml(app.application)}
${escapeHtml(app.proved_work_human || "")}
${escapeHtml(app.evidence_events || 0)}
`).join("")}
${renderWorkforceIndexExplanation(policyExplain)}
Сотрудники
${(data.users || []).map(user => `
${escapeHtml(user.user)}
Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")}
${escapeHtml(user.sessions_count || 0)} сесс.
`).join("")}
`;
}
function workforceIndexText(usersCount, activeSeconds) {
const users = Number(usersCount || 0);
const seconds = Number(activeSeconds || 0);
if (users <= 0 || seconds <= 0) return "Нет данных";
const pct = Math.max(0, Math.min(100, Math.round(seconds / (users * 8 * 3600) * 100)));
return `${pct}%`;
}
function humanSeconds(seconds) {
const value = Math.max(0, Number(seconds || 0));
const totalMinutes = Math.round(value / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${String(minutes).padStart(2, "0")}`;
}
function pctText(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "0%";
return `${Math.round(n * 100)}%`;
}
function renderWorkforceIndexExplanation(policy) {
if (!policy || !policy.configured) {
return `
Почему такой индекс?
Role/application policy не настроена. Доступен только нейтральный индекс активности.
`;
}
const details = Array.isArray(policy.app_details) ? policy.app_details.slice(0, 12) : [];
const employees = Array.isArray(policy.employee_details) ? policy.employee_details.slice(0, 12) : [];
const weightedTotal = Math.max(1, Number(policy.weighted_seconds || 0));
const appRows = details.length === 0
? `Нет приложенийНет top breakdown для weighted KPI
`
: details.map(item => {
const contribution = Math.round(Number(item.weighted_seconds || 0) / weightedTotal * 100);
return `
${escapeHtml(item.application || "-")}
${escapeHtml(humanSeconds(item.seconds))} · вес ${escapeHtml(pctText(item.weight))} · правило ${escapeHtml(item.matched_rule || "default_weight")}
${escapeHtml(humanSeconds(item.weighted_seconds))} · ${contribution}%
`;
}).join("");
return `
Почему такой индекс?
${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}
Формула: ${escapeHtml(policy.formula || "index = weighted_seconds / planned_seconds × 100")}.
${escapeHtml(workforceIndexTextFromValue(policy.index))}
Роль${escapeHtml(policy.role_label || policy.role || "-")}
План${escapeHtml(humanSeconds(policy.planned_seconds))}
App time${escapeHtml(humanSeconds(policy.app_seconds))}
Weighted${escapeHtml(humanSeconds(policy.weighted_seconds))}
${appRows}
${renderPolicyAudit(policy.policy_audit)}
${renderEmployeeIndexDetails(employees)}
`;
}
function renderPolicyAudit(audit) {
const items = Array.isArray(audit?.needs_review) ? audit.needs_review.slice(0, 12) : [];
if (items.length === 0) {
return `Аудит policyВсе top приложения попали под явные правила или данных для аудита нет.
`;
}
return `
Аудит policy: default_weight
Эти приложения не нашли явного правила и требуют проверки классификации.
${items.map(item => `
${escapeHtml(item.application || "-")}
${escapeHtml(humanSeconds(item.seconds))} · default ${escapeHtml(pctText(item.weight))}
review
`).join("")}
`;
}
function renderEmployeeIndexDetails(items) {
if (!items.length) return "";
return `
Drill-down по сотрудникам
Per-user индекс сейчас считается по активному времени; app-weight breakdown доступен на уровне портфеля.
${items.map(item => `
${escapeHtml(item.user || "-")}
${escapeHtml(item.reason || `${item.formula || "employee_index = active_seconds / planned_seconds × 100"} · active ${humanSeconds(item.active_seconds)} / plan ${humanSeconds(item.planned_seconds)}`)}
${escapeHtml(workforceIndexTextFromValue(item.index))}
`).join("")}
`;
}
function workforceIndexTextFromValue(value) {
const n = Number(value);
return Number.isFinite(n) ? `${Math.round(n)}%` : "Нет данных";
}
function workforceIndexStatus(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "UNKNOWN";
if (n >= 80) return "OK";
if (n >= 45) return "WARN";
return "FAIL";
}
function renderOwner(data) {
const cards = Object.entries(data.cards || {});
return `
Владелец
${cards.map(([name, block]) => `
${escapeHtml(block.status)}
${escapeHtml(label(name))}
${escapeHtml(block.text)}
`).join("")}
Что сделать
${(data.recommendations || []).map(item => `
Рекомендация${escapeHtml(item)}
`).join("")}
Переходы
${renderLinks(data.links)}
`;
}
function renderIncidentsList(items) {
if (!items || items.length === 0) return `Активных проблем нет.
`;
return `${items.map(item => `
${escapeHtml(item.source)}
${escapeHtml(item.kind)} · ${escapeHtml(item.id)}
${escapeHtml(item.summary)}
${item.acknowledged ? `
В работе: ${escapeHtml(item.assigned_to || item.actor || "оператор")} · ${escapeHtml(item.comment || "")}
` : ""}
${escapeHtml(item.status)}
`).join("")}
`;
}
function isDlpIncident(item) {
const text = `${item?.kind || ""} ${item?.source || ""} ${item?.summary || ""}`.toLowerCase();
return text.includes("dlp") || text.includes("incident") || text.includes("case") || text.includes("иб");
}
function renderDlpIncidentsList(items) {
const dlpItems = (items || []).filter(isDlpIncident);
if (dlpItems.length === 0) return `Активных DLP/ИБ-инцидентов нет.
`;
return renderIncidentsList(dlpItems);
}
function renderDlpEvidence(evidence) {
if (!evidence) return `Данные evidence загружаются.
`;
if (!evidence.ok) return `Evidence недоступны: ${escapeHtml(evidence.error || "ошибка чтения")}
`;
const items = evidence.items || [];
if (items.length === 0) return `DLP evidence пока не найдены.
`;
return `${items.map(item => `
${escapeHtml(item.signal_type || item.source || item.stream_type)}
${escapeHtml(item.event_ts)} · ${escapeHtml(item.hostname)}${item.username ? " · " + escapeHtml(item.username) : ""}
${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)}
${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "SHA-256: " + escapeHtml(item.screenshot_sha256) : escapeHtml(item.blocked_reason || "без скрина")}
${item.screenshot_available ? "СКРИН" : "МЕТА"}
${item.preview_url ? `
Открыть` : ""}
${item.download_url ? `
Скачать` : ""}
`).join("")}
`;
}
function renderIncidents(data) {
const links = state.links || {};
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
Инциденты ИБ
DLP-инциденты
${renderDlpIncidentsList(incidents)}
Графики и дашборды
${renderDlpLinks(links)}
Доказательства
${renderDlpEvidence(evidence)}
`;
}
function renderKpiCards(items) {
return `${(items || []).map(item => `
${escapeHtml(item.status || "INFO")}
${escapeHtml(item.label)}
${escapeHtml(item.value)}
${escapeHtml(item.context || "")}
`).join("")}
`;
}
function renderReportSections(sections) {
return `${(sections || []).map(section => `
${escapeHtml(section.title)}
${(section.items || []).map(item => `
${escapeHtml(item.label)}
${escapeHtml(item.value)}
${escapeHtml(item.status || "INFO")}
`).join("")}
`).join("")}
`;
}
function renderReports(data) {
return `
Отчеты
${escapeHtml(data.severity)}
${escapeHtml(data.headline)}
${escapeHtml(data.period || "")} · обновлено ${escapeHtml(data.generated_at_utc || "")}
Для руководителя
${(data.executive_points || []).map(point => `
Итог${escapeHtml(point)}
`).join("")}
Ключевые показатели
${renderKpiCards(data.kpis)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
Срезы отчета
${renderReportSections(data.sections)}
Markdown для отчета
${escapeHtml(data.markdown || "")}
`;
}
async function refresh() {
if (!state.links) state.links = await loadJson("/links");
const summary = await loadJson("/summary");
renderSummary(summary);
const content = document.getElementById("content");
const data = await loadJson(`/${state.tab}`);
if (state.tab === "operator") content.innerHTML = renderOperator(data);
if (state.tab === "manager") {
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderManager(data, policyExplain);
}
if (state.tab === "owner") content.innerHTML = renderOwner(data);
if (state.tab === "incidents") {
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
}
if (state.tab === "reports") content.innerHTML = renderReports(data);
}
function setTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach(btn => {
btn.classList.toggle("is-active", btn.dataset.tab === tab);
});
document.getElementById("content").innerHTML = `Загрузка...
`;
refresh().catch(showError);
}
function showError(error) {
document.getElementById("content").innerHTML = `${escapeHtml(error.stack || error.message || error)}`;
}
document.querySelectorAll(".tab").forEach(btn => {
btn.addEventListener("click", () => setTab(btn.dataset.tab));
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-incident-action]");
if (!button) return;
incidentAction(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-print-report]");
if (!button) return;
window.print();
});
async function incidentAction(button) {
const id = button.dataset.incidentId;
const action = button.dataset.incidentAction;
const payload = { id, action };
if (action === "ack") {
const comment = window.prompt("Комментарий к взятию в работу", "");
if (comment === null) return;
payload.comment = comment;
}
if (action === "assign") {
const assignedTo = window.prompt("Кому назначить", "");
if (assignedTo === null || assignedTo.trim() === "") return;
payload.assigned_to = assignedTo;
const comment = window.prompt("Комментарий", "");
if (comment === null) return;
payload.comment = comment;
}
button.disabled = true;
await postJson("/incidents/action", payload);
await refresh();
}
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);