fix(portal): localize loading states for pilot

This commit is contained in:
igor04091968
2026-06-04 22:30:06 +03:00
parent 8957161317
commit 88dcd8ecaa
4 changed files with 88 additions and 50 deletions
+39 -25
View File
@@ -22,21 +22,21 @@ const VIEW_MODES = {
title: "Роль руководителя", title: "Роль руководителя",
heading: "Представление руководителя", heading: "Представление руководителя",
description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.", description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.",
stage: "Загрузка представления руководителя", stage: "Формирование главного вывода",
}, },
security: { security: {
label: "Безопасность", label: "Безопасность",
title: "Роль безопасности", title: "Роль безопасности",
heading: "Представление безопасности", heading: "Представление безопасности",
description: "Очередь проверки, расследования, аудит решений и пакеты расследований.", description: "Очередь проверки, расследования, аудит решений и пакеты расследований.",
stage: "Загрузка представления безопасности", stage: "Подготовка разделов проверки и расследований",
}, },
operations: { operations: {
label: "Эксплуатация", label: "Эксплуатация",
title: "Роль эксплуатации", title: "Роль эксплуатации",
heading: "Представление эксплуатации", heading: "Представление эксплуатации",
description: "Полнота данных, качество сбора, ошибки и телеметрия рабочих мест.", description: "Полнота данных, качество сбора, ошибки и телеметрия рабочих мест.",
stage: "Загрузка представления эксплуатации", stage: "Подготовка разделов эксплуатации",
}, },
}; };
@@ -92,6 +92,7 @@ function displayText(value) {
.replaceAll("Risk Narrative", "Главный вывод") .replaceAll("Risk Narrative", "Главный вывод")
.replaceAll("Risk Heatmap", "Карта рисков") .replaceAll("Risk Heatmap", "Карта рисков")
.replaceAll("Business Risk", "Риски подразделений") .replaceAll("Business Risk", "Риски подразделений")
.replaceAll("Security Events", "События безопасности")
.replaceAll("Security Correlation", "Связь рисков и активности") .replaceAll("Security Correlation", "Связь рисков и активности")
.replaceAll("Incident Candidates", "Требует проверки") .replaceAll("Incident Candidates", "Требует проверки")
.replaceAll("Incident Candidate", "Требует проверки") .replaceAll("Incident Candidate", "Требует проверки")
@@ -286,6 +287,17 @@ function setLoadStatus(status, stage, progress, options = {}) {
renderLoadStatus(); renderLoadStatus();
} }
function loadStatusLabel(status) {
return {
LOADING: "Загрузка данных",
READY: "Данные готовы",
EMPTY: "Данные отсутствуют",
STALE: "Данные устарели",
ERROR: "Ошибка получения данных",
UNKNOWN: "Состояние неизвестно",
}[status] || "Состояние неизвестно";
}
function renderLoadStatus() { function renderLoadStatus() {
const box = document.getElementById("loadingStatus"); const box = document.getElementById("loadingStatus");
if (!box) return; if (!box) return;
@@ -297,7 +309,8 @@ function renderLoadStatus() {
const updatedText = document.getElementById("loadingUpdatedText"); const updatedText = document.getElementById("loadingUpdatedText");
const bar = document.getElementById("loadingProgressBar"); const bar = document.getElementById("loadingProgressBar");
box.className = `loading-status loading-status-${status.toLowerCase()}`; box.className = `loading-status loading-status-${status.toLowerCase()}`;
if (stateText) stateText.textContent = status; box.dataset.loadStatus = status;
if (stateText) stateText.textContent = loadStatusLabel(status);
if (stageText) stageText.textContent = stage; if (stageText) stageText.textContent = stage;
if (updatedText) updatedText.textContent = `последнее обновление: ${formatLoadTime(state.load.lastUpdatedAt)}`; if (updatedText) updatedText.textContent = `последнее обновление: ${formatLoadTime(state.load.lastUpdatedAt)}`;
if (bar) bar.style.width = `${progress}%`; if (bar) bar.style.width = `${progress}%`;
@@ -320,7 +333,7 @@ function renderLoadingContent(stage = "Данные загружаются") {
return ` return `
<div class="loading-shell" data-loading-state="LOADING"> <div class="loading-shell" data-loading-state="LOADING">
<div class="loading-message"> <div class="loading-message">
<strong>Данные загружаются</strong> <strong>Загрузка данных</strong>
<p class="muted small">${ui(stage)}</p> <p class="muted small">${ui(stage)}</p>
</div> </div>
<section class="skeleton-grid" aria-label="Загрузка основных показателей"> <section class="skeleton-grid" aria-label="Загрузка основных показателей">
@@ -353,7 +366,7 @@ function renderLoadingContent(stage = "Данные загружаются") {
function renderEmptyState(stage = "Данных за выбранный период нет") { function renderEmptyState(stage = "Данных за выбранный период нет") {
return ` return `
<section class="empty-state" data-loading-state="EMPTY"> <section class="empty-state" data-loading-state="EMPTY">
<span class="badge status-unknown">EMPTY</span> <span class="badge status-unknown">Данные отсутствуют</span>
<h3>Данных пока нет</h3> <h3>Данных пока нет</h3>
<p class="muted">${ui(stage)}</p> <p class="muted">${ui(stage)}</p>
</section> </section>
@@ -364,8 +377,8 @@ function renderErrorState(error) {
const message = error?.message || error?.stack || String(error || "Неизвестная ошибка"); const message = error?.message || error?.stack || String(error || "Неизвестная ошибка");
return ` return `
<section class="error-state" data-loading-state="ERROR"> <section class="error-state" data-loading-state="ERROR">
<span class="badge status-fail">ERROR</span> <span class="badge status-fail">Ошибка получения данных</span>
<h3>Ошибка загрузки данных</h3> <h3>Ошибка получения данных</h3>
<p class="muted">Портал не получил актуальные данные. Подробности ниже.</p> <p class="muted">Портал не получил актуальные данные. Подробности ниже.</p>
<pre>${escapeHtml(message)}</pre> <pre>${escapeHtml(message)}</pre>
</section> </section>
@@ -376,7 +389,7 @@ function staleBanner(error) {
const message = error?.message || String(error || "ошибка обновления"); const message = error?.message || String(error || "ошибка обновления");
return ` return `
<div class="stale-banner" data-loading-state="STALE"> <div class="stale-banner" data-loading-state="STALE">
<span class="badge status-warn">STALE</span> <span class="badge status-warn">Данные устарели</span>
<strong>Показаны ранее загруженные данные.</strong> <strong>Показаны ранее загруженные данные.</strong>
<p class="muted small">Последнее обновление: ${escapeHtml(formatLoadTime(state.load.lastUpdatedAt))}. Новая загрузка не завершилась: ${escapeHtml(message)}</p> <p class="muted small">Последнее обновление: ${escapeHtml(formatLoadTime(state.load.lastUpdatedAt))}. Новая загрузка не завершилась: ${escapeHtml(message)}</p>
</div> </div>
@@ -2750,33 +2763,34 @@ function renderSettings(report) {
async function refresh(options = {}) { async function refresh(options = {}) {
const content = document.getElementById("content"); const content = document.getElementById("content");
const background = Boolean(options.background); const background = Boolean(options.background);
const stage = options.stage || "Загрузка данных портала"; const stage = options.stage || "Получение данных";
try { try {
setLoadStatus("LOADING", stage, background ? 35 : 8); setLoadStatus("LOADING", stage, background ? 35 : 8);
if (!background && content) content.innerHTML = renderLoadingContent(stage); if (!background && content) content.innerHTML = renderLoadingContent(stage);
if (!state.links) { if (!state.links) {
setLoadStatus("LOADING", "Загрузка ссылок портала", 18); setLoadStatus("LOADING", "Получение данных", 18);
state.links = await loadJson("/links"); state.links = await loadJson("/links");
} }
setLoadStatus("LOADING", "Загрузка общей сводки", 32); setLoadStatus("LOADING", "Расчёт показателей", 34);
const summary = await loadJson("/summary"); const summary = await loadJson("/summary");
setLoadStatus("LOADING", "Проверка готовности системы", 44); setLoadStatus("LOADING", "Расчёт показателей", 46);
state.readiness = { state.readiness = {
bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })), bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })),
verify: state.readiness?.verify || null verify: state.readiness?.verify || null
}; };
renderSummary(summary, state.readiness); renderSummary(summary, state.readiness);
setLoadStatus("LOADING", tabLoadingStage(state.tab), 62); setLoadStatus("LOADING", "Формирование главного вывода", 68);
const tabResult = await loadCurrentTab(); const tabResult = await loadCurrentTab();
setLoadStatus("LOADING", "Подготовка разделов", 88);
if (!hasTabData(state.tab, tabResult)) { if (!hasTabData(state.tab, tabResult)) {
setLoadStatus("EMPTY", "Данных за выбранный период нет", 100); setLoadStatus("EMPTY", "Данные отсутствуют", 100);
if (content) { if (content) {
content.innerHTML = `${renderEmptyState("Источники ответили, но полезные записи для текущего раздела пока не найдены.")}${tabResult.html || ""}`; content.innerHTML = `${renderEmptyState("Источники ответили, но полезные записи для текущего раздела пока не найдены.")}${tabResult.html || ""}`;
} }
return; return;
} }
if (content) content.innerHTML = tabResult.html; if (content) content.innerHTML = tabResult.html;
setLoadStatus("READY", "Данные загружены", 100); setLoadStatus("READY", "Данные готовы", 100);
consumePendingScroll(); consumePendingScroll();
} catch (error) { } catch (error) {
showError(error); showError(error);
@@ -2845,14 +2859,14 @@ async function loadCurrentTab() {
function tabLoadingStage(tab) { function tabLoadingStage(tab) {
if (tab === "operator") return currentViewMeta().stage; if (tab === "operator") return currentViewMeta().stage;
return { return {
employees: "Загрузка карточек сотрудников", employees: "Получение данных сотрудников",
departments: "Загрузка подразделений", departments: "Расчёт показателей подразделений",
owner: "Загрузка рисков", owner: "Формирование главного вывода по рискам",
incidents: "Загрузка расследований", incidents: "Подготовка разделов расследований",
perimeter: "Загрузка сетевого периметра", perimeter: "Подготовка разделов сетевого периметра",
reports: "Формирование отчетов", reports: "Подготовка разделов отчета",
settings: "Загрузка параметров расчета", settings: "Подготовка раздела настроек",
}[tab] || "Загрузка раздела"; }[tab] || "Подготовка разделов";
} }
function setTab(tab) { function setTab(tab) {
@@ -2907,7 +2921,7 @@ document.querySelectorAll("[data-view-mode]").forEach(btn => {
document.getElementById("periodFilter")?.addEventListener("change", event => { document.getElementById("periodFilter")?.addEventListener("change", event => {
state.period = periodFromSelectValue(event.target.value); state.period = periodFromSelectValue(event.target.value);
refresh({ stage: "Обновление периода отчета" }); refresh({ stage: "Получение данных за выбранный период" });
}); });
document.addEventListener("click", event => { document.addEventListener("click", event => {
@@ -57,10 +57,10 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button> <button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
<button class="small-button" data-export-markdown="true">Markdown</button> <button class="small-button" data-export-markdown="true">Текстовый отчет</button>
<button class="small-button" data-print-report="true">PDF</button> <button class="small-button" data-print-report="true">PDF-отчет</button>
<a class="small-button" href="/portal/api/reports" target="_blank" rel="noopener noreferrer">JSON</a> <a class="small-button" href="/portal/api/reports" target="_blank" rel="noopener noreferrer">Данные</a>
<div id="globalStatus" class="status-pill status-unknown">Загрузка</div> <div id="globalStatus" class="status-pill status-unknown">Загрузка данных</div>
</div> </div>
</div> </div>
</header> </header>
@@ -68,8 +68,8 @@
<section id="loadingStatus" class="loading-status loading-status-unknown" aria-live="polite"> <section id="loadingStatus" class="loading-status loading-status-unknown" aria-live="polite">
<div class="loading-status-head"> <div class="loading-status-head">
<div> <div>
<strong id="loadingStateText">LOADING</strong> <strong id="loadingStateText">Загрузка данных</strong>
<span id="loadingStageText">Инициализация портала</span> <span id="loadingStageText">Получение данных</span>
</div> </div>
<span id="loadingUpdatedText">последнее обновление: -</span> <span id="loadingUpdatedText">последнее обновление: -</span>
</div> </div>
+21 -13
View File
@@ -53,21 +53,29 @@ SIEM-представление не выводятся.
данных`. Она показывает, что сейчас происходит с данными, и не смешивает данных`. Она показывает, что сейчас происходит с данными, и не смешивает
состояния `нет данных` и `данные еще загружаются`. состояния `нет данных` и `данные еще загружаются`.
Статусы: Видимые статусы:
- `LOADING` - портал загружает сводку, отчеты или выбранный раздел; - `Загрузка данных` - портал получает данные и готовит экран;
- `READY` - данные успешно загружены и экран актуален; - `Данные готовы` - данные успешно загружены и экран актуален;
- `EMPTY` - источники ответили, но полезных записей для выбранного раздела - `Данные отсутствуют` - источники ответили, но полезных записей для выбранного
или периода нет; раздела или периода нет;
- `STALE` - показаны ранее загруженные данные, новое фоновое обновление не - `Данные устарели` - показаны ранее загруженные данные, новое фоновое
завершилось; обновление не завершилось;
- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет. - `Ошибка получения данных` - первичная загрузка не удалась, актуальных данных
на экране нет.
Во время `LOADING` портал показывает заготовки карточек и текст `Данные Этапы прогресса:
загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до
завершения загрузки. После успешного обновления отображается время последнего - `Получение данных`;
обновления. При `STALE` старый экран остается доступным, но сверху появляется - `Расчёт показателей`;
предупреждение о неудачном обновлении. - `Формирование главного вывода`;
- `Подготовка разделов`.
Во время загрузки портал показывает заготовки карточек и текст `Загрузка
данных`; пустые таблицы и сообщения `Нет данных` не выводятся до завершения
загрузки. После успешного обновления отображается время последнего обновления.
При состоянии `Данные устарели` старый экран остается доступным, но сверху
появляется предупреждение о неудачном обновлении.
## Сводка руководителя ## Сводка руководителя
+22 -6
View File
@@ -69,6 +69,21 @@ function assertStaticTabHandlers() {
if (missing.length) { if (missing.length) {
throw new Error(`data-tab without app.js handler: ${missing.join(", ")}`); throw new Error(`data-tab without app.js handler: ${missing.join(", ")}`);
} }
const loadingTerms = [
"Загрузка данных",
"Данные готовы",
"Данные отсутствуют",
"Данные устарели",
"Ошибка получения данных",
"Получение данных",
"Расчёт показателей",
"Формирование главного вывода",
"Подготовка разделов",
];
const missingLoadingTerms = loadingTerms.filter((term) => !app.includes(term) && !index.includes(term));
if (missingLoadingTerms.length) {
throw new Error(`missing loading/localization terms: ${missingLoadingTerms.join(", ")}`);
}
return tabs; return tabs;
} }
@@ -124,7 +139,7 @@ async function main() {
await page.waitForSelector(".tab", { timeout }); await page.waitForSelector(".tab", { timeout });
smokeStep = "wait_initial_ready"; smokeStep = "wait_initial_ready";
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector("#loadingStateText")?.textContent === "READY", () => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY",
null, null,
{ timeout }, { timeout },
); );
@@ -132,8 +147,9 @@ async function main() {
name: "loading_refresh_status_ready", name: "loading_refresh_status_ready",
ok: ok:
(await page.locator("#loadingStatus").count()) === 1 (await page.locator("#loadingStatus").count()) === 1
&& (await page.locator("#loadingStageText").innerText({ timeout })).includes("Данные загружены") && (await page.locator("#loadingStateText").innerText({ timeout })).includes("Данные готовы")
&& !(await page.locator("body").innerText({ timeout })).includes("Данные загружаются"), && (await page.locator("#loadingStageText").innerText({ timeout })).includes("Данные готовы")
&& !(await page.locator("body").innerText({ timeout })).includes("Загрузка данных"),
}); });
smokeStep = "api:security_events_summary"; smokeStep = "api:security_events_summary";
const reportsPayload = await page.evaluate(async () => { const reportsPayload = await page.evaluate(async () => {
@@ -230,7 +246,7 @@ async function main() {
smokeStep = "role:security"; smokeStep = "role:security";
await page.click('[data-view-mode="security"]', { timeout }); await page.click('[data-view-mode="security"]', { timeout });
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector("#loadingStateText")?.textContent === "READY" () => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Материалы расследования"), && document.body.innerText.includes("Материалы расследования"),
null, null,
{ timeout }, { timeout },
@@ -258,7 +274,7 @@ async function main() {
smokeStep = "role:operations"; smokeStep = "role:operations";
await page.click('[data-view-mode="operations"]', { timeout }); await page.click('[data-view-mode="operations"]', { timeout });
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector("#loadingStateText")?.textContent === "READY" () => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Телеметрия"), && document.body.innerText.includes("Телеметрия"),
null, null,
{ timeout }, { timeout },
@@ -306,7 +322,7 @@ async function main() {
await page.click('button[data-tab="operator"]', { timeout }); await page.click('button[data-tab="operator"]', { timeout });
await page.click('[data-view-mode="security"]', { timeout }); await page.click('[data-view-mode="security"]', { timeout });
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector("#loadingStateText")?.textContent === "READY" () => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Требует проверки"), && document.body.innerText.includes("Требует проверки"),
null, null,
{ timeout }, { timeout },