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 ``; } -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) {
+ + 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 => ``).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

+

Рабочий кабинет руководителя, ИБ и расследователя

+
+
+
+ + + +
+
+ + +
Загрузка
+
-
Загрузка
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` включает темную тему.