fix(portal): apply selected report period
This commit is contained in:
@@ -332,6 +332,24 @@ h1 {
|
|||||||
background: var(--panel);
|
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 {
|
.security-band {
|
||||||
border-left: 4px solid #7c2d12;
|
border-left: 4px solid #7c2d12;
|
||||||
}
|
}
|
||||||
@@ -560,6 +578,7 @@ pre {
|
|||||||
.control-strip label { min-width: 100%; }
|
.control-strip label { min-width: 100%; }
|
||||||
h1 { font-size: 24px; }
|
h1 { font-size: 24px; }
|
||||||
.page-head { flex-direction: column; }
|
.page-head { flex-direction: column; }
|
||||||
|
.period-banner { flex-direction: column; align-items: flex-start; }
|
||||||
.band-head { flex-direction: column; align-items: flex-start; }
|
.band-head { flex-direction: column; align-items: flex-start; }
|
||||||
.row { grid-template-columns: 1fr; }
|
.row { grid-template-columns: 1fr; }
|
||||||
.incident-row { grid-template-columns: 1fr; }
|
.incident-row { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -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() {
|
function apiBase() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -174,6 +174,120 @@ function findSection(report, title) {
|
|||||||
return (report?.sections || []).find(item => String(item.title || "").toLowerCase().includes(text));
|
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) {
|
function metricCard(labelText, value, status, context) {
|
||||||
return `
|
return `
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
@@ -201,7 +315,7 @@ function renderExecutiveMetrics(report, incidents) {
|
|||||||
${metricCard("Подразделения с просадкой", departments?.value, departments?.status, departments?.context || "сравнение текущего дня")}
|
${metricCard("Подразделения с просадкой", departments?.value, departments?.status, departments?.context || "сравнение текущего дня")}
|
||||||
${metricCard("Риски ИБ", risk?.value, risk?.status, risk?.context || "оценка по правилам")}
|
${metricCard("Риски ИБ", risk?.value, risk?.status, risk?.context || "оценка по правилам")}
|
||||||
${metricCard("Новые инциденты", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "не взятые в работу")}
|
${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)}
|
${metricCard("Доказательная база", evidence?.value, evidence?.status, evidence?.context)}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@@ -293,6 +407,7 @@ function renderSourceList(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderOperator(data, report) {
|
function renderOperator(data, report) {
|
||||||
|
report = periodReport(report);
|
||||||
const incidents = Array.isArray(data.incidents) ? data.incidents : [];
|
const incidents = Array.isArray(data.incidents) ? data.incidents : [];
|
||||||
const workforce = findSection(report, "Работа");
|
const workforce = findSection(report, "Работа");
|
||||||
const insights = findSection(report, "Выводы Workforce");
|
const insights = findSection(report, "Выводы Workforce");
|
||||||
@@ -306,6 +421,7 @@ function renderOperator(data, report) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderPeriodBanner(report)}
|
||||||
${renderExecutiveMetrics(report, incidents)}
|
${renderExecutiveMetrics(report, incidents)}
|
||||||
<section class="dashboard-band">
|
<section class="dashboard-band">
|
||||||
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
|
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
|
||||||
@@ -338,6 +454,7 @@ function renderManager(data, policyExplain) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.status?.status || "INFO")}</span>
|
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.status?.status || "INFO")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderDailyDetailNotice()}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Индекс активности</h3>
|
<h3>Индекс активности</h3>
|
||||||
@@ -370,6 +487,7 @@ function renderManager(data, policyExplain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDepartments(report) {
|
function renderDepartments(report) {
|
||||||
|
report = periodReport(report);
|
||||||
const departments = report?.workforce?.department_comparison || [];
|
const departments = report?.workforce?.department_comparison || [];
|
||||||
const owners = report?.workforce?.owner_comparison || [];
|
const owners = report?.workforce?.owner_comparison || [];
|
||||||
const insights = report?.workforce?.insights || [];
|
const insights = report?.workforce?.insights || [];
|
||||||
@@ -381,6 +499,7 @@ function renderDepartments(report) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(report?.severity)}">${ui(report?.severity || "INFO")}</span>
|
<span class="badge ${statusClass(report?.severity)}">${ui(report?.severity || "INFO")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderPeriodBanner(report)}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Рейтинг подразделений</h3>
|
<h3>Рейтинг подразделений</h3>
|
||||||
@@ -410,6 +529,7 @@ function renderEmployees(data, policyExplain) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.users_count || 0)} сотрудников</span>
|
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.users_count || 0)} сотрудников</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderDailyDetailNotice()}
|
||||||
<div class="employee-card-grid">${selected.map(user => renderEmployeeCard(user, employees)).join("") || `<p class="muted">Сотрудники пока не найдены.</p>`}</div>
|
<div class="employee-card-grid">${selected.map(user => renderEmployeeCard(user, employees)).join("") || `<p class="muted">Сотрудники пока не найдены.</p>`}</div>
|
||||||
${renderWorkforceIndexExplanation(policyExplain)}
|
${renderWorkforceIndexExplanation(policyExplain)}
|
||||||
`;
|
`;
|
||||||
@@ -595,6 +715,7 @@ function renderOwner(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPerimeter(data, report) {
|
function renderPerimeter(data, report) {
|
||||||
|
report = periodReport(report);
|
||||||
const risk = report?.ueba_risk || {};
|
const risk = report?.ueba_risk || {};
|
||||||
const cards = Object.entries(data.cards || {});
|
const cards = Object.entries(data.cards || {});
|
||||||
return `
|
return `
|
||||||
@@ -605,6 +726,7 @@ function renderPerimeter(data, report) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge status-unknown">наблюдение</span>
|
<span class="badge status-unknown">наблюдение</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderPeriodBanner(report)}
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")}
|
${metricCard("Нетипичные направления", "нет данных", "UNKNOWN", "источник периметра не подключен к порталу")}
|
||||||
${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")}
|
${metricCard("Сетевые события", "нет данных", "UNKNOWN", "ожидается интеграционный слой")}
|
||||||
@@ -759,6 +881,7 @@ function renderUebaRisk(risk) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderReports(data) {
|
function renderReports(data) {
|
||||||
|
data = periodReport(data);
|
||||||
return `
|
return `
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -767,6 +890,7 @@ function renderReports(data) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
|
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderPeriodBanner(data)}
|
||||||
${renderReportTypeCards()}
|
${renderReportTypeCards()}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<section class="card report-hero">
|
<section class="card report-hero">
|
||||||
@@ -877,6 +1001,12 @@ document.querySelectorAll(".tab").forEach(btn => {
|
|||||||
btn.addEventListener("click", () => setTab(btn.dataset.tab));
|
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 => {
|
document.addEventListener("click", event => {
|
||||||
const button = event.target.closest("[data-incident-action]");
|
const button = event.target.closest("[data-incident-action]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -926,12 +1056,13 @@ async function verifyReadinessBundle(button) {
|
|||||||
async function anonymizeReport(button) {
|
async function anonymizeReport(button) {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
const data = await loadJson("/reports?anonymize=1");
|
const data = await loadJson("/reports?anonymize=1");
|
||||||
|
state.reports = data;
|
||||||
document.getElementById("content").innerHTML = renderReports(data);
|
document.getElementById("content").innerHTML = renderReports(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportMarkdown() {
|
async function exportMarkdown() {
|
||||||
const data = state.reports || await loadJson("/reports");
|
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 blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
- экран подразделений: рейтинг групп, ответственные, просадки и отклонения;
|
- экран подразделений: рейтинг групп, ответственные, просадки и отклонения;
|
||||||
- экран сотрудников: карточка сотрудника, активность, сессии, плановое время, последнее событие;
|
- экран сотрудников: карточка сотрудника, активность, сессии, плановое время, последнее событие;
|
||||||
- экран сетевого периметра: режим наблюдения без управления сетевыми правилами.
|
- экран сетевого периметра: режим наблюдения без управления сетевыми правилами.
|
||||||
|
- Селектор периода подключен к интерфейсу:
|
||||||
|
- `Сегодня` показывает текущий дневной срез;
|
||||||
|
- `Неделя` и `Месяц` пересчитывают управленческие карточки по накопленной истории тренда;
|
||||||
|
- если истории недостаточно, портал явно показывает `накоплено N/M дней`;
|
||||||
|
- детальные дневные экраны маркируются плашкой `дневная детализация`.
|
||||||
- Первый экран показывает executive summary:
|
- Первый экран показывает executive summary:
|
||||||
- индекс активности за день;
|
- индекс активности за день;
|
||||||
- активные сотрудники;
|
- активные сотрудники;
|
||||||
@@ -43,7 +48,7 @@
|
|||||||
|
|
||||||
## Важные ограничения
|
## Важные ограничения
|
||||||
|
|
||||||
- Фильтры периода, подразделения и ответственного пока являются UI-контролами. Backend-фильтрация должна добавляться отдельным изменением, чтобы не искажать текущие данные.
|
- Фильтр периода влияет на управленческие представления портала. Фильтры подразделения и ответственного пока являются UI-контролами; backend-фильтрация должна добавляться отдельным изменением.
|
||||||
- `Индекс активности` остается расчетной метрикой: активное время / плановое время. Взвешенный показатель объясняется отдельно через правила ролей и приложений.
|
- `Индекс активности` остается расчетной метрикой: активное время / плановое время. Взвешенный показатель объясняется отдельно через правила ролей и приложений.
|
||||||
- Разделы рисков и расследований показывают риск-срез и материалы проверки. Это не заявляется как сертифицированная СЗИ.
|
- Разделы рисков и расследований показывают риск-срез и материалы проверки. Это не заявляется как сертифицированная СЗИ.
|
||||||
- Раздел сетевого периметра работает как витрина наблюдения. Он не меняет правила сети и не выполняет блокировки.
|
- Раздел сетевого периметра работает как витрина наблюдения. Он не меняет правила сети и не выполняет блокировки.
|
||||||
@@ -85,3 +90,4 @@ Playwright smoke:
|
|||||||
- JS-ошибок нет;
|
- JS-ошибок нет;
|
||||||
- API-ответов с кодом 400+ нет;
|
- API-ответов с кодом 400+ нет;
|
||||||
- раздел рисков включает темную тему.
|
- раздел рисков включает темную тему.
|
||||||
|
- смена периода `Сегодня` -> `Неделя` меняет содержимое страницы и показывает плашку периода.
|
||||||
|
|||||||
Reference in New Issue
Block a user