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