feat(portal): add executive workforce risk layer

This commit is contained in:
igor04091968
2026-06-04 08:46:37 +03:00
parent 9f5f99ae65
commit 9f47dcaefa
4 changed files with 387 additions and 39 deletions
@@ -400,6 +400,45 @@ h1 {
line-height: 1.1;
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.attention-panel {
margin-bottom: 14px;
padding: 14px;
border: 1px solid var(--line);
border-left: 4px solid var(--warn);
border-radius: 8px;
background: var(--panel);
}
.attention-list {
display: grid;
gap: 8px;
}
.attention-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.attention-item p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.analytics-grid {
display: grid;
grid-template-columns: minmax(430px, 1.25fr) minmax(320px, 1fr) minmax(300px, 0.9fr);
@@ -457,6 +496,18 @@ h1 {
text-transform: uppercase;
}
.data-table small {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.heatmap-table td {
min-width: 120px;
}
.rank-list {
display: grid;
gap: 8px;
@@ -666,6 +717,27 @@ h1 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.leader-card {
margin-top: 14px;
}
.leader-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 12px 0;
}
.leader-grid > div {
display: grid;
gap: 4px;
min-height: 64px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.report-actions {
display: flex;
justify-content: flex-end;
@@ -768,9 +840,11 @@ pre {
@media (max-width: 1280px) {
.executive-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.analytics-grid { grid-template-columns: 1fr; }
.ranking-grid { grid-template-columns: 1fr; }
.department-panel,
.anomaly-panel,
.risk-panel { grid-column: auto; }
.leader-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
@@ -801,6 +875,8 @@ pre {
.employee-index-row { grid-template-columns: 1fr; }
.employee-metrics { grid-template-columns: 1fr; }
.risk-list li { grid-template-columns: 1fr; }
.attention-item { grid-template-columns: 1fr; }
.leader-grid { grid-template-columns: 1fr; }
.section-head { flex-direction: column; }
.actions { justify-content: flex-start; }
}
+271 -22
View File
@@ -304,21 +304,10 @@ function metricCard(labelText, value, status, context, trend) {
}
function renderExecutiveMetrics(report, incidents) {
const activity = findKpi(report, "Индекс активности");
const activeTime = findKpi(report, "Активное время");
const employees = findKpi(report, "Сотрудники");
const risk = findKpi(report, "UEBA риск");
const open = findKpi(report, "Открытые вопросы");
const openCount = Array.isArray(incidents) ? incidents.filter(item => !item.acknowledged).length : 0;
const anomalies = Array.isArray(report?.workforce?.insights) ? report.workforce.insights.filter(item => item.status !== "OK").length : 0;
void incidents;
return `
<section class="executive-grid" aria-label="Управленческая сводка">
${metricCard("Активность", activity?.value, activity?.status, activity?.context || "к выбранному периоду")}
${metricCard("Полезное время", activeTime?.value || "нет данных", activeTime?.status || "UNKNOWN", "на сотрудника")}
${metricCard("Сотрудников онлайн", employees?.value, employees?.status, employees?.context || "в текущем срезе")}
${metricCard("Риск-индекс", risk?.value, risk?.status, risk?.context || "низкий / средний / высокий")}
${metricCard("Аномалии", anomalies, anomalies ? "WARN" : "OK", "требуют проверки")}
${metricCard("Инциденты ИБ", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "за выбранный период")}
${executiveDashboardKpis(report)}
</section>
`;
}
@@ -344,6 +333,40 @@ function firstPercent(value) {
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 || "";
@@ -358,21 +381,107 @@ function departmentResponsible(owners, index) {
return item?.label || item?.title || "не назначен";
}
function renderDepartmentTable(report) {
const items = (report?.workforce?.department_comparison || []).slice(0, 8);
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 || [];
if (!items.length) return `<p class="muted">Подразделения пока не рассчитаны.</p>`;
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 `<p class="muted">Подразделения пока не рассчитаны.</p>`;
return `
<div class="table-scroll">
<table class="data-table department-table">
<thead><tr><th>Подразделение</th><th>Индекс активности</th><th>Отклонение</th><th>Статус</th><th>Ответственный</th></tr></thead>
<tbody>${items.map((item, index) => `
<tbody>${rows.map(row => `
<tr>
<td><strong>${ui(item.label)}</strong></td>
<td>${ui(item.index_activity || firstPercent(item.value) || "-")}</td>
<td>${ui(item.deviation || departmentDeviation(item))}</td>
<td><span class="badge ${statusClass(item.status)}">${ui(item.status || "INFO")}</span></td>
<td>${ui(item.responsible || departmentResponsible(owners, index))}</td>
<td><strong>${ui(row.label)}</strong></td>
<td>${ui(row.activityText || "-")}</td>
<td>${ui(row.deviation)}</td>
<td><span class="badge ${statusClass(row.status)}">${ui(row.status || "INFO")}</span></td>
<td>${ui(row.responsible)}</td>
</tr>
`).join("")}</tbody>
</table>
@@ -435,8 +544,146 @@ function renderTopRisks(report) {
`).join("")}</ol>`;
}
function renderDepartmentRanking(report) {
const rows = departmentRows(report);
if (!rows.length) return "";
const best = [...rows]
.sort((a, b) => (b.activity ?? -1) - (a.activity ?? -1) || statusWeight(a.status) - statusWeight(b.status))
.slice(0, 5);
const problem = [...rows]
.sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))
.slice(0, 5);
const renderRows = items => `<div class="list compact-list">${items.map(row => `
<div class="row compact-row">
<strong>${ui(row.label)}</strong>
<span class="muted">${ui(row.activityText)} · ${ui(row.deviation)} · ${ui(row.responsible)}</span>
<span class="badge ${statusClass(row.status)}">${ui(row.status)}</span>
</div>
`).join("")}</div>`;
return `
<section class="ranking-grid">
<article class="card">
<h3>ТОП-5 лучших подразделений</h3>
${renderRows(best)}
</article>
<article class="card">
<h3>ТОП-5 проблемных подразделений</h3>
${renderRows(problem)}
</article>
</section>
`;
}
function attentionItems(report) {
const rows = departmentRows(report);
const items = [];
rows
.filter(row => row.status === "FAIL" || row.status === "WARN")
.slice(0, 3)
.forEach(row => items.push({
title: `${row.status === "FAIL" ? "Требуется действие" : "Требуется внимание"}: ${row.label}`,
why: row.reason,
check: row.check,
action: `Поручить разбор: ${row.responsible}. Открыть расследование и сверить первичные события.`,
status: row.status,
}));
(report?.workforce?.insights || [])
.filter(item => item.status !== "OK")
.slice(0, Math.max(0, 5 - items.length))
.forEach(item => {
const explanation = anomalyExplanation(item);
items.push({
title: explanation.title,
why: explanation.why,
check: explanation.check,
action: "Назначить владельца проверки и сверить с планом работ.",
status: item.status || "WARN",
});
});
return items.slice(0, 5);
}
function renderAttentionBlock(report) {
const items = attentionItems(report);
if (!items.length) return "";
return `
<section class="attention-panel">
<div class="band-head"><h3>Требует внимания</h3><span class="muted">что руководителю нужно поручить сегодня</span></div>
<div class="attention-list">${items.map(item => `
<article class="attention-item">
<span class="badge ${statusClass(item.status)}">${ui(item.status || "INFO")}</span>
<div>
<strong>${ui(item.title)}</strong>
<p><b>Почему это важно:</b> ${ui(item.why)}</p>
<p><b>Что проверить:</b> ${ui(item.check)}</p>
<p><b>Рекомендуемое действие:</b> ${ui(item.action)}</p>
</div>
<button class="small-button" data-open-investigation="true">Открыть расследование</button>
</article>
`).join("")}</div>
</section>
`;
}
function renderDepartmentHeatMap(report) {
const rows = departmentRows(report);
if (!rows.length) return "";
return `
<section class="dashboard-band">
<div class="band-head"><h3>Heat Map подразделений</h3><span class="muted">риск понятным языком для руководителя</span></div>
<div class="table-scroll">
<table class="data-table heatmap-table">
<thead><tr><th>Подразделение</th><th>Активность</th><th>Отклонение</th><th>Риск</th><th>Ответственный</th><th>Действие</th></tr></thead>
<tbody>${rows.map(row => `
<tr>
<td><strong>${ui(row.label)}</strong><small>${ui(row.reason)}</small></td>
<td>${ui(row.activityText)}</td>
<td>${ui(row.deviation)}</td>
<td><span class="badge ${statusClass(row.status)}">${ui(row.risk)}</span></td>
<td>${ui(row.responsible)}</td>
<td><button class="small-button" data-open-investigation="true">Открыть расследование</button></td>
</tr>
`).join("")}</tbody>
</table>
</div>
</section>
`;
}
function renderDepartmentLeaderCard(report) {
const rows = departmentRows(report);
if (!rows.length) return "";
const row = [...rows].sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))[0];
const best = [...rows].sort((a, b) => (b.activity ?? -1) - (a.activity ?? -1))[0];
const worst = [...rows].sort((a, b) => (a.activity ?? 101) - (b.activity ?? 101))[0];
return `
<section class="card leader-card">
<div class="section-head">
<div>
<h3>Карточка руководителя подразделения</h3>
<p class="muted">Read-only срез для поручения разбора ответственному.</p>
</div>
<span class="badge ${statusClass(row.status)}">${ui(row.risk)}</span>
</div>
<div class="leader-grid">
<div><span class="muted">Подразделение</span><strong>${ui(row.label)}</strong></div>
<div><span class="muted">Ответственный</span><strong>${ui(row.responsible)}</strong></div>
<div><span class="muted">Сотрудников всего</span><strong>${ui(row.total ?? "-")}</strong></div>
<div><span class="muted">Активны сегодня</span><strong>${ui(row.active ?? "-")}</strong></div>
<div><span class="muted">Средний индекс</span><strong>${ui(row.activityText)}</strong></div>
<div><span class="muted">Лучший показатель</span><strong>${ui(best?.activityText || "-")}</strong></div>
<div><span class="muted">Худший показатель</span><strong>${ui(worst?.activityText || "-")}</strong></div>
<div><span class="muted">Недельный тренд</span><strong>${ui(signedPercent(row.trend))}</strong></div>
</div>
<p class="muted"><b>Что проверить:</b> ${ui(row.check)}</p>
</section>
`;
}
function renderOverviewAnalytics(report) {
return `
${renderDepartmentRanking(report)}
${renderAttentionBlock(report)}
<section class="analytics-grid">
<article class="analytics-panel department-panel">
<h3>Подразделения сегодня</h3>
@@ -451,6 +698,8 @@ function renderOverviewAnalytics(report) {
${renderTopRisks(report)}
</article>
</section>
${renderDepartmentHeatMap(report)}
${renderDepartmentLeaderCard(report)}
`;
}
+19 -16
View File
@@ -28,17 +28,22 @@
- `Сетевой периметр`;
- `Отчеты`.
- `Настройки`.
- Главный экран `Пульс организации` содержит 6 KPI-карточек:
- активность;
- полезное время;
- сотрудников онлайн;
- риск-индекс;
- аномалии;
- инциденты ИБ.
- Главный экран `Пульс организации` содержит Executive Dashboard для утреннего просмотра руководителем:
- сотрудников в работе сегодня;
- средний индекс активности;
- WARN подразделений;
- FAIL подразделений;
- критических рисков;
- недельный тренд активности.
- Главный аналитический блок содержит:
- `ТОП-5 лучших подразделений`;
- `ТОП-5 проблемных подразделений`;
- блок `Требует внимания` с форматом: заголовок проблемы, почему это важно, что проверить, рекомендуемое действие;
- широкую таблицу `Подразделения сегодня` с колонками: подразделение, индекс активности, отклонение, статус, ответственный;
- блок `Аномалии` с объяснением: что произошло, почему это риск, что проверить;
- блок `Топ рисков` с read-only переходом к расследованиям.
- `Heat Map подразделений` с колонками: подразделение, активность, отклонение, риск, ответственный, действие;
- read-only карточку руководителя подразделения.
- Раздел `Настройки` работает только на чтение и показывает: период расчета, рабочий день, пороги WARN/FAIL, источник правил, дату последнего пересчета.
- Добавлены отдельные промышленные представления:
- экран подразделений: рейтинг групп, ответственные, просадки и отклонения;
@@ -50,14 +55,12 @@
- если истории недостаточно, портал явно показывает `накоплено N/M дней`;
- детальные дневные экраны маркируются плашкой `дневная детализация`.
- Первый экран показывает executive summary:
- индекс активности за день;
- активные сотрудники;
- отклонения от нормы;
- подразделения с просадкой;
- риски ИБ;
- новые инциденты;
- готовность отчета;
- доказательная база.
- какие подразделения работают нормально;
- где падает активность;
- где есть риск;
- что проверить;
- кому поручить разбор;
- куда открыть расследование без автоматического воздействия.
- Блок работы сотрудников показывает загрузку, простои, перегруз, приложения, объяснение индекса и проверку правил.
- Блок рисков использует темную тему и выводит риск/ИБ-срез без автоматического воздействия на сеть.
- Блок расследований показывает инциденты, материалы, скриншоты, хеши и ссылки на выгрузку.
@@ -108,4 +111,4 @@ Playwright smoke:
- API-ответов с кодом 400+ нет;
- раздел рисков включает темную тему.
- смена периода `Сегодня` -> `Неделя` меняет содержимое страницы и показывает плашку периода.
- `scripts/detmir-portal-tabs-smoke.mjs` проверяет все `data-tab`, наличие обработчиков в `app.js`, строки read-only настроек и read-only переход из топ-рисков в расследования.
- `scripts/detmir-portal-tabs-smoke.mjs` проверяет все `data-tab`, наличие обработчиков в `app.js`, Executive Dashboard, строки read-only настроек и read-only переход из топ-рисков в расследования.
+21 -1
View File
@@ -43,6 +43,10 @@ function extractDataTabs(html) {
return [...html.matchAll(/data-tab=["']([^"']+)["']/g)].map((match) => match[1]);
}
function containsText(text, marker) {
return String(text || "").toLocaleLowerCase("ru-RU").includes(String(marker || "").toLocaleLowerCase("ru-RU"));
}
function assertStaticTabHandlers() {
const index = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/index.html"), "utf8");
const app = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/app.js"), "utf8");
@@ -112,6 +116,22 @@ async function main() {
const stillLoading = bodyText.includes("Загрузка") || bodyText.includes("Обновление периода");
const ok = activeTab.trim() === item.label && bodyText.includes(item.marker) && !stillLoading;
results.push({ tab: item.tab, label: item.label, marker: item.marker, ok });
if (item.tab === "operator") {
const requiredExecutive = [
"Сотрудников в работе",
"Средний индекс активности",
"ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений",
"Требует внимания",
"Heat Map подразделений",
"Карточка руководителя подразделения",
];
checks.push({
name: "executive_dashboard_layer",
ok: requiredExecutive.every((marker) => containsText(bodyText, marker)),
required: requiredExecutive,
});
}
if (item.tab === "settings") {
const requiredSettings = [
"Период расчета",
@@ -123,7 +143,7 @@ async function main() {
];
checks.push({
name: "settings_readonly_rows",
ok: requiredSettings.every((marker) => bodyText.includes(marker)),
ok: requiredSettings.every((marker) => containsText(bodyText, marker)),
required: requiredSettings,
});
}