From dfecc637b53c2f810bb7fcf3c57033f8793af18b Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 19:35:31 +0300 Subject: [PATCH] feat(portal): add loading refresh status --- .../crates/detmir-portal/src/static/app.css | 120 ++++++++ .../crates/detmir-portal/src/static/app.js | 271 +++++++++++++++--- .../detmir-portal/src/static/index.html | 13 + docs/PORTAL_RU.md | 22 ++ scripts/detmir-portal-tabs-smoke.mjs | 12 + 5 files changed, 406 insertions(+), 32 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 7e23590..94f9f44 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -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; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index cde0e64..47819f5 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -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 ` +
+
+ Данные загружаются +

${ui(stage)}

+
+
+ ${Array.from({ length: 6 }).map(() => ` +
+ + + +
+ `).join("")} +
+
+
+ + + + +
+
+ + + + +
+
+
+ `; +} + +function renderEmptyState(stage = "Данных за выбранный период нет") { + return ` +
+ EMPTY +

Данных пока нет

+

${ui(stage)}

+
+ `; +} + +function renderErrorState(error) { + const message = error?.message || error?.stack || String(error || "Неизвестная ошибка"); + return ` +
+ ERROR +

Ошибка загрузки данных

+

Портал не получил актуальные данные. Подробности ниже.

+
${escapeHtml(message)}
+
+ `; +} + +function staleBanner(error) { + const message = error?.message || String(error || "ошибка обновления"); + return ` +
+ STALE + Показаны ранее загруженные данные. +

Последнее обновление: ${escapeHtml(formatLoadTime(state.load.lastUpdatedAt))}. Новая загрузка не завершилась: ${escapeHtml(message)}

+
+ `; +} + +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 = `

Загрузка...

`; - refresh().catch(showError); + refresh({ stage: tabLoadingStage(tab) }); } function applySecurityMode(tab) { @@ -2326,7 +2518,22 @@ function consumePendingScroll() { } function showError(error) { - document.getElementById("content").innerHTML = `
${escapeHtml(error.stack || error.message || error)}
`; + 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 = `

Обновление периода...

`; - 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); diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html index f52f808..bfd8f1f 100644 --- a/adk-rust/crates/detmir-portal/src/static/index.html +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -59,6 +59,19 @@ +
+
+
+ LOADING + Инициализация портала +
+ последнее обновление: - +
+ +
+
diff --git a/docs/PORTAL_RU.md b/docs/PORTAL_RU.md index bfb15a1..43da6c6 100644 --- a/docs/PORTAL_RU.md +++ b/docs/PORTAL_RU.md @@ -19,6 +19,28 @@ - `Отчеты` - Markdown/PDF/JSON и управленческий текст. - `Настройки` - read-only параметры расчета. +## Загрузка и актуальность данных + +В верхней части портала есть глобальная полоса `Data Loading & Refresh +Status`. Она показывает, что сейчас происходит с данными, и не смешивает +состояния `нет данных` и `данные еще загружаются`. + +Статусы: + +- `LOADING` - портал загружает сводку, отчеты или выбранный раздел; +- `READY` - данные успешно загружены и экран актуален; +- `EMPTY` - источники ответили, но полезных записей для выбранного раздела + или периода нет; +- `STALE` - показаны ранее загруженные данные, новое фоновое обновление не + завершилось; +- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет. + +Во время `LOADING` портал показывает skeleton placeholders и текст `Данные +загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до +завершения загрузки. После успешного обновления отображается время последнего +обновления. При `STALE` старый экран остается доступным, но сверху появляется +предупреждение о неудачном обновлении. + ## Executive Dashboard Показывает: diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 2342e9c..bce0133 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -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(