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 => `
- | ${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)} |
`).join("")}
@@ -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 подразделений
риск понятным языком для руководителя
+
+
+ `;
+}
+
+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,
});
}