feat(portal): localize management UI

This commit is contained in:
igor04091968
2026-06-04 20:00:15 +03:00
parent dfecc637b5
commit 0992679100
5 changed files with 361 additions and 320 deletions
File diff suppressed because it is too large Load Diff
+101 -73
View File
@@ -54,10 +54,28 @@ function escapeHtml(value) {
function displayText(value) { function displayText(value) {
return String(value ?? "") return String(value ?? "")
.replaceAll("Risk Narrative", "Главный вывод")
.replaceAll("Risk Heatmap", "Карта рисков")
.replaceAll("Business Risk", "Риски подразделений")
.replaceAll("Security Correlation", "Связь рисков и активности")
.replaceAll("Incident Candidates", "Требует проверки")
.replaceAll("Incident Candidate", "Требует проверки")
.replaceAll("Critical Candidates", "Срочно проверить")
.replaceAll("Open Cases", "Активные расследования")
.replaceAll("Agent Coverage", "Полнота данных")
.replaceAll("Coverage SLA", "Полнота данных")
.replaceAll("Agent Quality", "Качество данных")
.replaceAll("Trust Score", "Уровень доверия")
.replaceAll("Trust KPI", "Достоверность показателей")
.replaceAll("Correlation Score", "Уровень взаимосвязи")
.replaceAll("Forensics Readiness", "Готовность к расследованию")
.replaceAll("Executive Dashboard", "Сводка руководителя")
.replaceAll("Executive Summary", "Краткий вывод")
.replaceAll("Cases", "Расследования")
.replaceAll("Workforce", "Работа сотрудников") .replaceAll("Workforce", "Работа сотрудников")
.replaceAll("workforce", "работа сотрудников") .replaceAll("workforce", "работа сотрудников")
.replaceAll("Security", "Безопасность") .replaceAll("Security", "Безопасность")
.replaceAll("Forensics", "Расследования") .replaceAll("Forensics", "Материалы расследования")
.replaceAll("UEBA риск", "Индекс риска") .replaceAll("UEBA риск", "Индекс риска")
.replaceAll("UEBA", "оценка риска") .replaceAll("UEBA", "оценка риска")
.replaceAll("DLP/ИБ", "ИБ") .replaceAll("DLP/ИБ", "ИБ")
@@ -88,6 +106,7 @@ function displayText(value) {
.replaceAll("Management report", "Сводка руководителя") .replaceAll("Management report", "Сводка руководителя")
.replaceAll("Drill-down", "Разбор") .replaceAll("Drill-down", "Разбор")
.replaceAll("KPI", "показатель") .replaceAll("KPI", "показатель")
.replaceAll("SLA", "полнота данных")
.replaceAll("items", "записи") .replaceAll("items", "записи")
.replaceAll("medium risk", "средний риск") .replaceAll("medium risk", "средний риск")
.replaceAll("low risk", "низкий риск") .replaceAll("low risk", "низкий риск")
@@ -102,6 +121,11 @@ function displayText(value) {
.replaceAll("dept:", "подразделение:") .replaceAll("dept:", "подразделение:")
.replace(/\byes\b/g, "да") .replace(/\byes\b/g, "да")
.replace(/\bno\b/g, "нет") .replace(/\bno\b/g, "нет")
.replace(/\bsource\b/g, "источник")
.replace(/\bunknown\b/g, "неизвестно")
.replace(/\baccepted\b/g, "принято")
.replace(/\bopen_cases\b/g, "активные расследования")
.replace(/\bcritical_candidates\b/g, "срочно проверить")
.replaceAll("daily", "день") .replaceAll("daily", "день")
.replaceAll("weekly", "неделя") .replaceAll("weekly", "неделя")
.replaceAll("monthly", "месяц") .replaceAll("monthly", "месяц")
@@ -114,6 +138,10 @@ function ui(value) {
return escapeHtml(displayText(value)); return escapeHtml(displayText(value));
} }
function tooltip(value) {
return `title="${ui(value)}"`;
}
function renderSummary(summary, readiness) { function renderSummary(summary, readiness) {
const global = document.getElementById("globalStatus"); const global = document.getElementById("globalStatus");
global.className = `status-pill ${statusClass(summary.severity)}`; global.className = `status-pill ${statusClass(summary.severity)}`;
@@ -475,17 +503,17 @@ function renderExecutiveDashboard(report) {
<section class="card executive-dashboard-card"> <section class="card executive-dashboard-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Сводка руководителя</h3> <h3 ${tooltip("Главная сводка для руководителя: что происходит в организации прямо сейчас.")}>Сводка руководителя</h3>
<p class="muted">Что происходит в организации прямо сейчас: доверие к KPI, покрытие, риски, дела и готовность расследований.</p> <p class="muted">Что происходит в организации прямо сейчас: достоверность показателей, полнота данных, риски, активные расследования и готовность к расследованию.</p>
</div> </div>
<span class="badge ${statusClass(executiveDashboardStatus(dashboard))}">${ui(dashboard.forensics_readiness || "UNKNOWN")}</span> <span class="badge ${statusClass(executiveDashboardStatus(dashboard))}">${ui(dashboard.forensics_readiness || "UNKNOWN")}</span>
</div> </div>
<div class="quality-grid"> <div class="quality-grid">
<div><span class="muted">Trust KPI</span><strong>${ui(optionalPercent(dashboard.trust_kpi_score))}</strong></div> <div ${tooltip("Можно ли доверять рассчитанным показателям активности.")}><span class="muted">Достоверность показателей</span><strong>${ui(optionalPercent(dashboard.trust_kpi_score))}</strong></div>
<div><span class="muted">Покрытие агентов</span><strong>${ui(optionalPercent(dashboard.agent_coverage_pct))}</strong></div> <div ${tooltip("Какая часть рабочих мест прислала свежие подтвержденные данные.")}><span class="muted">Полнота данных</span><strong>${ui(optionalPercent(dashboard.agent_coverage_pct))}</strong></div>
<div><span class="muted">Высокий риск</span><strong>${ui(highRisk.length)}</strong></div> <div><span class="muted">Высокий риск</span><strong>${ui(highRisk.length)}</strong></div>
<div><span class="muted">Кандидаты</span><strong>${ui(candidates.length)}</strong></div> <div ${tooltip("Сколько записей нужно срочно проверить вручную.")}><span class="muted">Срочно проверить</span><strong>${ui(candidates.length)}</strong></div>
<div><span class="muted">Открытые дела</span><strong>${ui(dashboard.open_cases ?? 0)}</strong></div> <div ${tooltip("Сколько расследований сейчас в работе.")}><span class="muted">Активные расследования</span><strong>${ui(dashboard.open_cases ?? 0)}</strong></div>
<div><span class="muted">Закрыто за 30 дней</span><strong>${ui(dashboard.resolved_cases_30d ?? 0)}</strong></div> <div><span class="muted">Закрыто за 30 дней</span><strong>${ui(dashboard.resolved_cases_30d ?? 0)}</strong></div>
</div> </div>
<div class="list compact-list executive-summary-list"> <div class="list compact-list executive-summary-list">
@@ -505,14 +533,14 @@ function renderRiskNarrative(report) {
<section class="card risk-narrative-card"> <section class="card risk-narrative-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Связанная картина риска</h3> <h3 ${tooltip("Главный управленческий вывод: что случилось, почему это риск и чем это подтверждается.")}>Главный вывод</h3>
<p class="muted">Главный управленческий вывод: что происходит, почему это риск и какие слои это подтверждают.</p> <p class="muted">Главный управленческий вывод: что происходит, почему это риск и какие слои это подтверждают.</p>
</div> </div>
<span class="badge ${statusClass(narrativeStatus)}">${ui(narrativeStatus)}</span> <span class="badge ${statusClass(narrativeStatus)}">${ui(narrativeStatus)}</span>
</div> </div>
<div class="list compact-list executive-summary-list"> <div class="list compact-list executive-summary-list">
<div class="row compact-row"><strong>Главная причина риска</strong><span class="muted">${ui(summary.main_risk_cause || "связанный риск не выражен")}</span><span class="badge ${statusClass(narrativeStatus)}">${ui(narrativeStatus)}</span></div> <div class="row compact-row"><strong>Главная причина риска</strong><span class="muted">${ui(summary.main_risk_cause || "связанный риск не выражен")}</span><span class="badge ${statusClass(narrativeStatus)}">${ui(narrativeStatus)}</span></div>
<div class="row compact-row"><strong>Подтверждающие слои</strong><span class="muted">Trust KPI · Agent Coverage · Business Risk · Risk Heatmap · Security Correlation · Incident Candidates · Cases</span><span></span></div> <div class="row compact-row"><strong>Подтверждающие слои</strong><span class="muted">Достоверность показателей · Полнота данных · Риски подразделений · Карта рисков · Связь рисков и активности · Требует проверки · Расследования</span><span></span></div>
</div> </div>
</section> </section>
`; `;
@@ -898,7 +926,7 @@ function renderDepartmentLeaderCard(report) {
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Карточка руководителя подразделения</h3> <h3>Карточка руководителя подразделения</h3>
<p class="muted">Read-only срез для поручения разбора ответственному.</p> <p class="muted">Срез только для чтения, чтобы поручить разбор ответственному.</p>
</div> </div>
<span class="badge ${statusClass(row.status)}">${ui(row.risk)}</span> <span class="badge ${statusClass(row.status)}">${ui(row.risk)}</span>
</div> </div>
@@ -1418,7 +1446,7 @@ function renderDlpEvidence(evidence) {
<span class="muted">${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)}</span> <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 ? "Контрольный хеш: " + escapeHtml(item.screenshot_sha256) : ui(item.blocked_reason || "без скрина")}</div> <div class="muted small">${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "Контрольный хеш: " + escapeHtml(item.screenshot_sha256) : ui(item.blocked_reason || "без скрина")}</div>
</div> </div>
<span class="badge ${item.screenshot_available ? "status-ok" : "status-warn"}">${item.screenshot_available ? "СКРИН" : "МЕТА"}</span> <span class="badge ${item.screenshot_available ? "status-ok" : "status-warn"}">${item.screenshot_available ? "скриншот" : "метаданные"}</span>
<div class="actions"> <div class="actions">
${item.preview_url ? `<a class="small-button" href="${escapeHtml(item.preview_url)}" target="_blank" rel="noopener noreferrer">Открыть</a>` : ""} ${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>` : ""} ${item.download_url ? `<a class="small-button" href="${escapeHtml(item.download_url)}" target="_blank" rel="noopener noreferrer">Скачать</a>` : ""}
@@ -1440,7 +1468,7 @@ function buildAutoInvestigation(report) {
const summary = risk.label || risk.code || risky?.reason || "Система сформировала риск-сигнал для ручной проверки"; const summary = risk.label || risk.code || risky?.reason || "Система сформировала риск-сигнал для ручной проверки";
return { return {
incident_id: `auto-${String(department).toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-").replace(/^-|-$/g, "") || "risk"}`, incident_id: `auto-${String(department).toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-").replace(/^-|-$/g, "") || "risk"}`,
risk_id: risk.code || risk.label || "workforce-ueba-risk", risk_id: risk.code || risk.label || "risk-check",
department, department,
owner, owner,
activity_index: risky?.activityText || "нет данных", activity_index: risky?.activityText || "нет данных",
@@ -1448,18 +1476,18 @@ function buildAutoInvestigation(report) {
status, status,
summary, summary,
why_it_is_risk: risky?.reason || risk.value || "риск может указывать на просадку активности, отклонение от нормы или событие безопасности", why_it_is_risk: risky?.reason || risk.value || "риск может указывать на просадку активности, отклонение от нормы или событие безопасности",
what_to_check: risky?.check || risk.recommendation || "проверить первичные события ActivityWatch, RDP/1C активность, процессы и сетевые сигналы", what_to_check: risky?.check || risk.recommendation || "проверить первичные события ActivityWatch, удаленный доступ, активность в 1С, процессы и сетевые сигналы",
recommended_actions: [ recommended_actions: [
"назначить ответственного за ручную проверку", "назначить ответственного за ручную проверку",
"сопоставить риск с журналами активности и бизнес-задачей", "сопоставить риск с журналами активности и бизнес-задачей",
"зафиксировать вывод в отчете по инциденту" "зафиксировать вывод в отчете по инциденту"
], ],
evidence: [ evidence: [
"RDP activity: проверяется по событиям рабочего времени", "удаленные сеансы: проверяются по событиям рабочего времени",
"process activity: проверяется по процессам и приложениям", "процессы: проверяются по приложениям",
"network activity: проверяется по сетевым сигналам", "сеть: проверяется по сетевым сигналам",
"proxy activity: подключается как внешний источник", "внешний шлюз: подключается как дополнительный источник",
"pfSense events: учитываются при наличии интеграционного слоя" "периметр: учитывается при наличии интеграционного слоя"
], ],
generated_at: generated generated_at: generated
}; };
@@ -1477,20 +1505,20 @@ function renderAutoInvestigationCard(report) {
<span class="badge ${statusClass(card.status)}">${ui(card.status)}</span> <span class="badge ${statusClass(card.status)}">${ui(card.status)}</span>
</div> </div>
<div class="investigation-grid"> <div class="investigation-grid">
<div><span class="muted">incident_id</span><strong>${ui(card.incident_id)}</strong></div> <div><span class="muted">Номер</span><strong>${ui(card.incident_id)}</strong></div>
<div><span class="muted">risk_id</span><strong>${ui(card.risk_id)}</strong></div> <div><span class="muted">Риск</span><strong>${ui(card.risk_id)}</strong></div>
<div><span class="muted">department</span><strong>${ui(card.department)}</strong></div> <div><span class="muted">Подразделение</span><strong>${ui(card.department)}</strong></div>
<div><span class="muted">owner</span><strong>${ui(card.owner)}</strong></div> <div><span class="muted">Ответственный</span><strong>${ui(card.owner)}</strong></div>
<div><span class="muted">activity_index</span><strong>${ui(card.activity_index)}</strong></div> <div><span class="muted">Индекс активности</span><strong>${ui(card.activity_index)}</strong></div>
<div><span class="muted">deviation</span><strong>${ui(card.deviation)}</strong></div> <div><span class="muted">Отклонение</span><strong>${ui(card.deviation)}</strong></div>
<div><span class="muted">generated_at</span><strong>${ui(card.generated_at)}</strong></div> <div><span class="muted">Сформировано</span><strong>${ui(card.generated_at)}</strong></div>
</div> </div>
<div class="list compact-list"> <div class="list compact-list">
<div class="row compact-row"><strong>summary</strong><span class="muted">${ui(card.summary)}</span><span></span></div> <div class="row compact-row"><strong>Краткое описание</strong><span class="muted">${ui(card.summary)}</span><span></span></div>
<div class="row compact-row"><strong>why_it_is_risk</strong><span class="muted">${ui(card.why_it_is_risk)}</span><span></span></div> <div class="row compact-row"><strong>Почему это риск</strong><span class="muted">${ui(card.why_it_is_risk)}</span><span></span></div>
<div class="row compact-row"><strong>what_to_check</strong><span class="muted">${ui(card.what_to_check)}</span><span></span></div> <div class="row compact-row"><strong>Что проверить</strong><span class="muted">${ui(card.what_to_check)}</span><span></span></div>
<div class="row compact-row"><strong>recommended_actions</strong><span class="muted">${ui(card.recommended_actions.join("; "))}</span><span></span></div> <div class="row compact-row"><strong>Рекомендуемые действия</strong><span class="muted">${ui(card.recommended_actions.join("; "))}</span><span></span></div>
<div class="row compact-row"><strong>evidence</strong><span class="muted">${ui(card.evidence.join("; "))}</span><span></span></div> <div class="row compact-row"><strong>Материалы расследования</strong><span class="muted">${ui(card.evidence.join("; "))}</span><span></span></div>
</div> </div>
</section> </section>
`; `;
@@ -1664,8 +1692,8 @@ function fallbackAgentQualityExplain(quality) {
if (source === "wts_api" && !hasError) { if (source === "wts_api" && !hasError) {
return { return {
status: "OK", status: "OK",
title: "Данные агента подтверждают KPI", title: "Данные агента подтверждают показатели",
summary: "Сессии собраны основным способом через Windows WTS API; индекс активности можно использовать как рабочий управленческий KPI.", summary: "Сессии собраны основным способом Windows; индекс активности можно использовать как рабочий управленческий показатель.",
recommendation: "Использовать отчет как подтвержденный оперативный срез.", recommendation: "Использовать отчет как подтвержденный оперативный срез.",
kpi_accepted: true kpi_accepted: true
}; };
@@ -1674,7 +1702,7 @@ function fallbackAgentQualityExplain(quality) {
return { return {
status: "DEGRADED", status: "DEGRADED",
title: "Диагностический режим агента", title: "Диагностический режим агента",
summary: "Диагностический режим, данные не засчитываются в KPI.", summary: "Диагностический режим, данные не засчитываются в показатели активности.",
recommendation: "Проверить доступность WTS API и права запуска агента.", recommendation: "Проверить доступность WTS API и права запуска агента.",
kpi_accepted: false kpi_accepted: false
}; };
@@ -1684,7 +1712,7 @@ function fallbackAgentQualityExplain(quality) {
status: "DEGRADED", status: "DEGRADED",
title: "Достоверность данных снижена", title: "Достоверность данных снижена",
summary: `Коллектор передал ошибку: ${q.collector_error}`, summary: `Коллектор передал ошибку: ${q.collector_error}`,
recommendation: "Восстановить основной путь WTS API перед использованием отчета как доказательной базы.", recommendation: "Восстановить основной способ сбора Windows перед использованием отчета как доказательной базы.",
kpi_accepted: false kpi_accepted: false
}; };
} }
@@ -1708,17 +1736,17 @@ function renderAgentQuality(quality, explain) {
<section class="card agent-quality-card" id="trust-kpi-section"> <section class="card agent-quality-card" id="trust-kpi-section">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Достоверность данных агента</h3> <h3 ${tooltip("Показывает, можно ли использовать данные агента для управленческих показателей.")}>Качество данных</h3>
<p class="muted">${ui(e.title || "Оценка доверия к данным агента")}</p> <p class="muted">${ui(e.title || "Оценка доверия к данным агента")}</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
<p class="quality-summary">${ui(e.summary || "")}</p> <p class="quality-summary">${ui(e.summary || "")}</p>
<div class="quality-decision"> <div class="quality-decision">
<div><span class="muted">Принято в KPI</span><strong>${accepted ? "да" : "нет"}</strong></div> <div><span class="muted">Участвует в показателях</span><strong>${accepted ? "да" : "нет"}</strong></div>
<div><span class="muted">Источник</span><strong>${ui(source)}</strong></div> <div><span class="muted">Источник</span><strong>${ui(source)}</strong></div>
</div> </div>
${warn ? `<div class="quality-warning">Внимание. Данные активности собраны не основным способом. Точность определения активности и RDP-сессий может быть снижена.</div>` : ""} ${warn ? `<div class="quality-warning">Внимание. Данные активности собраны не основным способом. Точность определения активности и удаленных сеансов может быть снижена.</div>` : ""}
<p class="muted">${ui(e.recommendation || "")}</p> <p class="muted">${ui(e.recommendation || "")}</p>
<details class="quality-details"> <details class="quality-details">
<summary>Технические детали</summary> <summary>Технические детали</summary>
@@ -1726,7 +1754,7 @@ function renderAgentQuality(quality, explain) {
<div><span class="muted">Источник коллектора</span><strong>${ui(source)}</strong></div> <div><span class="muted">Источник коллектора</span><strong>${ui(source)}</strong></div>
<div><span class="muted">Всего сессий</span><strong>${escapeHtml(q.sessions_collected_total ?? 0)}</strong></div> <div><span class="muted">Всего сессий</span><strong>${escapeHtml(q.sessions_collected_total ?? 0)}</strong></div>
<div><span class="muted">Активных сессий</span><strong>${escapeHtml(q.active_sessions_total ?? 0)}</strong></div> <div><span class="muted">Активных сессий</span><strong>${escapeHtml(q.active_sessions_total ?? 0)}</strong></div>
<div><span class="muted">RDP-сессий</span><strong>${escapeHtml(q.rdp_sessions_total ?? 0)}</strong></div> <div><span class="muted">Удаленных сеансов</span><strong>${escapeHtml(q.rdp_sessions_total ?? 0)}</strong></div>
<div><span class="muted">Ошибка коллектора</span><strong>${q.collector_error ? ui(q.collector_error) : "нет"}</strong></div> <div><span class="muted">Ошибка коллектора</span><strong>${q.collector_error ? ui(q.collector_error) : "нет"}</strong></div>
</div> </div>
</details> </details>
@@ -1744,22 +1772,22 @@ function renderAgentQualityHistory(history, summary) {
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Стабильность агента за 7 дней</h3> <h3>Стабильность агента за 7 дней</h3>
<p class="muted">Показывает, можно ли доверять недельному KPI, а не только текущему срезу.</p> <p class="muted">Показывает, можно ли доверять недельным показателям, а не только текущему срезу.</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
<div class="quality-decision"> <div class="quality-decision">
<div><span class="muted">OK дней</span><strong>${escapeHtml(s.ok_days ?? 0)}</strong></div> <div><span class="muted">OK дней</span><strong>${escapeHtml(s.ok_days ?? 0)}</strong></div>
<div><span class="muted">Проблемных дней</span><strong>${escapeHtml(unstableDays)}</strong></div> <div><span class="muted">Проблемных дней</span><strong>${escapeHtml(unstableDays)}</strong></div>
<div><span class="muted">KPI принят</span><strong>${escapeHtml(s.kpi_accepted_pct ?? 0)}%</strong></div> <div><span class="muted">Показатели подтверждены</span><strong>${escapeHtml(s.kpi_accepted_pct ?? 0)}%</strong></div>
</div> </div>
${Number(s.ok_days || 0) < 5 ? `<div class="quality-warning">KPI требует валидации: нестабильный сбор данных агента.</div>` : ""} ${Number(s.ok_days || 0) < 5 ? `<div class="quality-warning">Показатели требуют проверки: нестабильный сбор данных агента.</div>` : ""}
<details class="quality-details"> <details class="quality-details">
<summary>История по дням</summary> <summary>История по дням</summary>
<div class="list compact-list">${items.length ? items.map(item => ` <div class="list compact-list">${items.length ? items.map(item => `
<div class="row compact-row"> <div class="row compact-row">
<strong>${escapeHtml(item.date || "-")}</strong> <strong>${escapeHtml(item.date || "-")}</strong>
<span class="muted">source=${ui(item.source || "unknown")} · KPI=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""}</span> <span class="muted">источник=${ui(item.source || "unknown")} · показатели=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""}</span>
<span class="badge ${statusClass(item.status)}">${ui(item.status || "UNKNOWN")}</span> <span class="badge ${statusClass(item.status)}">${ui(item.status || "UNKNOWN")}</span>
</div> </div>
`).join("") : `<div class="row compact-row"><strong>История</strong><span class="muted">История качества агента за период отсутствует.</span><span class="badge status-unknown">UNKNOWN</span></div>`}</div> `).join("") : `<div class="row compact-row"><strong>История</strong><span class="muted">История качества агента за период отсутствует.</span><span class="badge status-unknown">UNKNOWN</span></div>`}</div>
@@ -1781,7 +1809,7 @@ function renderAgentQualityNodes(nodes, summary) {
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Качество данных по рабочим местам</h3> <h3>Качество данных по рабочим местам</h3>
<p class="muted">Какие узлы подтверждают KPI, а какие снижают доверие к управленческой аналитике.</p> <p class="muted">Какие рабочие места подтверждают показатели, а какие снижают доверие к управленческой аналитике.</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
@@ -1789,9 +1817,9 @@ function renderAgentQualityNodes(nodes, summary) {
<div><span class="muted">Всего узлов</span><strong>${escapeHtml(s.total_nodes ?? 0)}</strong></div> <div><span class="muted">Всего узлов</span><strong>${escapeHtml(s.total_nodes ?? 0)}</strong></div>
<div><span class="muted">OK</span><strong>${escapeHtml(s.ok_nodes ?? 0)}</strong></div> <div><span class="muted">OK</span><strong>${escapeHtml(s.ok_nodes ?? 0)}</strong></div>
<div><span class="muted">Проблемных</span><strong>${escapeHtml(Number(s.degraded_nodes || 0) + Number(s.unknown_nodes || 0))}</strong></div> <div><span class="muted">Проблемных</span><strong>${escapeHtml(Number(s.degraded_nodes || 0) + Number(s.unknown_nodes || 0))}</strong></div>
<div><span class="muted">KPI принят</span><strong>${escapeHtml(s.accepted_kpi_nodes_pct ?? 0)}%</strong></div> <div><span class="muted">Показатели подтверждены</span><strong>${escapeHtml(s.accepted_kpi_nodes_pct ?? 0)}%</strong></div>
</div> </div>
${Number(s.total_nodes || 0) > 0 && Number(s.accepted_kpi_nodes_pct || 0) < 80 ? `<div class="quality-warning">KPI требует проверки: менее 80% узлов дают подтвержденные данные.</div>` : ""} ${Number(s.total_nodes || 0) > 0 && Number(s.accepted_kpi_nodes_pct || 0) < 80 ? `<div class="quality-warning">Показатели требуют проверки: менее 80% рабочих мест дают подтвержденные данные.</div>` : ""}
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@@ -1800,7 +1828,7 @@ function renderAgentQualityNodes(nodes, summary) {
<th>Статус</th> <th>Статус</th>
<th>Источник</th> <th>Источник</th>
<th>Последняя телеметрия</th> <th>Последняя телеметрия</th>
<th>KPI</th> <th>Показатели</th>
<th>Рекомендация</th> <th>Рекомендация</th>
</tr> </tr>
</thead> </thead>
@@ -1840,8 +1868,8 @@ function renderAgentCoverageSla(sla) {
<section class="card agent-coverage-card" id="agent-coverage-section"> <section class="card agent-coverage-card" id="agent-coverage-section">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Покрытие агентов</h3> <h3 ${tooltip("Показывает, какая часть рабочих мест присылает свежие данные.")}>Полнота данных</h3>
<p class="muted">Показывает, насколько KPI репрезентативен по всему парку рабочих мест.</p> <p class="muted">Показывает, насколько показатели репрезентативны по всему парку рабочих мест.</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
@@ -1853,8 +1881,8 @@ function renderAgentCoverageSla(sla) {
<div><span class="muted">Покрытие</span><strong>${escapeHtml(s.coverage_pct ?? 0)}%</strong></div> <div><span class="muted">Покрытие</span><strong>${escapeHtml(s.coverage_pct ?? 0)}%</strong></div>
<div><span class="muted">Свежесть</span><strong>${escapeHtml(s.freshness_pct ?? 0)}%</strong></div> <div><span class="muted">Свежесть</span><strong>${escapeHtml(s.freshness_pct ?? 0)}%</strong></div>
</div> </div>
${status === "CRITICAL" ? `<div class="quality-warning">Покрытие агентов критически недостаточно: KPI не может считаться репрезентативным.</div>` : ""} ${status === "CRITICAL" ? `<div class="quality-warning">Полнота данных критически недостаточна: показатели нельзя считать репрезентативными.</div>` : ""}
${status === "WARNING" ? `<div class="quality-warning">KPI требует проверки: часть рабочих мест не присылает свежую телеметрию.</div>` : ""} ${status === "WARNING" ? `<div class="quality-warning">Показатели требуют проверки: часть рабочих мест не присылает свежую телеметрию.</div>` : ""}
${status === "UNKNOWN" ? `<p class="muted">Список ожидаемых рабочих мест не настроен.</p>` : ""} ${status === "UNKNOWN" ? `<p class="muted">Список ожидаемых рабочих мест не настроен.</p>` : ""}
<div class="table-scroll"> <div class="table-scroll">
<table class="data-table"> <table class="data-table">
@@ -1904,7 +1932,7 @@ function renderBusinessRisk(items) {
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Риски подразделений</h3> <h3>Риски подразделений</h3>
<p class="muted">Организационные зоны риска по доверию к KPI, активности, тренду и проблемным узлам.</p> <p class="muted">Организационные зоны риска по достоверности показателей, активности, тренду и проблемным рабочим местам.</p>
</div> </div>
<span class="badge ${statusClass(worst)}">${ui(worst)}</span> <span class="badge ${statusClass(worst)}">${ui(worst)}</span>
</div> </div>
@@ -1948,8 +1976,8 @@ function renderRiskHeatmap(items) {
<section class="card risk-heatmap-card"> <section class="card risk-heatmap-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Карта рисков подразделений</h3> <h3 ${tooltip("Таблица показывает подразделения, где одновременно есть несколько признаков риска.")}>Карта рисков</h3>
<p class="muted">Где одновременно слабый Trust KPI, низкая активность, плохое покрытие агентов, кандидаты и открытые дела.</p> <p class="muted">Где одновременно снижена достоверность показателей, низкая активность, неполные данные, записи на проверку и активные расследования.</p>
</div> </div>
<span class="badge ${statusClass(worst)}">${ui(worst)}</span> <span class="badge ${statusClass(worst)}">${ui(worst)}</span>
</div> </div>
@@ -1958,11 +1986,11 @@ function renderRiskHeatmap(items) {
<thead> <thead>
<tr> <tr>
<th>Подразделение</th> <th>Подразделение</th>
<th>Trust KPI</th> <th>Достоверность</th>
<th>Активность</th> <th>Активность</th>
<th>Покрытие</th> <th>Покрытие</th>
<th>Риск</th> <th>Риск</th>
<th>Дела</th> <th>Расследования</th>
<th>Связи</th> <th>Связи</th>
</tr> </tr>
</thead> </thead>
@@ -1975,7 +2003,7 @@ function renderRiskHeatmap(items) {
<td>${ui(riskPercentText(item.agent_coverage_pct))}</td> <td>${ui(riskPercentText(item.agent_coverage_pct))}</td>
<td> <td>
<span class="badge ${statusClass(item.heat_level)}">${ui(item.heat_level || "UNKNOWN")}</span><br> <span class="badge ${statusClass(item.heat_level)}">${ui(item.heat_level || "UNKNOWN")}</span><br>
<span class="muted small">${ui(item.business_risk_level || "UNKNOWN")} · кандидаты ${ui(item.critical_candidates ?? 0)}</span> <span class="muted small">${ui(item.business_risk_level || "UNKNOWN")} · проверить ${ui(item.critical_candidates ?? 0)}</span>
</td> </td>
<td>${ui(item.open_cases ?? 0)}</td> <td>${ui(item.open_cases ?? 0)}</td>
<td>${renderRiskLayerLinks(item.links)}</td> <td>${renderRiskLayerLinks(item.links)}</td>
@@ -2031,8 +2059,8 @@ function renderSecurityCorrelation(items) {
<section class="card security-correlation-card"> <section class="card security-correlation-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Корреляция Workforce и Security</h3> <h3 ${tooltip("Показывает, где проблемы активности совпадают с рисками проверки.")}>Связь рисков и активности</h3>
<p class="muted">Связь между падением активности, доверием к KPI, кандидатами в инциденты и открытыми делами. Инциденты автоматически не создаются.</p> <p class="muted">Связь между падением активности, достоверностью показателей, записями на проверку и активными расследованиями. Инциденты автоматически не создаются.</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
@@ -2041,11 +2069,11 @@ function renderSecurityCorrelation(items) {
<thead> <thead>
<tr> <tr>
<th>Подразделение</th> <th>Подразделение</th>
<th>Trust KPI</th> <th>Достоверность</th>
<th>Активность</th> <th>Активность</th>
<th>Бизнес-риск</th> <th>Риск подразделения</th>
<th>Security</th> <th>Проверки</th>
<th>Корреляция</th> <th>Взаимосвязь</th>
<th>Причина</th> <th>Причина</th>
</tr> </tr>
</thead> </thead>
@@ -2056,7 +2084,7 @@ function renderSecurityCorrelation(items) {
<td>${ui(riskPercentText(item.trust_kpi_score))}</td> <td>${ui(riskPercentText(item.trust_kpi_score))}</td>
<td>${ui(riskPercentText(item.activity_score))}</td> <td>${ui(riskPercentText(item.activity_score))}</td>
<td><span class="badge ${statusClass(item.business_risk_level)}">${ui(item.business_risk_level || "UNKNOWN")}</span></td> <td><span class="badge ${statusClass(item.business_risk_level)}">${ui(item.business_risk_level || "UNKNOWN")}</span></td>
<td>кандидаты ${ui(item.critical_candidates ?? 0)} · дела ${ui(item.open_cases ?? 0)}</td> <td>проверить ${ui(item.critical_candidates ?? 0)} · расследования ${ui(item.open_cases ?? 0)}</td>
<td><strong>${ui(Number(item.correlation_score || 0))}/100</strong></td> <td><strong>${ui(Number(item.correlation_score || 0))}/100</strong></td>
<td>${ui(item.explanation || item.correlation_reason || "связь не выражена")}</td> <td>${ui(item.explanation || item.correlation_reason || "связь не выражена")}</td>
</tr> </tr>
@@ -2066,7 +2094,7 @@ function renderSecurityCorrelation(items) {
<td>UNKNOWN</td> <td>UNKNOWN</td>
<td>UNKNOWN</td> <td>UNKNOWN</td>
<td><span class="badge status-unknown">UNKNOWN</span></td> <td><span class="badge status-unknown">UNKNOWN</span></td>
<td>кандидаты 0 · дела 0</td> <td>проверить 0 · расследования 0</td>
<td>0/100</td> <td>0/100</td>
<td>недостаточно данных по подразделениям</td> <td>недостаточно данных по подразделениям</td>
</tr> </tr>
@@ -2082,7 +2110,7 @@ function businessRiskReasons(item) {
const reasons = Array.isArray(item?.reasons) && item.reasons.length const reasons = Array.isArray(item?.reasons) && item.reasons.length
? item.reasons.join("; ") ? item.reasons.join("; ")
: "существенных причин не найдено"; : "существенных причин не найдено";
return `${reasons}. Trust ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных узлов ${item?.problem_nodes_count ?? 0}`; return `${reasons}. Доверие ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных рабочих мест ${item?.problem_nodes_count ?? 0}`;
} }
function renderBusinessRiskTimeline(history, summary) { function renderBusinessRiskTimeline(history, summary) {
@@ -2102,7 +2130,7 @@ function renderBusinessRiskTimeline(history, summary) {
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Динамика рисков</h3> <h3>Динамика рисков</h3>
<p class="muted">Как менялся бизнес-риск подразделений по накопленной daily history.</p> <p class="muted">Как менялись риски подразделений по накопленной ежедневной истории.</p>
</div> </div>
<span class="badge ${statusClass(status)}">${ui(status)}</span> <span class="badge ${statusClass(status)}">${ui(status)}</span>
</div> </div>
@@ -2155,8 +2183,8 @@ function renderRiskIncidentCandidates(items) {
<section class="card risk-candidates-card" id="risk-candidates-section"> <section class="card risk-candidates-card" id="risk-candidates-section">
<div class="section-head"> <div class="section-head">
<div> <div>
<h3>Кандидаты в инциденты</h3> <h3 ${tooltip("Очередь ситуаций, которые нужно проверить вручную перед созданием расследования.")}>Требует проверки</h3>
<p class="muted">Read-only очередь ручной проверки. Реальные инциденты автоматически не создаются.</p> <p class="muted">Очередь ручной проверки. Реальные инциденты автоматически не создаются.</p>
</div> </div>
<span class="badge ${statusClass(worst)}">${ui(worst)}</span> <span class="badge ${statusClass(worst)}">${ui(worst)}</span>
</div> </div>
@@ -2164,7 +2192,7 @@ function renderRiskIncidentCandidates(items) {
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>Номер</th>
<th>Подразделение</th> <th>Подразделение</th>
<th>Узел</th> <th>Узел</th>
<th>Риск</th> <th>Риск</th>
@@ -2201,7 +2229,7 @@ function renderRiskIncidentCandidates(items) {
</tbody> </tbody>
</table> </table>
</div> </div>
${rows.length ? `<p class="muted small">Показаны кандидаты для проверки, а не автоматически подтвержденные инциденты.</p>` : ""} ${rows.length ? `<p class="muted small">Показаны записи для проверки, а не автоматически подтвержденные инциденты.</p>` : ""}
</section> </section>
`; `;
} }
@@ -2269,7 +2297,7 @@ function reviewStatusText(status) {
function candidateReasonText(item) { function candidateReasonText(item) {
const evidence = Array.isArray(item?.evidence) && item.evidence.length const evidence = Array.isArray(item?.evidence) && item.evidence.length
? ` Evidence: ${item.evidence.join("; ")}` ? ` Материалы: ${item.evidence.join("; ")}`
: ""; : "";
return `${item?.reason || "требуется проверка"}.${evidence}`; return `${item?.reason || "требуется проверка"}.${evidence}`;
} }
@@ -2362,7 +2390,7 @@ function settingRows(report) {
["Рабочий день", `${workdayHours} ч`, `роль: ${policy.role_label || activeRole.label || policy.default_role || "default"}`], ["Рабочий день", `${workdayHours} ч`, `роль: ${policy.role_label || activeRole.label || policy.default_role || "default"}`],
["Порог WARN", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"], ["Порог WARN", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"],
["Порог FAIL", ">= 70 баллов", "высокий риск требует приоритетного разбора"], ["Порог FAIL", ">= 70 баллов", "высокий риск требует приоритетного разбора"],
["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `UEBA policy: ${basename(risk.policy_path)}` : "UEBA policy: встроенная модель"], ["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `правила оценки риска: ${basename(risk.policy_path)}` : "правила оценки риска: встроенная модель"],
["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`], ["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`],
]; ];
} }
@@ -2373,7 +2401,7 @@ function renderSettings(report) {
<div class="page-head"> <div class="page-head">
<div> <div>
<h2 class="section-title">Настройки</h2> <h2 class="section-title">Настройки</h2>
<p class="muted">Read-only параметры расчета и границы интерпретации данных.</p> <p class="muted">Параметры расчета и границы интерпретации данных. Экран открыт только для чтения.</p>
</div> </div>
<span class="badge status-ok">только чтение</span> <span class="badge status-ok">только чтение</span>
</div> </div>
@@ -11,7 +11,7 @@
<aside class="sidebar" aria-label="Основная навигация"> <aside class="sidebar" aria-label="Основная навигация">
<div class="sidebar-brand"> <div class="sidebar-brand">
<strong>AWatch-rus</strong> <strong>AWatch-rus</strong>
<span>Workforce-first</span> <span>Активность и риски</span>
</div> </div>
<nav class="tabs"> <nav class="tabs">
<button class="tab is-active" data-tab="operator">Обзор</button> <button class="tab is-active" data-tab="operator">Обзор</button>
@@ -30,7 +30,7 @@
<div class="brand"> <div class="brand">
<p class="eyebrow">AWatch-rus</p> <p class="eyebrow">AWatch-rus</p>
<h1>Пульс организации</h1> <h1>Пульс организации</h1>
<p class="lead">Workforce, UEBA и события безопасности за выбранный период</p> <p class="lead">Активность сотрудников, риски подразделений и материалы расследований за выбранный период</p>
</div> </div>
<div class="topbar-side" aria-label="Параметры отчета"> <div class="topbar-side" aria-label="Параметры отчета">
<div class="control-strip"> <div class="control-strip">
+60 -60
View File
@@ -5,24 +5,24 @@
Портал является рабочим кабинетом для трех ролей: Портал является рабочим кабинетом для трех ролей:
- руководитель: пульс организации, активность, подразделения, риски; - руководитель: пульс организации, активность, подразделения, риски;
- ИБ: риск-сигналы, события безопасности, evidence; - ИБ: риск-сигналы, события безопасности, материалы проверки;
- расследователь: карточка инцидента, причины, проверяемые источники, отчет. - расследователь: карточка инцидента, причины, проверяемые источники, отчет.
## Главные экраны ## Главные экраны
- `Обзор` - Executive Dashboard. - `Обзор` - сводка руководителя и главный вывод.
- `Сотрудники` - карточки сотрудников и объяснение индекса. - `Сотрудники` - карточки сотрудников и объяснение индекса.
- `Подразделения` - сравнение групп, тренды и ответственные. - `Подразделения` - сравнение групп, тренды и ответственные.
- `Риски` - read-only контроль безопасности. - `Риски` - контроль безопасности только для чтения.
- `Расследования` - инциденты, evidence и автоматическая read-only карточка расследования. - `Расследования` - инциденты, материалы проверки и автоматическая карточка расследования только для чтения.
- `Сетевой периметр` - read-only контекст внешних сетевых сигналов. - `Сетевой периметр` - внешний сетевой контекст только для чтения.
- `Отчеты` - Markdown/PDF/JSON и управленческий текст. - `Отчеты` - текстовый отчет, печать/PDF, выгрузка данных и управленческий текст.
- `Настройки` - read-only параметры расчета. - `Настройки` - параметры расчета только для чтения.
## Загрузка и актуальность данных ## Загрузка и актуальность данных
В верхней части портала есть глобальная полоса `Data Loading & Refresh В верхней части портала есть глобальная полоса `Загрузка и актуальность
Status`. Она показывает, что сейчас происходит с данными, и не смешивает данных`. Она показывает, что сейчас происходит с данными, и не смешивает
состояния `нет данных` и `данные еще загружаются`. состояния `нет данных` и `данные еще загружаются`.
Статусы: Статусы:
@@ -35,13 +35,13 @@ Status`. Она показывает, что сейчас происходит
завершилось; завершилось;
- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет. - `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет.
Во время `LOADING` портал показывает skeleton placeholders и текст `Данные Во время `LOADING` портал показывает заготовки карточек и текст `Данные
загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до
завершения загрузки. После успешного обновления отображается время последнего завершения загрузки. После успешного обновления отображается время последнего
обновления. При `STALE` старый экран остается доступным, но сверху появляется обновления. При `STALE` старый экран остается доступным, но сверху появляется
предупреждение о неудачном обновлении. предупреждение о неудачном обновлении.
## Executive Dashboard ## Сводка руководителя
Показывает: Показывает:
@@ -50,16 +50,16 @@ Status`. Она показывает, что сейчас происходит
- WARN/FAIL подразделения; - WARN/FAIL подразделения;
- критические риски; - критические риски;
- недельный тренд; - недельный тренд;
- достоверность данных агента; - качество данных;
- топ-5 лучших и проблемных подразделений; - топ-5 лучших и проблемных подразделений;
- Heat Map; - карту рисков;
- блок `Требует внимания`. - блок `Требует внимания`.
## Достоверность данных агента ## Качество данных
Портал показывает карточку `Достоверность данных агента` в `Обзор` и Портал показывает карточку `Качество данных` в `Обзор` и
`Отчеты`. Это управленческий вывод о том, можно ли использовать текущий `Отчеты`. Это управленческий вывод о том, можно ли использовать текущий
`Индекс активности` и отчеты как подтвержденный KPI. `Индекс активности` и отчеты как подтвержденные показатели.
API сохраняет старое поле `agent_quality` и дополнительно отдает API сохраняет старое поле `agent_quality` и дополнительно отдает
`agent_quality_explain`: `agent_quality_explain`:
@@ -68,32 +68,32 @@ API сохраняет старое поле `agent_quality` и дополнит
- `title`; - `title`;
- `summary`; - `summary`;
- `recommendation`; - `recommendation`;
- `kpi_accepted`. - `kpi_accepted` - участвуют ли данные в показателях.
Цвета статусов: Цвета статусов:
- `OK` - зеленый, KPI подтвержден основным источником; - `OK` - зеленый, показатели подтверждены основным источником;
- `WARNING` - желтый, данные собраны резервным способом; - `WARNING` - желтый, данные собраны резервным способом;
- `DEGRADED` - оранжевый, данные нельзя использовать как доказательный KPI; - `DEGRADED` - оранжевый, данные нельзя использовать как доказательные показатели;
- `UNKNOWN` - серый, агент не передал diagnostics. - `UNKNOWN` - серый, агент не передал диагностику качества.
Карточка сначала показывает управленческий вывод, принято ли значение в KPI, и Карточка сначала показывает управленческий вывод, участвуют ли данные в показателях, и
рекомендацию. Технические поля доступны в раскрываемом блоке: рекомендацию. Технические поля доступны в раскрываемом блоке:
- источник коллектора; - источник коллектора;
- всего сессий; - всего сессий;
- активных сессий; - активных сессий;
- RDP-сессий; - удаленных сеансов;
- ошибка коллектора, если она есть. - ошибка коллектора, если она есть.
Если diagnostics отсутствует, статус `UNKNOWN`, рекомендация - обновить Rust Если диагностика отсутствует, статус `UNKNOWN`, рекомендация - обновить Rust
agent и проверить поступление telemetry JSONL. Если источник `local_fallback`, agent и проверить поступление telemetry JSONL. Если источник `local_fallback`,
портал прямо пишет: `Диагностический режим, данные не засчитываются в KPI`. портал прямо пишет: `Диагностический режим, данные не засчитываются в показатели активности`.
## Стабильность агента за 7 дней ## Стабильность агента за 7 дней
Портал показывает мини-блок `Стабильность агента за 7 дней`. Он отвечает на Портал показывает мини-блок `Стабильность агента за 7 дней`. Он отвечает на
вопрос, можно ли доверять недельному KPI, а не только текущему срезу. вопрос, можно ли доверять недельным показателям, а не только текущему срезу.
`GET /api/reports` отдает: `GET /api/reports` отдает:
@@ -113,16 +113,16 @@ agent и проверить поступление telemetry JSONL. Если и
- сколько дней были `OK`; - сколько дней были `OK`;
- сколько дней были `WARNING`, `DEGRADED` или `UNKNOWN`; - сколько дней были `WARNING`, `DEGRADED` или `UNKNOWN`;
- процент дней, когда KPI был принят. - процент дней, когда показатели были подтверждены.
Если за 7 дней меньше 5 дней `OK`, портал и executive summary показывают: Если за 7 дней меньше 5 дней `OK`, портал и краткий вывод показывают:
`KPI требует валидации: нестабильный сбор данных агента`. `Показатели требуют проверки: нестабильный сбор данных агента`.
## Качество данных по рабочим местам ## Качество данных по рабочим местам
Портал показывает карточку `Качество данных по рабочим местам`. Она отвечает Портал показывает карточку `Качество данных по рабочим местам`. Она отвечает
на управленческий вопрос: какие узлы дают подтвержденные данные, а какие на управленческий вопрос: какие узлы дают подтвержденные данные, а какие
снижают доверие к KPI. снижают доверие к показателям.
`GET /api/reports` отдает: `GET /api/reports` отдает:
@@ -150,15 +150,15 @@ agent и проверить поступление telemetry JSONL. Если и
- `unknown_nodes`; - `unknown_nodes`;
- `accepted_kpi_nodes_pct`. - `accepted_kpi_nodes_pct`.
Если `accepted_kpi_nodes_pct < 80` и узлы есть, executive summary показывает: Если `accepted_kpi_nodes_pct < 80` и узлы есть, краткий вывод показывает:
`KPI требует проверки: менее 80% узлов дают подтвержденные данные`. `Показатели требуют проверки: менее 80% рабочих мест дают подтвержденные данные`.
Главная страница не перегружается: показывается сводка и таблица максимум из Главная страница не перегружается: показывается сводка и таблица максимум из
10 проблемных узлов. Источник `local_fallback` никогда не подтверждает KPI. 10 проблемных узлов. Источник `local_fallback` никогда не подтверждает показатели.
## SLA покрытия агентов ## Полнота данных
Портал показывает карточку `Покрытие агентов`. Она отвечает на вопрос, Портал показывает карточку `Полнота данных`. Она отвечает на вопрос,
насколько текущий `Индекс активности` репрезентативен по всему парку рабочих насколько текущий `Индекс активности` репрезентативен по всему парку рабочих
мест. мест.
@@ -187,51 +187,51 @@ agent и проверить поступление telemetry JSONL. Если и
- `expected_nodes` - сколько рабочих мест ожидается по конфигу; - `expected_nodes` - сколько рабочих мест ожидается по конфигу;
- `reporting_nodes_24h` - сколько узлов прислали свежую за 24 часа - `reporting_nodes_24h` - сколько узлов прислали свежую за 24 часа
телеметрию, принятую в KPI; телеметрию, принятую в показатели;
- `stale_nodes` - узлы есть в telemetry, но последняя запись старше 24 часов; - `stale_nodes` - узлы есть в telemetry, но последняя запись старше 24 часов;
- `missing_nodes` - узлы из expected list не найдены в telemetry; - `missing_nodes` - узлы из expected list не найдены в telemetry;
- `coverage_pct` - процент узлов, подтверждающих KPI; - `coverage_pct` - процент узлов, подтверждающих показатели;
- `freshness_pct` - процент узлов со свежей telemetry независимо от качества - `freshness_pct` - процент узлов со свежей telemetry независимо от качества
источника; источника;
- `sla_status` - `OK`, `WARNING`, `CRITICAL` или `UNKNOWN`; - `sla_status` - статус полноты данных: `OK`, `WARNING`, `CRITICAL` или `UNKNOWN`;
- `problem_nodes` - максимум выводится в UI по 10 проблемным узлам. - `problem_nodes` - максимум выводится в UI по 10 проблемным узлам.
Правила SLA: Правила полноты данных:
- `coverage_pct >= 90%` - `OK`; - `coverage_pct >= 90%` - `OK`;
- `75-89%` - `WARNING`; - `75-89%` - `WARNING`;
- `<75%` - `CRITICAL`; - `<75%` - `CRITICAL`;
- если expected list отсутствует или пустой - `UNKNOWN`. - если expected list отсутствует или пустой - `UNKNOWN`.
Если статус `CRITICAL`, executive summary показывает: Если статус `CRITICAL`, краткий вывод показывает:
`Покрытие агентов критически недостаточно: KPI не может считаться репрезентативным`. `Полнота данных критически недостаточна: показатели нельзя считать репрезентативными`.
Если статус `WARNING`, executive summary показывает: Если статус `WARNING`, краткий вывод показывает:
`KPI требует проверки: часть рабочих мест не присылает свежую телеметрию`. `Показатели требуют проверки: часть рабочих мест не присылает свежую телеметрию`.
Источник `local_fallback` может повышать `freshness_pct`, если телеметрия Источник `local_fallback` может повышать `freshness_pct`, если телеметрия
свежая, но не повышает `coverage_pct` и не засчитывается как подтвержденный свежая, но не повышает `coverage_pct` и не засчитывается как подтвержденные
KPI. показатели.
## Risk -> Investigation ## Переход от риска к расследованию
Кнопка `Открыть расследование` переводит пользователя в read-only карточку. Кнопка `Открыть расследование` переводит пользователя в карточку только для чтения.
Карточка содержит: Карточка содержит:
- `incident_id`; - номер;
- `risk_id`; - риск;
- `department`; - подразделение;
- `owner`; - ответственного;
- `activity_index`; - индекс активности;
- `deviation`; - отклонение;
- `status`; - статус;
- `summary`; - краткое описание;
- `why_it_is_risk`; - почему это риск;
- `what_to_check`; - что проверить;
- `recommended_actions`; - рекомендуемые действия;
- `evidence`; - материалы расследования;
- `generated_at`. - дату формирования.
Предупреждение обязательно: Предупреждение обязательно:
@@ -247,4 +247,4 @@ Smoke-тест:
node scripts/detmir-portal-tabs-smoke.mjs node scripts/detmir-portal-tabs-smoke.mjs
``` ```
Тест проверяет все вкладки, read-only настройки и переход Risk -> Investigation. Тест проверяет все вкладки, настройки только для чтения и переход от риска к расследованию.
+20 -17
View File
@@ -136,20 +136,20 @@ async function main() {
); );
const readyBodyText = await page.locator("#content").innerText({ timeout }); const readyBodyText = await page.locator("#content").innerText({ timeout });
const requiredExecutive = [ const requiredExecutive = [
"Связанная картина риска", "Главный вывод",
"Сотрудников в работе", "Сотрудников в работе",
"Средний индекс активности", "Средний индекс активности",
"Главная причина риска", "Главная причина риска",
"Подтверждающие слои", "Подтверждающие слои",
"Достоверность данных агента", "Качество данных",
"Стабильность агента за 7 дней", "Стабильность агента за 7 дней",
"Качество данных по рабочим местам", "Качество данных по рабочим местам",
"Покрытие агентов", "Полнота данных",
"Карта рисков подразделений", "Карта рисков",
"Корреляция Workforce и Security", "Связь рисков и активности",
"Риски подразделений", "Риски подразделений",
"Динамика рисков", "Динамика рисков",
"Кандидаты в инциденты", "Требует проверки",
"ТОП-5 лучших подразделений", "ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений", "ТОП-5 проблемных подразделений",
"Требует внимания", "Требует внимания",
@@ -161,11 +161,14 @@ async function main() {
ok: requiredExecutive.every((marker) => containsText(readyBodyText, marker)), ok: requiredExecutive.every((marker) => containsText(readyBodyText, marker)),
required: requiredExecutive, required: requiredExecutive,
}); });
const riskNarrativeIndex = readyBodyText.indexOf("Связанная картина риска"); const cardHeadings = await page.$$eval("#content section.card h3, #content h3.section-title", (nodes) =>
const executiveIndex = readyBodyText.indexOf("Сводка руководителя"); nodes.map((node) => node.textContent.trim()),
const trustIndex = readyBodyText.indexOf("Достоверность данных агента"); );
const businessRiskIndex = readyBodyText.indexOf("Риски подразделений"); const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод");
const candidatesIndex = readyBodyText.indexOf("Кандидаты в инциденты"); const executiveIndex = cardHeadings.indexOf("Сводка руководителя");
const trustIndex = cardHeadings.indexOf("Качество данных");
const businessRiskIndex = cardHeadings.indexOf("Риски подразделений");
const candidatesIndex = cardHeadings.indexOf("Требует проверки");
checks.push({ checks.push({
name: "management_block_order", name: "management_block_order",
ok: ok:
@@ -175,11 +178,11 @@ async function main() {
businessRiskIndex > trustIndex && businessRiskIndex > trustIndex &&
candidatesIndex > businessRiskIndex, candidatesIndex > businessRiskIndex,
order: [ order: [
"Связанная картина риска", "Главный вывод",
"Сводка руководителя", "Сводка руководителя",
"Достоверность данных агента", "Качество данных",
"Риски подразделений", "Риски подразделений",
"Кандидаты в инциденты", "Требует проверки",
], ],
}); });
} }
@@ -219,9 +222,9 @@ async function main() {
name: "risk_open_investigation_readonly_navigation", name: "risk_open_investigation_readonly_navigation",
ok: activeTab.trim() === "Расследования" ok: activeTab.trim() === "Расследования"
&& containsText(incidentText, "Расследование сформировано автоматически") && containsText(incidentText, "Расследование сформировано автоматически")
&& containsText(incidentText, "incident_id") && containsText(incidentText, "Номер")
&& containsText(incidentText, "risk_id") && containsText(incidentText, "Риск")
&& containsText(incidentText, "evidence"), && containsText(incidentText, "Материалы расследования"),
buttons: investigationButtons, buttons: investigationButtons,
}); });
} else { } else {