diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css
index 726efdf..d47d146 100644
--- a/adk-rust/crates/detmir-portal/src/static/app.css
+++ b/adk-rust/crates/detmir-portal/src/static/app.css
@@ -439,6 +439,25 @@ h1 {
grid-template-columns: minmax(140px, 1fr) minmax(280px, 2fr) auto;
}
+.employee-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.employee-card {
+ min-height: 180px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.employee-metrics {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
.report-actions {
display: flex;
justify-content: flex-end;
@@ -550,6 +569,7 @@ pre {
.readiness-metrics { grid-template-columns: 1fr; }
.app-weight-row { grid-template-columns: 1fr; }
.employee-index-row { grid-template-columns: 1fr; }
+ .employee-metrics { 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 cdce4ae..131cd1c 100644
--- a/adk-rust/crates/detmir-portal/src/static/app.js
+++ b/adk-rust/crates/detmir-portal/src/static/app.js
@@ -369,6 +369,84 @@ function renderManager(data, policyExplain) {
`;
}
+function renderDepartments(report) {
+ const departments = report?.workforce?.department_comparison || [];
+ const owners = report?.workforce?.owner_comparison || [];
+ const insights = report?.workforce?.insights || [];
+ return `
+
+
+
Подразделения
+
Сравнение активности, тренда, риска и ответственных по группам.
+
+
${ui(report?.severity || "INFO")}
+
+
+
+ Рейтинг подразделений
+ ${renderSimpleItems(departments, "Подразделения пока не рассчитаны.")}
+
+
+ Ответственные
+ ${renderSimpleItems(owners, "Ответственные пока не рассчитаны.")}
+
+
+
+ Просадки и отклонения
что требует управленческого внимания
+ ${renderSimpleItems(insights, "Существенных отклонений по подразделениям пока нет.")}
+
+ `;
+}
+
+function renderEmployees(data, policyExplain) {
+ const employees = Array.isArray(policyExplain?.employee_details) ? policyExplain.employee_details : [];
+ const users = Array.isArray(data.users) ? data.users : [];
+ const selected = users.slice(0, 12);
+ return `
+
+
+
Карточки сотрудников
+
Рабочий день, приложения, активность, последнее событие и риск-сигналы.
+
+
${escapeHtml(data.users_count || 0)} сотрудников
+
+ ${selected.map(user => renderEmployeeCard(user, employees)).join("") || `
Сотрудники пока не найдены.
`}
+ ${renderWorkforceIndexExplanation(policyExplain)}
+ `;
+}
+
+function renderEmployeeCard(user, employeeDetails) {
+ const detail = employeeDetails.find(item => item.user === user.user) || {};
+ return `
+
+
+
+
${ui(user.user || "Сотрудник")}
+
Последняя активность: ${ui(user.last_activity || "-")}
+
+
${ui(workforceIndexTextFromValue(detail.index))}
+
+
+
Активность${ui(user.active_hhmm || "00:00")}
+
Сессии${escapeHtml(user.sessions_count || 0)}
+
План${escapeHtml(humanSeconds(detail.planned_seconds))}
+
+ ${ui(detail.reason || "Персональный разбор по приложениям будет точнее после накопления данных по ролям.")}
+
+ `;
+}
+
+function renderSimpleItems(items, emptyText) {
+ if (!Array.isArray(items) || items.length === 0) return `${ui(emptyText)}
`;
+ return `${items.slice(0, 16).map(item => `
+
+ ${ui(item.label || item.title || "-")}
+ ${ui(item.value || item.subject || "")}
+ ${ui(item.status || "INFO")}
+
+ `).join("")}
`;
+}
+
function workforceIndexText(usersCount, activeSeconds) {
const users = Number(usersCount || 0);
const seconds = Number(activeSeconds || 0);
@@ -516,6 +594,30 @@ function renderOwner(data) {
`;
}
+function renderPerimeter(data, report) {
+ const risk = report?.ueba_risk || {};
+ const cards = Object.entries(data.cards || {});
+ return `
+
+
+
Сетевой периметр
+
Режим наблюдения: состояние внешних сигналов, необычные направления и связь с рисками сотрудников. Управление сетью отсюда не выполняется.
+
+
наблюдение
+
+
+ ${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")}
+ ${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")}
+ ${metricCard("Связь с рисками", `${risk.score ?? 0}/100`, risk.status || "UNKNOWN", risk.summary || "оценка по правилам")}
+ ${metricCard("Автоматическое воздействие", "выключено", "OK", "портал работает только на чтение")}
+
+
+ Состояние доступных сигналов
без изменения правил периметра
+ ${renderSimpleItems(cards.map(([name, block]) => ({ label: label(name), value: block.text, status: block.status })), "Сигналы периметра пока не подключены.")}
+
+ `;
+}
+
function renderIncidentsList(items) {
if (!items || items.length === 0) return `Активных проблем нет.
`;
return `${items.map(item => `
@@ -708,22 +810,45 @@ async function refresh() {
};
renderSummary(summary, state.readiness);
const content = document.getElementById("content");
- const data = await loadJson(`/${state.tab}`);
if (state.tab === "operator") {
+ const data = await loadJson("/operator");
state.reports = await loadJson("/reports").catch(() => state.reports);
content.innerHTML = renderOperator(data, state.reports);
updateFilters(state.reports);
}
if (state.tab === "manager") {
+ const data = await loadJson("/manager");
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderManager(data, policyExplain);
}
- if (state.tab === "owner") content.innerHTML = renderOwner(data);
+ if (state.tab === "departments") {
+ const data = await loadJson("/reports");
+ state.reports = data;
+ content.innerHTML = renderDepartments(data);
+ updateFilters(data);
+ }
+ if (state.tab === "employees") {
+ const data = await loadJson("/manager");
+ const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
+ content.innerHTML = renderEmployees(data, policyExplain);
+ }
+ if (state.tab === "owner") {
+ const data = await loadJson("/owner");
+ content.innerHTML = renderOwner(data);
+ }
if (state.tab === "incidents") {
+ const data = await loadJson("/incidents");
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
}
+ if (state.tab === "perimeter") {
+ const data = await loadJson("/owner");
+ const reports = await loadJson("/reports").catch(() => state.reports);
+ state.reports = reports;
+ content.innerHTML = renderPerimeter(data, reports);
+ }
if (state.tab === "reports") {
+ const data = await loadJson("/reports");
state.reports = data;
content.innerHTML = renderReports(data);
updateFilters(data);
@@ -741,7 +866,7 @@ function setTab(tab) {
}
function applySecurityMode(tab) {
- document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents");
+ document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents" || tab === "perimeter");
}
function showError(error) {
diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html
index ff4d15c..d171b02 100644
--- a/adk-rust/crates/detmir-portal/src/static/index.html
+++ b/adk-rust/crates/detmir-portal/src/static/index.html
@@ -46,10 +46,13 @@
diff --git a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md
index 138ef04..078e7ee 100644
--- a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md
+++ b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md
@@ -15,11 +15,18 @@
- Верхняя панель переименована в `AWatch-rus Активность / Риски / Расследования`.
- Добавлены элементы отчета: период, подразделение, ответственный, текстовый отчет/PDF, статус сбора данных.
- Навигация приведена к продуктовой модели:
- - `Сводка`;
- - `Работа сотрудников`;
- - `Риски и безопасность`;
+ - `Пульс организации`;
+ - `Рабочее время`;
+ - `Подразделения`;
+ - `Сотрудники`;
+ - `Риски`;
- `Расследования`;
+ - `Сетевой периметр`;
- `Отчеты`.
+- Добавлены отдельные промышленные представления:
+ - экран подразделений: рейтинг групп, ответственные, просадки и отклонения;
+ - экран сотрудников: карточка сотрудника, активность, сессии, плановое время, последнее событие;
+ - экран сетевого периметра: режим наблюдения без управления сетевыми правилами.
- Первый экран показывает executive summary:
- индекс активности за день;
- активные сотрудники;
@@ -39,6 +46,7 @@
- Фильтры периода, подразделения и ответственного пока являются UI-контролами. Backend-фильтрация должна добавляться отдельным изменением, чтобы не искажать текущие данные.
- `Индекс активности` остается расчетной метрикой: активное время / плановое время. Взвешенный показатель объясняется отдельно через правила ролей и приложений.
- Разделы рисков и расследований показывают риск-срез и материалы проверки. Это не заявляется как сертифицированная СЗИ.
+- Раздел сетевого периметра работает как витрина наблюдения. Он не меняет правила сети и не выполняет блокировки.
- pfSense и сетевой периметр этим изменением не затрагивались.
## Проверки
@@ -73,7 +81,7 @@ cargo build --release --manifest-path adk-rust/Cargo.toml -p detmir-portal
Playwright smoke:
- заголовок страницы: `AWatch-rus Portal`;
-- вкладки `Сводка`, `Работа сотрудников`, `Риски и безопасность`, `Расследования`, `Отчеты` открываются;
+- вкладки `Пульс организации`, `Рабочее время`, `Подразделения`, `Сотрудники`, `Риски`, `Расследования`, `Сетевой периметр`, `Отчеты` открываются;
- JS-ошибок нет;
- API-ответов с кодом 400+ нет;
- раздел рисков включает темную тему.