fix(portal): apply selected report period

This commit is contained in:
igor04091968
2026-06-04 07:23:12 +03:00
parent 96c90efbde
commit 96090f7c10
3 changed files with 160 additions and 4 deletions
@@ -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; }
+134 -3
View File
@@ -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 `
<section class="period-banner">
<div>
<strong>Период: ${ui(config.label)}</strong>
<span class="muted">Накоплено ${escapeHtml(view.available_days ?? 0)} из ${escapeHtml(config.days)} дневных срезов. Средняя активность: ${escapeHtml(avg)}.</span>
</div>
<span class="badge ${statusClass(status)}">${ui(periodReadinessText(report, config))}</span>
</section>
`;
}
function renderDailyDetailNotice() {
const config = periodConfig();
if (config.key === "today") return "";
return `
<section class="period-banner">
<div>
<strong>Период: ${ui(config.label)}</strong>
<span class="muted">Этот детальный экран показывает последний дневной срез. Сводная интерпретация за период доступна в разделах "Пульс организации", "Подразделения" и "Отчеты".</span>
</div>
<span class="badge status-warn">дневная детализация</span>
</section>
`;
}
function metricCard(labelText, value, status, context) {
return `
<article class="metric-card">
@@ -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)}
</section>
`;
@@ -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) {
</div>
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
</div>
${renderPeriodBanner(report)}
${renderExecutiveMetrics(report, incidents)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
@@ -338,6 +454,7 @@ function renderManager(data, policyExplain) {
</div>
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.status?.status || "INFO")}</span>
</div>
${renderDailyDetailNotice()}
<div class="grid-2">
<section class="card">
<h3>Индекс активности</h3>
@@ -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) {
</div>
<span class="badge ${statusClass(report?.severity)}">${ui(report?.severity || "INFO")}</span>
</div>
${renderPeriodBanner(report)}
<div class="grid-2">
<section class="card">
<h3>Рейтинг подразделений</h3>
@@ -410,6 +529,7 @@ function renderEmployees(data, policyExplain) {
</div>
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.users_count || 0)} сотрудников</span>
</div>
${renderDailyDetailNotice()}
<div class="employee-card-grid">${selected.map(user => renderEmployeeCard(user, employees)).join("") || `<p class="muted">Сотрудники пока не найдены.</p>`}</div>
${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) {
</div>
<span class="badge status-unknown">наблюдение</span>
</div>
${renderPeriodBanner(report)}
<div class="summary-grid">
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")}
${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")}
@@ -759,6 +881,7 @@ function renderUebaRisk(risk) {
}
function renderReports(data) {
data = periodReport(data);
return `
<div class="page-head">
<div>
@@ -767,6 +890,7 @@ function renderReports(data) {
</div>
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
</div>
${renderPeriodBanner(data)}
${renderReportTypeCards()}
<div class="grid-2">
<section class="card report-hero">
@@ -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 = `<p class="muted">Обновление периода...</p>`;
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");
+7 -1
View File
@@ -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+ нет;
- раздел рисков включает темную тему.
- смена периода `Сегодня` -> `Неделя` меняет содержимое страницы и показывает плашку периода.