From 9f47dcaefa55474aa171dfde6974a13f8fedd211 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 08:46:37 +0300 Subject: [PATCH] feat(portal): add executive workforce risk layer --- .../crates/detmir-portal/src/static/app.css | 76 +++++ .../crates/detmir-portal/src/static/app.js | 293 ++++++++++++++++-- docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md | 35 ++- scripts/detmir-portal-tabs-smoke.mjs | 22 +- 4 files changed, 387 insertions(+), 39 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 39d7588..b2f48aa 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -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; } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 5be7f49..7e32583 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -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 `
- ${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)}
`; } @@ -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 `

Подразделения пока не рассчитаны.

`; + return departments.map((item, index) => { + const parsed = parseActivityValue(item.value); + const activity = item.index_activity ? percentNumber(item.index_activity) : parsed.activity; + const trend = departmentTrendPct(report, item.label); + const status = item.status || "INFO"; + const responsible = item.responsible || departmentResponsible(owners, index); + return { + label: item.label || "Без подразделения", + activity, + activityText: Number.isFinite(activity) ? `${Math.round(activity)}%` : firstPercent(item.value), + deviation: item.deviation || departmentDeviation(item), + status, + responsible, + active: parsed.active, + total: parsed.total, + hhmm: parsed.hhmm, + trend, + risk: status === "FAIL" ? "FAIL — требуется действие" : status === "WARN" ? "WARN — требуется внимание" : "LOW — все нормально", + reason: departmentRiskReason({ item, parsed, activity, status, trend }), + check: departmentCheckText({ parsed, status, trend }), + }; + }); +} + +function departmentRiskReason({ parsed, activity, status, trend }) { + const reasons = []; + if (Number.isFinite(activity)) reasons.push(`индекс активности ${Math.round(activity)}%`); + if (Number.isFinite(parsed.active) && Number.isFinite(parsed.total)) reasons.push(`активны ${parsed.active}/${parsed.total} сотрудников`); + if (status === "WARN") reasons.push("подразделение ниже рабочего порога"); + if (status === "FAIL") reasons.push("требуется управленческое действие"); + if (Number.isFinite(trend) && trend < 0) reasons.push(`недельный тренд ${signedPercent(trend)}`); + if (parsed.hhmm === "00:00") reasons.push("нет подтвержденного рабочего времени"); + return reasons.join("; ") || "недостаточно данных для уверенного вывода"; +} + +function departmentCheckText({ parsed, status, trend }) { + if (status === "OK") return "держать под наблюдением, действий не требуется"; + if (parsed.hhmm === "00:00") return "проверить входы в рабочие системы, RDP/1C активность и свежесть данных коллектора"; + if (Number.isFinite(trend) && trend < 0) return "сверить план задач, нагрузку ответственного и причины падения за неделю"; + return "поручить ответственному проверить план работ и первичные события ActivityWatch"; +} + +function executiveDashboardKpis(report) { + const rows = departmentRows(report); + const latest = latestTrend(report); + const activityValues = rows.map(row => row.activity).filter(Number.isFinite); + const average = activityValues.length ? Math.round(activityValues.reduce((sum, value) => sum + value, 0) / activityValues.length) : null; + const criticalRisks = (report?.ueba_risk?.reasons || []).filter(item => String(item.status || item.severity).toUpperCase() === "FAIL" || Number(item.points || 0) >= 25).length; + return [ + metricCard("Сотрудников в работе", latest.active_users ?? findKpi(report, "Сотрудники")?.value ?? "нет данных", "OK", "активны сегодня"), + metricCard("Средний индекс активности", Number.isFinite(average) ? `${average}%` : findKpi(report, "Индекс активности")?.value, average === null ? "UNKNOWN" : workforceIndexStatus(average), "по подразделениям"), + metricCard("WARN подразделений", rows.filter(row => row.status === "WARN").length, rows.some(row => row.status === "WARN") ? "WARN" : "OK", "требуется внимание"), + metricCard("FAIL подразделений", rows.filter(row => row.status === "FAIL").length, rows.some(row => row.status === "FAIL") ? "FAIL" : "OK", "требуется действие"), + metricCard("Критических рисков", criticalRisks, criticalRisks ? "FAIL" : "OK", "приоритетный разбор"), + metricCard("Тренд недели", signedPercent(weeklyTrendPct(report)), Number(weeklyTrendPct(report)) < 0 ? "WARN" : "OK", "динамика активности"), + ].join(""); +} + +function renderDepartmentTable(report) { + const rows = departmentRows(report).slice(0, 12); + if (!rows.length) return `

Подразделения пока не рассчитаны.

`; return `
- ${items.map((item, index) => ` + ${rows.map(row => ` - - - - - + + + + + `).join("")}
ПодразделениеИндекс активностиОтклонениеСтатусОтветственный
${ui(item.label)}${ui(item.index_activity || firstPercent(item.value) || "-")}${ui(item.deviation || departmentDeviation(item))}${ui(item.status || "INFO")}${ui(item.responsible || departmentResponsible(owners, index))}${ui(row.label)}${ui(row.activityText || "-")}${ui(row.deviation)}${ui(row.status || "INFO")}${ui(row.responsible)}
@@ -435,8 +544,146 @@ function renderTopRisks(report) { `).join("")}`; } +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 => `
${items.map(row => ` +
+ ${ui(row.label)} + ${ui(row.activityText)} · ${ui(row.deviation)} · ${ui(row.responsible)} + ${ui(row.status)} +
+ `).join("")}
`; + return ` +
+
+

ТОП-5 лучших подразделений

+ ${renderRows(best)} +
+
+

ТОП-5 проблемных подразделений

+ ${renderRows(problem)} +
+
+ `; +} + +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 ` +
+

Требует внимания

что руководителю нужно поручить сегодня
+
${items.map(item => ` +
+ ${ui(item.status || "INFO")} +
+ ${ui(item.title)} +

Почему это важно: ${ui(item.why)}

+

Что проверить: ${ui(item.check)}

+

Рекомендуемое действие: ${ui(item.action)}

+
+ +
+ `).join("")}
+
+ `; +} + +function renderDepartmentHeatMap(report) { + const rows = departmentRows(report); + if (!rows.length) return ""; + return ` +
+

Heat Map подразделений

риск понятным языком для руководителя
+
+ + + ${rows.map(row => ` + + + + + + + + + `).join("")} +
ПодразделениеАктивностьОтклонениеРискОтветственныйДействие
${ui(row.label)}${ui(row.reason)}${ui(row.activityText)}${ui(row.deviation)}${ui(row.risk)}${ui(row.responsible)}
+
+
+ `; +} + +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 ` +
+
+
+

Карточка руководителя подразделения

+

Read-only срез для поручения разбора ответственному.

+
+ ${ui(row.risk)} +
+
+
Подразделение${ui(row.label)}
+
Ответственный${ui(row.responsible)}
+
Сотрудников всего${ui(row.total ?? "-")}
+
Активны сегодня${ui(row.active ?? "-")}
+
Средний индекс${ui(row.activityText)}
+
Лучший показатель${ui(best?.activityText || "-")}
+
Худший показатель${ui(worst?.activityText || "-")}
+
Недельный тренд${ui(signedPercent(row.trend))}
+
+

Что проверить: ${ui(row.check)}

+
+ `; +} + function renderOverviewAnalytics(report) { return ` + ${renderDepartmentRanking(report)} + ${renderAttentionBlock(report)}

Подразделения сегодня

@@ -451,6 +698,8 @@ function renderOverviewAnalytics(report) { ${renderTopRisks(report)}
+ ${renderDepartmentHeatMap(report)} + ${renderDepartmentLeaderCard(report)} `; } diff --git a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md index 5ca14d2..8ce99cf 100644 --- a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md +++ b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md @@ -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 переход из топ-рисков в расследования. diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 2d11f9f..74d81fa 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -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, }); }