diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css
index 0517581..726efdf 100644
--- a/adk-rust/crates/detmir-portal/src/static/app.css
+++ b/adk-rust/crates/detmir-portal/src/static/app.css
@@ -14,6 +14,8 @@
--unknown: #59636e;
--unknown-bg: #e9edf1;
--link: #075985;
+ --accent: #234b62;
+ --soft: #f8fafc;
}
* { box-sizing: border-box; }
@@ -25,8 +27,34 @@ body {
font-family: Inter, "Segoe UI", Arial, sans-serif;
}
+body.security-mode {
+ --bg: #111827;
+ --panel: #172026;
+ --ink: #e5e7eb;
+ --muted: #aab4bf;
+ --line: #334155;
+ --unknown: #cbd5e1;
+ --unknown-bg: #334155;
+ --link: #7dd3fc;
+ --accent: #38bdf8;
+ --soft: #1f2937;
+}
+
+body.security-mode .small-button,
+body.security-mode a.button,
+body.security-mode .tab,
+body.security-mode .control-strip select {
+ background: #111827;
+ color: var(--ink);
+}
+
+body.security-mode .tab.is-active {
+ background: #0f2a3a;
+ color: var(--link);
+}
+
.shell {
- max-width: 1280px;
+ max-width: 1440px;
margin: 0 auto;
padding: 22px;
}
@@ -39,6 +67,10 @@ body {
margin-bottom: 16px;
}
+.brand {
+ min-width: 280px;
+}
+
.eyebrow {
margin: 0 0 4px;
color: var(--muted);
@@ -53,6 +85,47 @@ h1 {
line-height: 1.1;
}
+.lead {
+ margin: 8px 0 0;
+ color: var(--muted);
+ font-size: 15px;
+}
+
+.topbar-side {
+ display: grid;
+ justify-items: end;
+ gap: 10px;
+ min-width: min(100%, 620px);
+}
+
+.control-strip,
+.header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.control-strip label {
+ display: grid;
+ gap: 4px;
+ min-width: 150px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.control-strip select {
+ min-height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ color: var(--ink);
+ padding: 6px 10px;
+ font: inherit;
+ font-size: 13px;
+}
+
.tabs {
display: flex;
flex-wrap: wrap;
@@ -74,6 +147,7 @@ h1 {
.tab.is-active {
border-color: var(--link);
color: var(--link);
+ background: #eef7fb;
}
.summary-grid {
@@ -99,6 +173,14 @@ h1 {
padding: 16px;
}
+.page-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
.card h3,
.section-title {
margin: 0 0 8px;
@@ -213,6 +295,79 @@ h1 {
line-height: 1.1;
}
+.executive-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.metric-card {
+ min-height: 132px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-left: 4px solid var(--accent);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.metric-card h3,
+.report-type h3 {
+ margin: 10px 0 6px;
+ font-size: 15px;
+}
+
+.metric-value {
+ margin: 8px 0 4px;
+ font-size: 26px;
+ font-weight: 800;
+ line-height: 1.1;
+}
+
+.dashboard-band {
+ margin-top: 12px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.security-band {
+ border-left: 4px solid #7c2d12;
+}
+
+.technical-band {
+ background: var(--soft);
+}
+
+.band-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.band-head h3 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.report-type-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.report-type {
+ min-height: 118px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--soft);
+}
+
.report-section {
min-height: 180px;
}
@@ -380,7 +535,13 @@ pre {
@media (max-width: 640px) {
.shell { padding: 14px; }
.topbar { flex-direction: column; }
+ .topbar-side { justify-items: stretch; min-width: 0; width: 100%; }
+ .control-strip,
+ .header-actions { justify-content: flex-start; }
+ .control-strip label { min-width: 100%; }
h1 { font-size: 24px; }
+ .page-head { flex-direction: column; }
+ .band-head { flex-direction: column; align-items: flex-start; }
.row { grid-template-columns: 1fr; }
.incident-row { grid-template-columns: 1fr; }
.evidence-row { grid-template-columns: 1fr; }
diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js
index 036dce4..847c350 100644
--- a/adk-rust/crates/detmir-portal/src/static/app.js
+++ b/adk-rust/crates/detmir-portal/src/static/app.js
@@ -1,4 +1,4 @@
-const state = { tab: "operator", links: null, readiness: null };
+const state = { tab: "operator", links: null, readiness: null, reports: null };
function apiBase() {
const path = window.location.pathname;
@@ -40,7 +40,7 @@ function escapeHtml(value) {
function renderSummary(summary, readiness) {
const global = document.getElementById("globalStatus");
global.className = `status-pill ${statusClass(summary.severity)}`;
- global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
+ global.textContent = `Сенсоры ${summary.operator_ok ? "OK" : "NO"} · ${summary.severity}`;
const blocks = Object.entries(summary.blocks || {});
const readinessCard = renderReadinessSummaryCard(readiness);
document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
@@ -96,10 +96,102 @@ function label(name) {
grafana: "Grafana",
dlp: "DLP",
worktime: "Работа сегодня",
- one_c: "1С"
+ one_c: "1С",
+ work: "Workforce",
+ security: "Security"
}[name] || name;
}
+function findKpi(report, needle) {
+ const text = String(needle || "").toLowerCase();
+ return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text));
+}
+
+function findSection(report, title) {
+ const text = String(title || "").toLowerCase();
+ return (report?.sections || []).find(item => String(item.title || "").toLowerCase().includes(text));
+}
+
+function metricCard(labelText, value, status, context) {
+ return `
+
+ ${escapeHtml(status || "INFO")}
+ ${escapeHtml(labelText)}
+ ${escapeHtml(value ?? "Нет данных")}
+ ${escapeHtml(context || "")}
+
+ `;
+}
+
+function renderExecutiveMetrics(report, incidents) {
+ const activity = findKpi(report, "Индекс активности");
+ const employees = findKpi(report, "Сотрудники");
+ const risk = findKpi(report, "UEBA риск");
+ const open = findKpi(report, "Открытые вопросы");
+ const departments = findKpi(report, "Подразделения");
+ const evidence = findKpi(report, "Evidence");
+ const openCount = Array.isArray(incidents) ? incidents.filter(item => !item.acknowledged).length : 0;
+ return `
+
+ ${metricCard("Индекс активности за день", activity?.value, activity?.status, activity?.context || "proxy: активное время / плановое время")}
+ ${metricCard("Активные сотрудники", employees?.value, employees?.status, employees?.context)}
+ ${metricCard("Отклонения от нормы", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "вопросы, требующие реакции")}
+ ${metricCard("Подразделения с просадкой", departments?.value, departments?.status, departments?.context || "сравнение текущего дня")}
+ ${metricCard("Риски ИБ", risk?.value, risk?.status, risk?.context || "rule-based UEBA v1")}
+ ${metricCard("Новые инциденты", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "не взятые в работу")}
+ ${metricCard("Готовность отчета", reportReadinessText(report), report?.operator_ok ? "OK" : report?.severity, "daily / weekly / monthly")}
+ ${metricCard("Доказательная база", evidence?.value, evidence?.status, evidence?.context)}
+
+ `;
+}
+
+function incidentStatusFromCount(count) {
+ const n = Number(count || 0);
+ if (n === 0) return "OK";
+ if (n <= 3) return "WARN";
+ return "FAIL";
+}
+
+function reportReadinessText(report) {
+ const trend = report?.workforce?.trend_status || "daily_only";
+ if (trend === "monthly_ready") return "daily / weekly / monthly";
+ if (trend === "weekly_ready") return "daily / weekly";
+ return "daily";
+}
+
+function renderSectionItems(section, emptyText) {
+ const items = Array.isArray(section?.items) ? section.items : [];
+ if (items.length === 0) {
+ return `
${escapeHtml(emptyText || "Данных для среза пока нет.")}
`;
+ }
+ return `${items.slice(0, 12).map(item => `
+
+ ${escapeHtml(item.label || "-")}
+ ${escapeHtml(item.value || "")}
+ ${escapeHtml(item.status || "INFO")}
+
+ `).join("")}
`;
+}
+
+function renderReportTypeCards() {
+ const types = [
+ ["Ежедневный отчет", "Оперативный срез за сегодня", "OK"],
+ ["Недельный отчет", "Динамика после накопления daily history", "INFO"],
+ ["Месячный отчет", "Готов для управленческой аналитики после накопления истории", "INFO"],
+ ["По подразделению", "Сравнение загрузки и просадок", "OK"],
+ ["По сотруднику", "Drill-down с осторожной трактовкой KPI", "WARN"],
+ ["По инциденту", "Материалы для служебной проверки", "OK"],
+ ["Акт пилота", "Итоги опытной эксплуатации", "OK"]
+ ];
+ return `${types.map(([title, text, status]) => `
+
+ ${escapeHtml(status)}
+ ${escapeHtml(title)}
+ ${escapeHtml(text)}
+
+ `).join("")}
`;
+}
+
function renderLinks(links) {
const link = (text, href) => `${escapeHtml(text)} `;
return `
@@ -138,38 +230,62 @@ function renderSourceList(data) {
`).join("")}
`;
}
-function renderOperator(data) {
+function renderOperator(data, report) {
+ const incidents = Array.isArray(data.incidents) ? data.incidents : [];
+ const workforce = findSection(report, "Работа");
+ const insights = findSection(report, "Выводы Workforce");
+ const security = findSection(report, "ИБ");
+ const actions = findSection(report, "Действия");
return `
- Оператор
-
-
- Контур
- ${renderSourceList(data)}
-
-
- Быстрые переходы
- ${renderLinks(data.links)}
-
+
+
+
Управленческая сводка
+
За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.
+
+
${escapeHtml(report?.headline || "Оперативный срез")}
-
Проблемы
- ${renderIncidentsList(data.incidents)}
+ ${renderExecutiveMetrics(report, incidents)}
+
+
Рабочая активность сотрудников загрузка, простои, перегруз и дисциплина процессов
+ ${renderSectionItems(workforce, "Workforce-срез пока не сформирован.")}
+ ${renderSectionItems(insights, "Отклонений Workforce пока не найдено.")}
+
+
+
Контроль безопасности подозрительные события и приоритет реакции
+ ${renderSectionItems(security, "ИБ-событий для реакции пока нет.")}
+
+
+
Что требует внимания операторские действия и открытые вопросы
+ ${renderIncidentsList(incidents)}
+ ${renderSectionItems(actions, "Рекомендаций нет.")}
+
+
+
Технический статус источников вторичный слой для оператора
+ ${renderSourceList(data)}
+
`;
}
function renderManager(data, policyExplain) {
const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds);
return `
-
Руководитель
+
+
+
Рабочая активность сотрудников
+
Оценка загрузки, простоев, перегруза и использования рабочих приложений.
+
+
${escapeHtml(data.status?.status || "INFO")}
+
- Работа сегодня
+ Индекс активности
${escapeHtml(workforceIndex)}
proxy: активное время / плановое рабочее время
Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}
${escapeHtml(data.status?.text || "")}
- Приложения
+ RDP / 1C / рабочие приложения
${(data.applications || []).slice(0, 8).map(app => `
${escapeHtml(app.application)}
@@ -180,7 +296,7 @@ function renderManager(data, policyExplain) {
${renderWorkforceIndexExplanation(policyExplain)}
-
Сотрудники
+
Сотрудники без активности и с аномалиями
${(data.users || []).map(user => `
${escapeHtml(user.user)}
@@ -313,7 +429,13 @@ function workforceIndexStatus(value) {
function renderOwner(data) {
const cards = Object.entries(data.cards || {});
return `
-
Владелец
+
+
+
Контроль безопасности
+
Приоритеты ИБ: подозрительные подключения, DLP-сигналы, RDP-аномалии и состояние периметра.
+
+
${escapeHtml(data.summary?.severity || "INFO")}
+
${cards.map(([name, block]) => `
${escapeHtml(block.status)}
@@ -322,11 +444,11 @@ function renderOwner(data) {
`).join("")}
- Что сделать
+ Риски с приоритетом
${(data.recommendations || []).map(item => `
Рекомендация ${escapeHtml(item)}
`).join("")}
- Переходы
+ Дашборды безопасности
${renderLinks(data.links)}
`;
@@ -393,19 +515,25 @@ function renderIncidents(data) {
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
-
Инциденты ИБ
+
+
+
Расследования и доказательная база
+
Кто, когда, откуда, куда, связанные хосты, evidence и выгрузка материалов.
+
+
${escapeHtml((incidents || []).length)} items
+
- DLP-инциденты
+ Карточки инцидентов
${renderDlpIncidentsList(incidents)}
- Графики и дашборды
+ Связанные графики и дашборды
${renderDlpLinks(links)}
- Доказательства
+ Evidence: скриншоты, хеши, файлы
${renderDlpEvidence(evidence)}
`;
@@ -468,7 +596,14 @@ function renderUebaRisk(risk) {
function renderReports(data) {
return `
-
Отчеты
+
+
+
Отчеты
+
Ежедневные, недельные, месячные, по подразделению, сотруднику, инциденту и пилотной эксплуатации.
+
+
${escapeHtml(data.severity)}
+
+ ${renderReportTypeCards()}
${escapeHtml(data.severity)}
@@ -485,7 +620,9 @@ function renderReports(data) {
Демо без имен
+
Markdown
Печать / PDF
+
JSON
Ключевые показатели
${renderKpiCards(data.kpis)}
@@ -510,7 +647,11 @@ async function refresh() {
renderSummary(summary, state.readiness);
const content = document.getElementById("content");
const data = await loadJson(`/${state.tab}`);
- if (state.tab === "operator") content.innerHTML = renderOperator(data);
+ if (state.tab === "operator") {
+ state.reports = await loadJson("/reports").catch(() => state.reports);
+ content.innerHTML = renderOperator(data, state.reports);
+ updateFilters(state.reports);
+ }
if (state.tab === "manager") {
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderManager(data, policyExplain);
@@ -520,11 +661,16 @@ async function refresh() {
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
}
- if (state.tab === "reports") content.innerHTML = renderReports(data);
+ if (state.tab === "reports") {
+ state.reports = data;
+ content.innerHTML = renderReports(data);
+ updateFilters(data);
+ }
}
function setTab(tab) {
state.tab = tab;
+ applySecurityMode(tab);
document.querySelectorAll(".tab").forEach(btn => {
btn.classList.toggle("is-active", btn.dataset.tab === tab);
});
@@ -532,6 +678,10 @@ function setTab(tab) {
refresh().catch(showError);
}
+function applySecurityMode(tab) {
+ document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents");
+}
+
function showError(error) {
document.getElementById("content").innerHTML = `
${escapeHtml(error.stack || error.message || error)} `;
}
@@ -558,6 +708,12 @@ document.addEventListener("click", event => {
anonymizeReport(button).catch(showError);
});
+document.addEventListener("click", event => {
+ const button = event.target.closest("[data-export-markdown]");
+ if (!button) return;
+ exportMarkdown().catch(showError);
+});
+
document.addEventListener("click", event => {
const button = event.target.closest("[data-readiness-verify]");
if (!button) return;
@@ -586,6 +742,33 @@ async function anonymizeReport(button) {
document.getElementById("content").innerHTML = renderReports(data);
}
+async function exportMarkdown() {
+ const data = state.reports || await loadJson("/reports");
+ const markdown = data.markdown || "";
+ const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "awatch-rus-report.md";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+function updateFilters(report) {
+ updateSelect("departmentFilter", (report?.workforce?.department_comparison || []).map(item => item.label));
+ updateSelect("ownerFilter", (report?.workforce?.owner_comparison || []).map(item => item.label));
+}
+
+function updateSelect(id, values) {
+ const select = document.getElementById(id);
+ if (!select || select.dataset.loaded === "true") return;
+ const unique = [...new Set((values || []).filter(Boolean))].slice(0, 50);
+ select.innerHTML = `
Все ` + unique.map(item => `
${escapeHtml(item)} `).join("");
+ select.dataset.loaded = "true";
+}
+
async function incidentAction(button) {
const id = button.dataset.incidentId;
const action = button.dataset.incidentAction;
@@ -608,5 +791,6 @@ async function incidentAction(button) {
await refresh();
}
+applySecurityMode(state.tab);
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);
diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html
index 4fc648c..e640994 100644
--- a/adk-rust/crates/detmir-portal/src/static/index.html
+++ b/adk-rust/crates/detmir-portal/src/static/index.html
@@ -3,24 +3,53 @@
-
DetMir Portal
+
AWatch-rus Portal
-
-
DetMir Portal
-
Контур компании
+
+
AWatch-rus
+
Workforce / Security / Forensics
+
Рабочий кабинет руководителя, ИБ и расследователя
+
+
+
+
+ Период
+
+ Сегодня
+ Неделя
+ Месяц
+
+
+
+ Подразделение
+
+ Все
+
+
+
+ Ответственный
+
+ Все
+
+
+
+
-
Загрузка
- Оператор
- Руководитель
- Владелец
- Инциденты ИБ
+ Сводка
+ Workforce
+ Security
+ Forensics
Отчеты
diff --git a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md
new file mode 100644
index 0000000..cb37158
--- /dev/null
+++ b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md
@@ -0,0 +1,79 @@
+# AWatch-rus Portal: рабочий кабинет
+
+Дата: 2026-06-04
+
+## Цель изменения
+
+Портал переведен из технической "админки логов" в рабочий кабинет для трех ролей:
+
+- руководитель: управленческая сводка, Workforce KPI, отклонения, отчеты;
+- ИБ: риск-срез, DLP/evidence, состояние контрольных сигналов;
+- расследователь: карточки инцидентов, evidence, хеши и выгрузка материалов.
+
+## Что изменено
+
+- Верхняя панель переименована в `AWatch-rus Workforce / Security / Forensics`.
+- Добавлены элементы отчета: период, подразделение, ответственный, экспорт Markdown/PDF, статус сенсоров.
+- Навигация приведена к продуктовой модели:
+ - `Сводка`;
+ - `Workforce`;
+ - `Security`;
+ - `Forensics`;
+ - `Отчеты`.
+- Первый экран показывает executive summary:
+ - индекс активности за день;
+ - активные сотрудники;
+ - отклонения от нормы;
+ - подразделения с просадкой;
+ - риски ИБ;
+ - новые инциденты;
+ - готовность отчета;
+ - доказательная база.
+- Workforce-блок показывает загрузку, простои, перегруз, приложения, объяснение индекса и policy audit.
+- Security-блок использует темную тему и выводит риск/ИБ-срез без автоматического воздействия на сеть.
+- Forensics-блок показывает инциденты, evidence, скриншоты, хеши и ссылки на выгрузку.
+- Отчеты дополнены типами: ежедневный, недельный, месячный, по подразделению, по сотруднику, по инциденту, акт пилота.
+
+## Важные ограничения
+
+- Фильтры периода, подразделения и ответственного пока являются UI-контролами. Backend-фильтрация должна добавляться отдельным изменением, чтобы не искажать текущие данные.
+- `Индекс активности` остается proxy-метрикой: активное время / плановое время. Weighted KPI объясняется отдельно через role/application policy.
+- Security/Forensics показывают риск-срез и evidence. Это не заявляется как сертифицированная СЗИ.
+- pfSense и сетевой периметр этим изменением не затрагивались.
+
+## Проверки
+
+Локальные проверки:
+
+```bash
+export CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target
+node --check adk-rust/crates/detmir-portal/src/static/app.js
+git diff --check
+cargo test --manifest-path adk-rust/Cargo.toml -p detmir-portal
+cargo clippy --manifest-path adk-rust/Cargo.toml -p detmir-portal --all-targets -- -D warnings
+cargo build --release --manifest-path adk-rust/Cargo.toml -p detmir-portal
+```
+
+Боевые проверки после деплоя:
+
+- `detmir-portal.service`: active;
+- `nginx`: active;
+- `detmir-portal-evidence.service`: active;
+- `/portal/`: HTTP 200;
+- `/portal/api/summary`: HTTP 200;
+- `/portal/api/operator`: HTTP 200;
+- `/portal/api/manager`: HTTP 200;
+- `/portal/api/owner`: HTTP 200;
+- `/portal/api/incidents`: HTTP 200;
+- `/portal/api/reports`: HTTP 200;
+- `/portal/api/readiness/bundle`: HTTP 200;
+- `/portal/api/workforce/policy/explain`: HTTP 200;
+- `/portal/api/dlp/evidence`: HTTP 200.
+
+Playwright smoke:
+
+- заголовок страницы: `AWatch-rus Portal`;
+- вкладки `Сводка`, `Workforce`, `Security`, `Forensics`, `Отчеты` открываются;
+- JS-ошибок нет;
+- API-ответов с кодом 400+ нет;
+- `Security` включает темную тему.