fix(portal): localize loading states for pilot
This commit is contained in:
@@ -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
@@ -53,21 +53,29 @@ SIEM-представление не выводятся.
|
|||||||
данных`. Она показывает, что сейчас происходит с данными, и не смешивает
|
данных`. Она показывает, что сейчас происходит с данными, и не смешивает
|
||||||
состояния `нет данных` и `данные еще загружаются`.
|
состояния `нет данных` и `данные еще загружаются`.
|
||||||
|
|
||||||
Статусы:
|
Видимые статусы:
|
||||||
|
|
||||||
- `LOADING` - портал загружает сводку, отчеты или выбранный раздел;
|
- `Загрузка данных` - портал получает данные и готовит экран;
|
||||||
- `READY` - данные успешно загружены и экран актуален;
|
- `Данные готовы` - данные успешно загружены и экран актуален;
|
||||||
- `EMPTY` - источники ответили, но полезных записей для выбранного раздела
|
- `Данные отсутствуют` - источники ответили, но полезных записей для выбранного
|
||||||
или периода нет;
|
раздела или периода нет;
|
||||||
- `STALE` - показаны ранее загруженные данные, новое фоновое обновление не
|
- `Данные устарели` - показаны ранее загруженные данные, новое фоновое
|
||||||
завершилось;
|
обновление не завершилось;
|
||||||
- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет.
|
- `Ошибка получения данных` - первичная загрузка не удалась, актуальных данных
|
||||||
|
на экране нет.
|
||||||
|
|
||||||
Во время `LOADING` портал показывает заготовки карточек и текст `Данные
|
Этапы прогресса:
|
||||||
загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до
|
|
||||||
завершения загрузки. После успешного обновления отображается время последнего
|
- `Получение данных`;
|
||||||
обновления. При `STALE` старый экран остается доступным, но сверху появляется
|
- `Расчёт показателей`;
|
||||||
предупреждение о неудачном обновлении.
|
- `Формирование главного вывода`;
|
||||||
|
- `Подготовка разделов`.
|
||||||
|
|
||||||
|
Во время загрузки портал показывает заготовки карточек и текст `Загрузка
|
||||||
|
данных`; пустые таблицы и сообщения `Нет данных` не выводятся до завершения
|
||||||
|
загрузки. После успешного обновления отображается время последнего обновления.
|
||||||
|
При состоянии `Данные устарели` старый экран остается доступным, но сверху
|
||||||
|
появляется предупреждение о неудачном обновлении.
|
||||||
|
|
||||||
## Сводка руководителя
|
## Сводка руководителя
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user