feat(portal): add industrial operations modules

This commit is contained in:
igor04091968
2026-06-04 07:15:20 +03:00
parent 4197efe482
commit 96c90efbde
4 changed files with 166 additions and 10 deletions
@@ -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; }
}
+128 -3
View File
@@ -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 `
<div class="page-head">
<div>
<h2 class="section-title">Подразделения</h2>
<p class="muted">Сравнение активности, тренда, риска и ответственных по группам.</p>
</div>
<span class="badge ${statusClass(report?.severity)}">${ui(report?.severity || "INFO")}</span>
</div>
<div class="grid-2">
<section class="card">
<h3>Рейтинг подразделений</h3>
${renderSimpleItems(departments, "Подразделения пока не рассчитаны.")}
</section>
<section class="card">
<h3>Ответственные</h3>
${renderSimpleItems(owners, "Ответственные пока не рассчитаны.")}
</section>
</div>
<section class="dashboard-band">
<div class="band-head"><h3>Просадки и отклонения</h3><span class="muted">что требует управленческого внимания</span></div>
${renderSimpleItems(insights, "Существенных отклонений по подразделениям пока нет.")}
</section>
`;
}
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 `
<div class="page-head">
<div>
<h2 class="section-title">Карточки сотрудников</h2>
<p class="muted">Рабочий день, приложения, активность, последнее событие и риск-сигналы.</p>
</div>
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.users_count || 0)} сотрудников</span>
</div>
<div class="employee-card-grid">${selected.map(user => renderEmployeeCard(user, employees)).join("") || `<p class="muted">Сотрудники пока не найдены.</p>`}</div>
${renderWorkforceIndexExplanation(policyExplain)}
`;
}
function renderEmployeeCard(user, employeeDetails) {
const detail = employeeDetails.find(item => item.user === user.user) || {};
return `
<article class="employee-card">
<div class="section-head">
<div>
<h3>${ui(user.user || "Сотрудник")}</h3>
<p class="muted small">Последняя активность: ${ui(user.last_activity || "-")}</p>
</div>
<span class="badge ${statusClass(detail.status || "INFO")}">${ui(workforceIndexTextFromValue(detail.index))}</span>
</div>
<div class="index-metrics employee-metrics">
<div><span class="muted">Активность</span><strong>${ui(user.active_hhmm || "00:00")}</strong></div>
<div><span class="muted">Сессии</span><strong>${escapeHtml(user.sessions_count || 0)}</strong></div>
<div><span class="muted">План</span><strong>${escapeHtml(humanSeconds(detail.planned_seconds))}</strong></div>
</div>
<p class="muted">${ui(detail.reason || "Персональный разбор по приложениям будет точнее после накопления данных по ролям.")}</p>
</article>
`;
}
function renderSimpleItems(items, emptyText) {
if (!Array.isArray(items) || items.length === 0) return `<p class="muted">${ui(emptyText)}</p>`;
return `<div class="list compact-list">${items.slice(0, 16).map(item => `
<div class="row compact-row">
<strong>${ui(item.label || item.title || "-")}</strong>
<span class="muted">${ui(item.value || item.subject || "")}</span>
<span class="badge ${statusClass(item.status)}">${ui(item.status || "INFO")}</span>
</div>
`).join("")}</div>`;
}
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 `
<div class="page-head">
<div>
<h2 class="section-title">Сетевой периметр</h2>
<p class="muted">Режим наблюдения: состояние внешних сигналов, необычные направления и связь с рисками сотрудников. Управление сетью отсюда не выполняется.</p>
</div>
<span class="badge status-unknown">наблюдение</span>
</div>
<div class="summary-grid">
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")}
${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")}
${metricCard("Связь с рисками", `${risk.score ?? 0}/100`, risk.status || "UNKNOWN", risk.summary || "оценка по правилам")}
${metricCard("Автоматическое воздействие", "выключено", "OK", "портал работает только на чтение")}
</div>
<section class="dashboard-band security-band">
<div class="band-head"><h3>Состояние доступных сигналов</h3><span class="muted">без изменения правил периметра</span></div>
${renderSimpleItems(cards.map(([name, block]) => ({ label: label(name), value: block.text, status: block.status })), "Сигналы периметра пока не подключены.")}
</section>
`;
}
function renderIncidentsList(items) {
if (!items || items.length === 0) return `<p class="muted">Активных проблем нет.</p>`;
return `<div class="list">${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) {
@@ -46,10 +46,13 @@
</header>
<nav class="tabs" aria-label="Разделы портала">
<button class="tab is-active" data-tab="operator">Сводка</button>
<button class="tab" data-tab="manager">Работа сотрудников</button>
<button class="tab" data-tab="owner">Риски и безопасность</button>
<button class="tab is-active" data-tab="operator">Пульс организации</button>
<button class="tab" data-tab="manager">Рабочее время</button>
<button class="tab" data-tab="departments">Подразделения</button>
<button class="tab" data-tab="employees">Сотрудники</button>
<button class="tab" data-tab="owner">Риски</button>
<button class="tab" data-tab="incidents">Расследования</button>
<button class="tab" data-tab="perimeter">Сетевой периметр</button>
<button class="tab" data-tab="reports">Отчеты</button>
</nav>
+12 -4
View File
@@ -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+ нет;
- раздел рисков включает темную тему.