Files
AWatch-rus/adk-rust/crates/detmir-portal/src/static/app.js
T

504 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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]) => `
<article class="card">
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
<h3>${escapeHtml(label(name))}</h3>
<p class="muted">${escapeHtml(block.text)}</p>
</article>
`).join("");
}
function label(name) {
return {
collection: "Сбор данных",
grafana: "Grafana",
dlp: "DLP",
worktime: "Работа сегодня",
one_c: "1С"
}[name] || name;
}
function renderLinks(links) {
const link = (text, href) => `<a class="button" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
return `<div class="links">
${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)}
</div>`;
}
function renderDlpLinks(links) {
const link = (text, href) => `<a class="button" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
return `<div class="links">
${link("ИБ дашборд", links.dlp_security_dashboard)}
${link("ИБ для руководства", links.dlp_management_dashboard)}
${link("DLP обзор", links.dlp_overview_dashboard)}
${link("Все Grafana dashboards", links.grafana_dashboards)}
</div>`;
}
function renderSourceList(data) {
const sources = [
["DetMir", data.detmir_status],
["Проверки", data.detmir_check],
["Systemd", data.failed_units],
["Grafana data", data.grafana_data]
];
return `<div class="list">${sources.map(([name, source]) => `
<div class="row">
<strong>${escapeHtml(name)}</strong>
<span class="muted">${escapeHtml(source?.summary || source?.error || "нет данных")}</span>
<span class="badge ${statusClass(source?.status || source?.ok)}">${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}</span>
</div>
`).join("")}</div>`;
}
function renderOperator(data) {
return `
<h2 class="section-title">Оператор</h2>
<div class="grid-2">
<section class="card">
<h3>Контур</h3>
${renderSourceList(data)}
</section>
<section class="card">
<h3>Быстрые переходы</h3>
${renderLinks(data.links)}
</section>
</div>
<h3 class="section-title">Проблемы</h3>
${renderIncidentsList(data.incidents)}
`;
}
function renderManager(data, policyExplain) {
const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds);
return `
<h2 class="section-title">Руководитель</h2>
<div class="grid-2">
<section class="card">
<h3>Работа сегодня</h3>
<p class="kpi-value">${escapeHtml(workforceIndex)}</p>
<p class="muted">proxy: активное время / плановое рабочее время</p>
<p class="muted">Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}</p>
<p class="muted">${escapeHtml(data.status?.text || "")}</p>
</section>
<section class="card">
<h3>Приложения</h3>
<div class="list">${(data.applications || []).slice(0, 8).map(app => `
<div class="row">
<strong>${escapeHtml(app.application)}</strong>
<span class="muted">${escapeHtml(app.proved_work_human || "")}</span>
<span class="badge status-ok">${escapeHtml(app.evidence_events || 0)}</span>
</div>
`).join("")}</div>
</section>
</div>
${renderWorkforceIndexExplanation(policyExplain)}
<h3 class="section-title">Сотрудники</h3>
<div class="list">${(data.users || []).map(user => `
<div class="row">
<strong>${escapeHtml(user.user)}</strong>
<span class="muted">Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")}</span>
<span class="badge status-ok">${escapeHtml(user.sessions_count || 0)} сесс.</span>
</div>
`).join("")}</div>
`;
}
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 `
<section class="card index-explain-card">
<h3>Почему такой индекс?</h3>
<p class="muted">Role/application policy не настроена. Доступен только нейтральный индекс активности.</p>
</section>
`;
}
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
? `<div class="row compact-row"><strong>Нет приложений</strong><span class="muted">Нет top breakdown для weighted KPI</span><span></span></div>`
: details.map(item => {
const contribution = Math.round(Number(item.weighted_seconds || 0) / weightedTotal * 100);
return `
<div class="row app-weight-row">
<strong>${escapeHtml(item.application || "-")}</strong>
<span class="muted">${escapeHtml(humanSeconds(item.seconds))} · вес ${escapeHtml(pctText(item.weight))} · правило ${escapeHtml(item.matched_rule || "default_weight")}</span>
<span class="badge ${Number(item.weight || 0) > 0 ? "status-ok" : "status-unknown"}">${escapeHtml(humanSeconds(item.weighted_seconds))} · ${contribution}%</span>
</div>
`;
}).join("");
return `
<section class="card index-explain-card">
<div class="section-head">
<div>
<h3>Почему такой индекс?</h3>
<p class="muted">${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}</p>
<p class="muted small">Формула: ${escapeHtml(policy.formula || "index = weighted_seconds / planned_seconds × 100")}.</p>
</div>
<span class="badge ${statusClass(workforceIndexStatus(policy.index))}">${escapeHtml(workforceIndexTextFromValue(policy.index))}</span>
</div>
<div class="index-metrics">
<div><span class="muted">Роль</span><strong>${escapeHtml(policy.role_label || policy.role || "-")}</strong></div>
<div><span class="muted">План</span><strong>${escapeHtml(humanSeconds(policy.planned_seconds))}</strong></div>
<div><span class="muted">App time</span><strong>${escapeHtml(humanSeconds(policy.app_seconds))}</strong></div>
<div><span class="muted">Weighted</span><strong>${escapeHtml(humanSeconds(policy.weighted_seconds))}</strong></div>
</div>
<div class="list compact-list app-weight-list">${appRows}</div>
${renderPolicyAudit(policy.policy_audit)}
${renderEmployeeIndexDetails(employees)}
</section>
`;
}
function renderPolicyAudit(audit) {
const items = Array.isArray(audit?.needs_review) ? audit.needs_review.slice(0, 12) : [];
if (items.length === 0) {
return `<div class="audit-note"><strong>Аудит policy</strong><span class="muted">Все top приложения попали под явные правила или данных для аудита нет.</span></div>`;
}
return `
<div class="audit-block">
<h4>Аудит policy: default_weight</h4>
<p class="muted small">Эти приложения не нашли явного правила и требуют проверки классификации.</p>
<div class="list compact-list">${items.map(item => `
<div class="row compact-row">
<strong>${escapeHtml(item.application || "-")}</strong>
<span class="muted">${escapeHtml(humanSeconds(item.seconds))} · default ${escapeHtml(pctText(item.weight))}</span>
<span class="badge status-warn">review</span>
</div>
`).join("")}</div>
</div>
`;
}
function renderEmployeeIndexDetails(items) {
if (!items.length) return "";
return `
<div class="employee-drilldown">
<h4>Drill-down по сотрудникам</h4>
<p class="muted small">Per-user индекс сейчас считается по активному времени; app-weight breakdown доступен на уровне портфеля.</p>
<div class="list compact-list">${items.map(item => `
<div class="row employee-index-row">
<strong>${escapeHtml(item.user || "-")}</strong>
<span class="muted">${escapeHtml(item.reason || `${item.formula || "employee_index = active_seconds / planned_seconds × 100"} · active ${humanSeconds(item.active_seconds)} / plan ${humanSeconds(item.planned_seconds)}`)}</span>
<span class="badge ${statusClass(item.status)}">${escapeHtml(workforceIndexTextFromValue(item.index))}</span>
</div>
`).join("")}</div>
</div>
`;
}
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 `
<h2 class="section-title">Владелец</h2>
<div class="summary-grid">${cards.map(([name, block]) => `
<article class="card">
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
<h3>${escapeHtml(label(name))}</h3>
<p class="muted">${escapeHtml(block.text)}</p>
</article>
`).join("")}</div>
<section class="card">
<h3>Что сделать</h3>
<div class="list">${(data.recommendations || []).map(item => `<div class="row"><strong>Рекомендация</strong><span class="muted">${escapeHtml(item)}</span><span></span></div>`).join("")}</div>
</section>
<section class="card">
<h3>Переходы</h3>
${renderLinks(data.links)}
</section>
`;
}
function renderIncidentsList(items) {
if (!items || items.length === 0) return `<p class="muted">Активных проблем нет.</p>`;
return `<div class="list">${items.map(item => `
<div class="row incident-row">
<div>
<strong>${escapeHtml(item.source)}</strong>
<div class="muted small">${escapeHtml(item.kind)} · ${escapeHtml(item.id)}</div>
</div>
<div>
<span class="muted">${escapeHtml(item.summary)}</span>
${item.acknowledged ? `<div class="muted small">В работе: ${escapeHtml(item.assigned_to || item.actor || "оператор")} · ${escapeHtml(item.comment || "")}</div>` : ""}
</div>
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
<div class="actions">
<button class="small-button" data-incident-action="ack" data-incident-id="${escapeHtml(item.id)}"${item.acknowledged ? " disabled" : ""}>В работу</button>
<button class="small-button" data-incident-action="assign" data-incident-id="${escapeHtml(item.id)}">Назначить</button>
</div>
</div>
`).join("")}</div>`;
}
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 `<p class="muted">Активных DLP/ИБ-инцидентов нет.</p>`;
return renderIncidentsList(dlpItems);
}
function renderDlpEvidence(evidence) {
if (!evidence) return `<p class="muted">Данные evidence загружаются.</p>`;
if (!evidence.ok) return `<p class="muted">Evidence недоступны: ${escapeHtml(evidence.error || "ошибка чтения")}</p>`;
const items = evidence.items || [];
if (items.length === 0) return `<p class="muted">DLP evidence пока не найдены.</p>`;
return `<div class="list evidence-list">${items.map(item => `
<div class="row evidence-row">
<div>
<strong>${escapeHtml(item.signal_type || item.source || item.stream_type)}</strong>
<div class="muted small">${escapeHtml(item.event_ts)} · ${escapeHtml(item.hostname)}${item.username ? " · " + escapeHtml(item.username) : ""}</div>
</div>
<div>
<span class="muted">${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)}</span>
<div class="muted small">${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "SHA-256: " + escapeHtml(item.screenshot_sha256) : escapeHtml(item.blocked_reason || "без скрина")}</div>
</div>
<span class="badge ${item.screenshot_available ? "status-ok" : "status-warn"}">${item.screenshot_available ? "СКРИН" : "МЕТА"}</span>
<div class="actions">
${item.preview_url ? `<a class="small-button" href="${escapeHtml(item.preview_url)}" target="_blank" rel="noopener noreferrer">Открыть</a>` : ""}
${item.download_url ? `<a class="small-button" href="${escapeHtml(item.download_url)}" target="_blank" rel="noopener noreferrer">Скачать</a>` : ""}
</div>
</div>
`).join("")}</div>`;
}
function renderIncidents(data) {
const links = state.links || {};
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
<h2 class="section-title">Инциденты ИБ</h2>
<div class="grid-2">
<section class="card">
<h3>DLP-инциденты</h3>
${renderDlpIncidentsList(incidents)}
</section>
<section class="card">
<h3>Графики и дашборды</h3>
${renderDlpLinks(links)}
</section>
</div>
<section class="card evidence-card">
<h3>Доказательства</h3>
${renderDlpEvidence(evidence)}
</section>
`;
}
function renderKpiCards(items) {
return `<div class="summary-grid kpi-grid">${(items || []).map(item => `
<article class="card kpi-card">
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status || "INFO")}</span>
<h3>${escapeHtml(item.label)}</h3>
<p class="kpi-value">${escapeHtml(item.value)}</p>
<p class="muted">${escapeHtml(item.context || "")}</p>
</article>
`).join("")}</div>`;
}
function renderReportSections(sections) {
return `<div class="grid-2">${(sections || []).map(section => `
<section class="card report-section">
<h3>${escapeHtml(section.title)}</h3>
<div class="list compact-list">${(section.items || []).map(item => `
<div class="row compact-row">
<strong>${escapeHtml(item.label)}</strong>
<span class="muted">${escapeHtml(item.value)}</span>
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status || "INFO")}</span>
</div>
`).join("")}</div>
</section>
`).join("")}</div>`;
}
function renderReports(data) {
return `
<h2 class="section-title">Отчеты</h2>
<div class="grid-2">
<section class="card report-hero">
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
<h3>${escapeHtml(data.headline)}</h3>
<p class="muted">${escapeHtml(data.period || "")} · обновлено ${escapeHtml(data.generated_at_utc || "")}</p>
</section>
<section class="card">
<h3>Для руководителя</h3>
<div class="list compact-list">${(data.executive_points || []).map(point => `
<div class="row compact-row"><strong>Итог</strong><span class="muted">${escapeHtml(point)}</span><span></span></div>
`).join("")}</div>
</section>
</div>
<div class="report-actions">
<button class="small-button" data-print-report="true">Печать / PDF</button>
</div>
<h3 class="section-title">Ключевые показатели</h3>
${renderKpiCards(data.kpis)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
<h3 class="section-title">Срезы отчета</h3>
${renderReportSections(data.sections)}
<section class="card markdown-card">
<h3>Markdown для отчета</h3>
<pre>${escapeHtml(data.markdown || "")}</pre>
</section>
`;
}
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 = `<p class="muted">Загрузка...</p>`;
refresh().catch(showError);
}
function showError(error) {
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
}
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);