feat(portal): add loading refresh status

This commit is contained in:
igor04091968
2026-06-04 19:35:31 +03:00
parent a672941cc7
commit dfecc637b5
5 changed files with 406 additions and 32 deletions
@@ -210,6 +210,125 @@ h1 {
margin-bottom: 0;
}
.loading-status {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--line);
border-left: 4px solid var(--unknown);
border-radius: 8px;
background: var(--panel);
}
.loading-status-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.loading-status-head > div {
display: grid;
gap: 2px;
}
.loading-status-head strong {
font-size: 13px;
letter-spacing: 0;
}
.loading-status-head span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.loading-progress {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--unknown-bg);
}
.loading-progress > div {
height: 100%;
width: 0%;
border-radius: inherit;
background: var(--accent);
transition: width 180ms ease;
}
.loading-status-loading { border-left-color: var(--accent); }
.loading-status-ready { border-left-color: var(--ok); }
.loading-status-stale { border-left-color: var(--warn); }
.loading-status-empty { border-left-color: var(--unknown); }
.loading-status-error { border-left-color: var(--fail); }
.loading-status-error .loading-progress > div { background: var(--fail); }
.loading-status-stale .loading-progress > div { background: var(--warn); }
.loading-status-ready .loading-progress > div { background: var(--ok); }
.loading-shell {
display: grid;
gap: 14px;
}
.loading-message,
.empty-state,
.error-state,
.stale-banner {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.error-state {
border-left: 4px solid var(--fail);
}
.stale-banner {
margin-bottom: 12px;
border-left: 4px solid var(--warn);
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.skeleton-card,
.skeleton-panel {
min-height: 118px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.skeleton-panel {
min-height: 220px;
}
.skeleton-line {
display: block;
height: 12px;
margin-bottom: 10px;
border-radius: 999px;
background: linear-gradient(90deg, var(--unknown-bg), #e2e8f0, var(--unknown-bg));
background-size: 220% 100%;
animation: skeleton-pulse 1.2s ease-in-out infinite;
}
.skeleton-line.short { width: 42%; }
.skeleton-line.medium { width: 66%; }
.skeleton-line.long { width: 88%; }
@keyframes skeleton-pulse {
0% { background-position: 0% 0; }
100% { background-position: -220% 0; }
}
.card,
.content-panel {
background: var(--panel);
@@ -1000,6 +1119,7 @@ pre {
h1 { font-size: 24px; }
.page-head { flex-direction: column; }
.period-banner { flex-direction: column; align-items: flex-start; }
.loading-status-head { flex-direction: column; }
.band-head { flex-direction: column; align-items: flex-start; }
.row { grid-template-columns: 1fr; }
.executive-grid { grid-template-columns: 1fr; }
+239 -32
View File
@@ -1,4 +1,18 @@
const state = { tab: "operator", period: "today", links: null, readiness: null, reports: null, pendingScrollSelector: null };
const state = {
tab: "operator",
period: "today",
links: null,
readiness: null,
reports: null,
pendingScrollSelector: null,
load: {
status: "LOADING",
stage: "Инициализация портала",
progress: 0,
lastUpdatedAt: null,
lastError: null,
},
};
function apiBase() {
const path = window.location.pathname;
@@ -23,8 +37,8 @@ async function postJson(path, payload) {
function statusClass(status) {
const s = String(status || "UNKNOWN").toLowerCase();
if (s === "ok" || s === "true" || s === "normal" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok";
if (s === "warn" || s === "warning" || s === "attention" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed" || s === "open" || s === "in_progress") return "status-warn";
if (s === "ok" || s === "ready" || s === "true" || s === "normal" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok";
if (s === "loading" || s === "warn" || s === "warning" || s === "attention" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed" || s === "open" || s === "in_progress") return "status-warn";
if (s === "degraded" || s === "high" || s === "high_risk" || s === "confirmed" || s === "rejected" || s === "archived") return "status-degraded";
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
return "status-unknown";
@@ -165,6 +179,144 @@ function label(name) {
}[name] || name;
}
function setLoadStatus(status, stage, progress, options = {}) {
state.load.status = status;
state.load.stage = stage || state.load.stage || "Обновление данных";
state.load.progress = Math.max(0, Math.min(100, Number(progress) || 0));
if (status === "READY" || status === "EMPTY") {
state.load.lastUpdatedAt = new Date().toISOString();
state.load.lastError = null;
}
if (status === "ERROR" || status === "STALE") {
state.load.lastError = options.error || state.load.lastError || null;
}
renderLoadStatus();
}
function renderLoadStatus() {
const box = document.getElementById("loadingStatus");
if (!box) return;
const status = state.load.status || "UNKNOWN";
const stage = state.load.stage || "Состояние неизвестно";
const progress = Math.max(0, Math.min(100, Number(state.load.progress) || 0));
const stateText = document.getElementById("loadingStateText");
const stageText = document.getElementById("loadingStageText");
const updatedText = document.getElementById("loadingUpdatedText");
const bar = document.getElementById("loadingProgressBar");
box.className = `loading-status loading-status-${status.toLowerCase()}`;
if (stateText) stateText.textContent = status;
if (stageText) stageText.textContent = stage;
if (updatedText) updatedText.textContent = `последнее обновление: ${formatLoadTime(state.load.lastUpdatedAt)}`;
if (bar) bar.style.width = `${progress}%`;
}
function formatLoadTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function renderLoadingContent(stage = "Данные загружаются") {
return `
<div class="loading-shell" data-loading-state="LOADING">
<div class="loading-message">
<strong>Данные загружаются</strong>
<p class="muted small">${ui(stage)}</p>
</div>
<section class="skeleton-grid" aria-label="Загрузка основных показателей">
${Array.from({ length: 6 }).map(() => `
<article class="skeleton-card">
<span class="skeleton-line short"></span>
<span class="skeleton-line medium"></span>
<span class="skeleton-line long"></span>
</article>
`).join("")}
</section>
<section class="grid-2">
<article class="skeleton-panel">
<span class="skeleton-line medium"></span>
<span class="skeleton-line long"></span>
<span class="skeleton-line long"></span>
<span class="skeleton-line medium"></span>
</article>
<article class="skeleton-panel">
<span class="skeleton-line short"></span>
<span class="skeleton-line long"></span>
<span class="skeleton-line medium"></span>
<span class="skeleton-line long"></span>
</article>
</section>
</div>
`;
}
function renderEmptyState(stage = "Данных за выбранный период нет") {
return `
<section class="empty-state" data-loading-state="EMPTY">
<span class="badge status-unknown">EMPTY</span>
<h3>Данных пока нет</h3>
<p class="muted">${ui(stage)}</p>
</section>
`;
}
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>
<p class="muted">Портал не получил актуальные данные. Подробности ниже.</p>
<pre>${escapeHtml(message)}</pre>
</section>
`;
}
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>
<strong>Показаны ранее загруженные данные.</strong>
<p class="muted small">Последнее обновление: ${escapeHtml(formatLoadTime(state.load.lastUpdatedAt))}. Новая загрузка не завершилась: ${escapeHtml(message)}</p>
</div>
`;
}
function hasTabData(tab, payload) {
if (!payload) return false;
if (tab === "operator") {
const report = payload.report || {};
return (Array.isArray(report.kpis) && report.kpis.length > 0)
|| (Array.isArray(report.executive_points) && report.executive_points.length > 0)
|| (Array.isArray(payload.data?.workforce) && payload.data.workforce.length > 0);
}
if (tab === "employees") {
return Array.isArray(payload.data?.workforce) && payload.data.workforce.length > 0;
}
if (tab === "departments") {
return Array.isArray(payload.data?.workforce?.department_comparison)
&& payload.data.workforce.department_comparison.length > 0;
}
if (tab === "owner" || tab === "perimeter") {
return Boolean(payload.data && Object.keys(payload.data).length > 0);
}
if (tab === "incidents") {
return (Array.isArray(payload.data?.incidents) && payload.data.incidents.length > 0)
|| (Array.isArray(payload.data?.reports?.risk_incident_candidates) && payload.data.reports.risk_incident_candidates.length > 0)
|| (Array.isArray(payload.data?.cases?.cases) && payload.data.cases.cases.length > 0);
}
if (tab === "reports" || tab === "settings") return Boolean(payload.data && Object.keys(payload.data).length > 0);
return true;
}
function findKpi(report, needle) {
const text = String(needle || "").toLowerCase();
return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text));
@@ -2237,40 +2389,68 @@ function renderSettings(report) {
`;
}
async function refresh() {
if (!state.links) state.links = await loadJson("/links");
const summary = await loadJson("/summary");
state.readiness = {
bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })),
verify: state.readiness?.verify || null
};
renderSummary(summary, state.readiness);
async function refresh(options = {}) {
const content = document.getElementById("content");
const background = Boolean(options.background);
const stage = options.stage || "Загрузка данных портала";
try {
setLoadStatus("LOADING", stage, background ? 35 : 8);
if (!background && content) content.innerHTML = renderLoadingContent(stage);
if (!state.links) {
setLoadStatus("LOADING", "Загрузка ссылок портала", 18);
state.links = await loadJson("/links");
}
setLoadStatus("LOADING", "Загрузка общей сводки", 32);
const summary = await loadJson("/summary");
setLoadStatus("LOADING", "Проверка готовности системы", 44);
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);
const tabResult = await loadCurrentTab();
if (!hasTabData(state.tab, tabResult)) {
setLoadStatus("EMPTY", "Данных за выбранный период нет", 100);
if (content) {
content.innerHTML = `${renderEmptyState("Источники ответили, но полезные записи для текущего раздела пока не найдены.")}${tabResult.html || ""}`;
}
return;
}
if (content) content.innerHTML = tabResult.html;
setLoadStatus("READY", "Данные загружены", 100);
consumePendingScroll();
} catch (error) {
showError(error);
}
}
async function loadCurrentTab() {
if (state.tab === "operator") {
const data = await loadJson("/operator");
state.reports = await loadJson("/reports").catch(() => state.reports);
content.innerHTML = renderOperator(data, state.reports);
updateFilters(state.reports);
return { data, report: state.reports, html: renderOperator(data, state.reports) };
}
if (state.tab === "manager") {
const data = await loadJson("/manager");
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderManager(data, policyExplain);
return { data, policyExplain, html: renderManager(data, policyExplain) };
}
if (state.tab === "departments") {
const data = await loadJson("/reports");
state.reports = data;
content.innerHTML = renderDepartments(data);
updateFilters(data);
return { data, html: renderDepartments(data) };
}
if (state.tab === "employees") {
const data = await loadJson("/manager");
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderEmployees(data, policyExplain);
return { data, policyExplain, html: renderEmployees(data, policyExplain) };
}
if (state.tab === "owner") {
const data = await loadJson("/owner");
content.innerHTML = renderOwner(data);
return { data, html: renderOwner(data) };
}
if (state.tab === "incidents") {
const data = await loadJson("/incidents");
@@ -2278,27 +2458,40 @@ async function refresh() {
const reports = await loadJson("/reports").catch(() => state.reports || {});
const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
state.reports = reports;
content.innerHTML = renderIncidents({ incidents: data, evidence, reports, cases });
return { data: { incidents: data, evidence, reports, cases }, html: renderIncidents({ incidents: data, evidence, reports, cases }) };
}
if (state.tab === "perimeter") {
const data = await loadJson("/owner");
const reports = await loadJson("/reports").catch(() => state.reports);
state.reports = reports;
content.innerHTML = renderPerimeter(data, reports);
return { data, report: reports, html: renderPerimeter(data, reports) };
}
if (state.tab === "reports") {
const data = await loadJson("/reports");
state.reports = data;
content.innerHTML = renderReports(data);
updateFilters(data);
return { data, html: renderReports(data) };
}
if (state.tab === "settings") {
const data = await loadJson("/reports").catch(() => state.reports || {});
state.reports = data;
content.innerHTML = renderSettings(data);
updateFilters(data);
return { data, html: renderSettings(data) };
}
consumePendingScroll();
return { data: {}, html: renderEmptyState("Раздел не найден.") };
}
function tabLoadingStage(tab) {
return {
operator: "Загрузка связанной картины риска",
employees: "Загрузка карточек сотрудников",
departments: "Загрузка подразделений",
owner: "Загрузка рисков",
incidents: "Загрузка расследований",
perimeter: "Загрузка сетевого периметра",
reports: "Формирование отчетов",
settings: "Загрузка параметров расчета",
}[tab] || "Загрузка раздела";
}
function setTab(tab) {
@@ -2307,8 +2500,7 @@ function setTab(tab) {
document.querySelectorAll(".tab").forEach(btn => {
btn.classList.toggle("is-active", btn.dataset.tab === tab);
});
document.getElementById("content").innerHTML = `<p class="muted">Загрузка...</p>`;
refresh().catch(showError);
refresh({ stage: tabLoadingStage(tab) });
}
function applySecurityMode(tab) {
@@ -2326,7 +2518,22 @@ function consumePendingScroll() {
}
function showError(error) {
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
const hasPreviousData = Boolean(state.load.lastUpdatedAt);
setLoadStatus(
hasPreviousData ? "STALE" : "ERROR",
hasPreviousData ? "Не удалось обновить данные" : "Ошибка загрузки данных",
hasPreviousData ? 100 : 0,
{ error: error?.message || String(error) },
);
const content = document.getElementById("content");
if (!content) return;
if (hasPreviousData) {
if (!content.querySelector("[data-loading-state='STALE']")) {
content.insertAdjacentHTML("afterbegin", staleBanner(error));
}
return;
}
content.innerHTML = renderErrorState(error);
}
document.querySelectorAll(".tab").forEach(btn => {
@@ -2335,8 +2542,7 @@ document.querySelectorAll(".tab").forEach(btn => {
document.getElementById("periodFilter")?.addEventListener("change", event => {
state.period = periodFromSelectValue(event.target.value);
document.getElementById("content").innerHTML = `<p class="muted">Обновление периода...</p>`;
refresh().catch(showError);
refresh({ stage: "Обновление периода отчета" });
});
document.addEventListener("click", event => {
@@ -2482,7 +2688,7 @@ async function incidentAction(button) {
}
button.disabled = true;
await postJson("/incidents/action", payload);
await refresh();
await refresh({ stage: "Обновление статуса инцидента" });
}
async function candidateReviewAction(button) {
@@ -2499,7 +2705,7 @@ async function candidateReviewAction(button) {
reviewer,
comment,
});
await refresh();
await refresh({ stage: "Обновление проверки кандидата" });
}
async function createCaseAction(button) {
@@ -2518,7 +2724,7 @@ async function createCaseAction(button) {
summary,
});
state.tab = "incidents";
await refresh();
await refresh({ stage: "Создание дела и обновление расследований" });
}
async function caseStatusAction(button) {
@@ -2531,9 +2737,10 @@ async function caseStatusAction(button) {
status,
decision,
});
await refresh();
await refresh({ stage: "Обновление статуса дела" });
}
applySecurityMode(state.tab);
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);
renderLoadStatus();
refresh({ stage: "Первичная загрузка портала" });
setInterval(() => refresh({ background: true, stage: "Фоновое обновление данных" }), 60000);
@@ -59,6 +59,19 @@
</div>
</header>
<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>
</div>
<span id="loadingUpdatedText">последнее обновление: -</span>
</div>
<div class="loading-progress" aria-hidden="true">
<div id="loadingProgressBar" style="width: 0%"></div>
</div>
</section>
<section id="content" class="content-panel" aria-live="polite"></section>
<section id="summary" class="summary-grid service-summary"></section>
</main>
+22
View File
@@ -19,6 +19,28 @@
- `Отчеты` - Markdown/PDF/JSON и управленческий текст.
- `Настройки` - read-only параметры расчета.
## Загрузка и актуальность данных
В верхней части портала есть глобальная полоса `Data Loading & Refresh
Status`. Она показывает, что сейчас происходит с данными, и не смешивает
состояния `нет данных` и `данные еще загружаются`.
Статусы:
- `LOADING` - портал загружает сводку, отчеты или выбранный раздел;
- `READY` - данные успешно загружены и экран актуален;
- `EMPTY` - источники ответили, но полезных записей для выбранного раздела
или периода нет;
- `STALE` - показаны ранее загруженные данные, новое фоновое обновление не
завершилось;
- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет.
Во время `LOADING` портал показывает skeleton placeholders и текст `Данные
загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до
завершения загрузки. После успешного обновления отображается время последнего
обновления. При `STALE` старый экран остается доступным, но сверху появляется
предупреждение о неудачном обновлении.
## Executive Dashboard
Показывает:
+12
View File
@@ -104,6 +104,18 @@ async function main() {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
await page.waitForSelector(".tab", { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStateText")?.textContent === "READY",
null,
{ timeout },
);
checks.push({
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("Данные загружаются"),
});
for (const item of expectedTabs) {
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
await page.waitForFunction(