diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index d47d146..4ea40d4 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -332,6 +332,24 @@ h1 { background: var(--panel); } +.period-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-left: 4px solid var(--warn); + border-radius: 8px; + background: var(--soft); +} + +.period-banner > div { + display: grid; + gap: 4px; +} + .security-band { border-left: 4px solid #7c2d12; } @@ -560,6 +578,7 @@ pre { .control-strip label { min-width: 100%; } h1 { font-size: 24px; } .page-head { flex-direction: column; } + .period-banner { flex-direction: column; align-items: flex-start; } .band-head { flex-direction: column; align-items: flex-start; } .row { grid-template-columns: 1fr; } .incident-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 131cd1c..b9b8903 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, reports: null }; +const state = { tab: "operator", period: "today", links: null, readiness: null, reports: null }; function apiBase() { const path = window.location.pathname; @@ -174,6 +174,120 @@ function findSection(report, title) { return (report?.sections || []).find(item => String(item.title || "").toLowerCase().includes(text)); } +function periodConfig(period = state.period) { + if (period === "week") return { key: "week", label: "неделя", days: 7 }; + if (period === "month") return { key: "month", label: "месяц", days: 30 }; + return { key: "today", label: "сегодня", days: 1 }; +} + +function periodFromSelectValue(value) { + const text = String(value || "").toLowerCase(); + if (text.includes("нед")) return "week"; + if (text.includes("мес")) return "month"; + return "today"; +} + +function trendPoints(report, days) { + const trend = Array.isArray(report?.workforce?.trend) ? report.workforce.trend : []; + return trend.slice(Math.max(0, trend.length - days)); +} + +function averageCoverage(points) { + const values = points + .map(item => Number(item.portfolio_coverage_pct)) + .filter(value => Number.isFinite(value)); + if (values.length === 0) return null; + return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length); +} + +function periodStatus(points, config) { + if (config.key === "today") return "OK"; + if (points.length >= config.days) return "OK"; + if (points.length > 0) return "WARN"; + return "UNKNOWN"; +} + +function periodReadinessText(report, config = periodConfig()) { + if (config.key === "today") return "дневной срез"; + const points = trendPoints(report, config.days); + if (points.length >= config.days) return `готово: ${config.label}`; + return `накоплено ${points.length}/${config.days} дней`; +} + +function periodReport(report) { + if (!report || state.period === "today") return report; + const config = periodConfig(); + const points = trendPoints(report, config.days); + const average = averageCoverage(points); + const next = JSON.parse(JSON.stringify(report)); + next.period = `управленческий срез за период: ${config.label}`; + next.period_view = { + key: config.key, + label: config.label, + required_days: config.days, + available_days: points.length, + status: periodStatus(points, config), + average_coverage_pct: average + }; + next.kpis = (next.kpis || []).map(item => { + if (item.label === "Индекс активности") { + return { + ...item, + value: average === null ? "нет данных" : `${average}%`, + status: periodStatus(points, config), + context: `${config.label}: среднее по накопленной истории, ${points.length}/${config.days} дней` + }; + } + if (item.label === "Активное время") { + return { + ...item, + value: state.period === "week" ? "по дням" : "по истории", + status: periodStatus(points, config), + context: "агрегация времени требует накопленных дневных срезов" + }; + } + if (item.label === "Подразделения") { + return { + ...item, + context: `${config.label}: текущий состав групп, тренд по истории` + }; + } + return item; + }); + return next; +} + +function renderPeriodBanner(report) { + const config = periodConfig(); + if (config.key === "today") return ""; + const view = periodReport(report)?.period_view || {}; + const status = view.status || "UNKNOWN"; + const avg = Number.isFinite(Number(view.average_coverage_pct)) ? `${view.average_coverage_pct}%` : "нет данных"; + return ` +
+
+ Период: ${ui(config.label)} + Накоплено ${escapeHtml(view.available_days ?? 0)} из ${escapeHtml(config.days)} дневных срезов. Средняя активность: ${escapeHtml(avg)}. +
+ ${ui(periodReadinessText(report, config))} +
+ `; +} + +function renderDailyDetailNotice() { + const config = periodConfig(); + if (config.key === "today") return ""; + return ` +
+
+ Период: ${ui(config.label)} + Этот детальный экран показывает последний дневной срез. Сводная интерпретация за период доступна в разделах "Пульс организации", "Подразделения" и "Отчеты". +
+ дневная детализация +
+ `; +} + function metricCard(labelText, value, status, context) { return `
@@ -201,7 +315,7 @@ function renderExecutiveMetrics(report, incidents) { ${metricCard("Подразделения с просадкой", departments?.value, departments?.status, departments?.context || "сравнение текущего дня")} ${metricCard("Риски ИБ", risk?.value, risk?.status, risk?.context || "оценка по правилам")} ${metricCard("Новые инциденты", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "не взятые в работу")} - ${metricCard("Готовность отчета", reportReadinessText(report), report?.operator_ok ? "OK" : report?.severity, "daily / weekly / monthly")} + ${metricCard("Готовность отчета", periodReadinessText(report), report?.period_view?.status || (report?.operator_ok ? "OK" : report?.severity), "день / неделя / месяц")} ${metricCard("Доказательная база", evidence?.value, evidence?.status, evidence?.context)} `; @@ -293,6 +407,7 @@ function renderSourceList(data) { } function renderOperator(data, report) { + report = periodReport(report); const incidents = Array.isArray(data.incidents) ? data.incidents : []; const workforce = findSection(report, "Работа"); const insights = findSection(report, "Выводы Workforce"); @@ -306,6 +421,7 @@ function renderOperator(data, report) { ${escapeHtml(report?.headline || "Оперативный срез")} + ${renderPeriodBanner(report)} ${renderExecutiveMetrics(report, incidents)}

Рабочая активность сотрудников

загрузка, простои, перегруз и дисциплина процессов
@@ -338,6 +454,7 @@ function renderManager(data, policyExplain) { ${escapeHtml(data.status?.status || "INFO")} + ${renderDailyDetailNotice()}

Индекс активности

@@ -370,6 +487,7 @@ function renderManager(data, policyExplain) { } function renderDepartments(report) { + report = periodReport(report); const departments = report?.workforce?.department_comparison || []; const owners = report?.workforce?.owner_comparison || []; const insights = report?.workforce?.insights || []; @@ -381,6 +499,7 @@ function renderDepartments(report) {
${ui(report?.severity || "INFO")} + ${renderPeriodBanner(report)}

Рейтинг подразделений

@@ -410,6 +529,7 @@ function renderEmployees(data, policyExplain) {
${escapeHtml(data.users_count || 0)} сотрудников + ${renderDailyDetailNotice()}
${selected.map(user => renderEmployeeCard(user, employees)).join("") || `

Сотрудники пока не найдены.

`}
${renderWorkforceIndexExplanation(policyExplain)} `; @@ -595,6 +715,7 @@ function renderOwner(data) { } function renderPerimeter(data, report) { + report = periodReport(report); const risk = report?.ueba_risk || {}; const cards = Object.entries(data.cards || {}); return ` @@ -605,6 +726,7 @@ function renderPerimeter(data, report) { наблюдение + ${renderPeriodBanner(report)}
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")} ${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")} @@ -759,6 +881,7 @@ function renderUebaRisk(risk) { } function renderReports(data) { + data = periodReport(data); return `
@@ -767,6 +890,7 @@ function renderReports(data) {
${escapeHtml(data.severity)}
+ ${renderPeriodBanner(data)} ${renderReportTypeCards()}
@@ -877,6 +1001,12 @@ document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); +document.getElementById("periodFilter")?.addEventListener("change", event => { + state.period = periodFromSelectValue(event.target.value); + document.getElementById("content").innerHTML = `

Обновление периода...

`; + refresh().catch(showError); +}); + document.addEventListener("click", event => { const button = event.target.closest("[data-incident-action]"); if (!button) return; @@ -926,12 +1056,13 @@ async function verifyReadinessBundle(button) { async function anonymizeReport(button) { button.disabled = true; const data = await loadJson("/reports?anonymize=1"); + state.reports = data; document.getElementById("content").innerHTML = renderReports(data); } async function exportMarkdown() { const data = state.reports || await loadJson("/reports"); - const markdown = displayText(data.markdown || ""); + const markdown = displayText(periodReport(data)?.markdown || data.markdown || ""); const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md index 078e7ee..d4fbfe1 100644 --- a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md +++ b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md @@ -27,6 +27,11 @@ - экран подразделений: рейтинг групп, ответственные, просадки и отклонения; - экран сотрудников: карточка сотрудника, активность, сессии, плановое время, последнее событие; - экран сетевого периметра: режим наблюдения без управления сетевыми правилами. +- Селектор периода подключен к интерфейсу: + - `Сегодня` показывает текущий дневной срез; + - `Неделя` и `Месяц` пересчитывают управленческие карточки по накопленной истории тренда; + - если истории недостаточно, портал явно показывает `накоплено N/M дней`; + - детальные дневные экраны маркируются плашкой `дневная детализация`. - Первый экран показывает executive summary: - индекс активности за день; - активные сотрудники; @@ -43,7 +48,7 @@ ## Важные ограничения -- Фильтры периода, подразделения и ответственного пока являются UI-контролами. Backend-фильтрация должна добавляться отдельным изменением, чтобы не искажать текущие данные. +- Фильтр периода влияет на управленческие представления портала. Фильтры подразделения и ответственного пока являются UI-контролами; backend-фильтрация должна добавляться отдельным изменением. - `Индекс активности` остается расчетной метрикой: активное время / плановое время. Взвешенный показатель объясняется отдельно через правила ролей и приложений. - Разделы рисков и расследований показывают риск-срез и материалы проверки. Это не заявляется как сертифицированная СЗИ. - Раздел сетевого периметра работает как витрина наблюдения. Он не меняет правила сети и не выполняет блокировки. @@ -85,3 +90,4 @@ Playwright smoke: - JS-ошибок нет; - API-ответов с кодом 400+ нет; - раздел рисков включает темную тему. +- смена периода `Сегодня` -> `Неделя` меняет содержимое страницы и показывает плашку периода.