feat(portal): add loading refresh status
This commit is contained in:
@@ -210,6 +210,125 @@ h1 {
|
|||||||
margin-bottom: 0;
|
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,
|
.card,
|
||||||
.content-panel {
|
.content-panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -1000,6 +1119,7 @@ pre {
|
|||||||
h1 { font-size: 24px; }
|
h1 { font-size: 24px; }
|
||||||
.page-head { flex-direction: column; }
|
.page-head { flex-direction: column; }
|
||||||
.period-banner { flex-direction: column; align-items: flex-start; }
|
.period-banner { flex-direction: column; align-items: flex-start; }
|
||||||
|
.loading-status-head { flex-direction: column; }
|
||||||
.band-head { flex-direction: column; align-items: flex-start; }
|
.band-head { flex-direction: column; align-items: flex-start; }
|
||||||
.row { grid-template-columns: 1fr; }
|
.row { grid-template-columns: 1fr; }
|
||||||
.executive-grid { grid-template-columns: 1fr; }
|
.executive-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -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() {
|
function apiBase() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -23,8 +37,8 @@ async function postJson(path, payload) {
|
|||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
const s = String(status || "UNKNOWN").toLowerCase();
|
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 === "ok" || s === "ready" || 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 === "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 === "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";
|
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
|
||||||
return "status-unknown";
|
return "status-unknown";
|
||||||
@@ -165,6 +179,144 @@ function label(name) {
|
|||||||
}[name] || 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) {
|
function findKpi(report, needle) {
|
||||||
const text = String(needle || "").toLowerCase();
|
const text = String(needle || "").toLowerCase();
|
||||||
return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text));
|
return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text));
|
||||||
@@ -2237,40 +2389,68 @@ function renderSettings(report) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh(options = {}) {
|
||||||
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);
|
|
||||||
const content = document.getElementById("content");
|
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") {
|
if (state.tab === "operator") {
|
||||||
const data = await loadJson("/operator");
|
const data = await loadJson("/operator");
|
||||||
state.reports = await loadJson("/reports").catch(() => state.reports);
|
state.reports = await loadJson("/reports").catch(() => state.reports);
|
||||||
content.innerHTML = renderOperator(data, state.reports);
|
|
||||||
updateFilters(state.reports);
|
updateFilters(state.reports);
|
||||||
|
return { data, report: state.reports, html: renderOperator(data, state.reports) };
|
||||||
}
|
}
|
||||||
if (state.tab === "manager") {
|
if (state.tab === "manager") {
|
||||||
const data = await loadJson("/manager");
|
const data = await loadJson("/manager");
|
||||||
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
|
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") {
|
if (state.tab === "departments") {
|
||||||
const data = await loadJson("/reports");
|
const data = await loadJson("/reports");
|
||||||
state.reports = data;
|
state.reports = data;
|
||||||
content.innerHTML = renderDepartments(data);
|
|
||||||
updateFilters(data);
|
updateFilters(data);
|
||||||
|
return { data, html: renderDepartments(data) };
|
||||||
}
|
}
|
||||||
if (state.tab === "employees") {
|
if (state.tab === "employees") {
|
||||||
const data = await loadJson("/manager");
|
const data = await loadJson("/manager");
|
||||||
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
|
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") {
|
if (state.tab === "owner") {
|
||||||
const data = await loadJson("/owner");
|
const data = await loadJson("/owner");
|
||||||
content.innerHTML = renderOwner(data);
|
return { data, html: renderOwner(data) };
|
||||||
}
|
}
|
||||||
if (state.tab === "incidents") {
|
if (state.tab === "incidents") {
|
||||||
const data = await loadJson("/incidents");
|
const data = await loadJson("/incidents");
|
||||||
@@ -2278,27 +2458,40 @@ async function refresh() {
|
|||||||
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
||||||
const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
||||||
state.reports = reports;
|
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") {
|
if (state.tab === "perimeter") {
|
||||||
const data = await loadJson("/owner");
|
const data = await loadJson("/owner");
|
||||||
const reports = await loadJson("/reports").catch(() => state.reports);
|
const reports = await loadJson("/reports").catch(() => state.reports);
|
||||||
state.reports = reports;
|
state.reports = reports;
|
||||||
content.innerHTML = renderPerimeter(data, reports);
|
return { data, report: reports, html: renderPerimeter(data, reports) };
|
||||||
}
|
}
|
||||||
if (state.tab === "reports") {
|
if (state.tab === "reports") {
|
||||||
const data = await loadJson("/reports");
|
const data = await loadJson("/reports");
|
||||||
state.reports = data;
|
state.reports = data;
|
||||||
content.innerHTML = renderReports(data);
|
|
||||||
updateFilters(data);
|
updateFilters(data);
|
||||||
|
return { data, html: renderReports(data) };
|
||||||
}
|
}
|
||||||
if (state.tab === "settings") {
|
if (state.tab === "settings") {
|
||||||
const data = await loadJson("/reports").catch(() => state.reports || {});
|
const data = await loadJson("/reports").catch(() => state.reports || {});
|
||||||
state.reports = data;
|
state.reports = data;
|
||||||
content.innerHTML = renderSettings(data);
|
|
||||||
updateFilters(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) {
|
function setTab(tab) {
|
||||||
@@ -2307,8 +2500,7 @@ function setTab(tab) {
|
|||||||
document.querySelectorAll(".tab").forEach(btn => {
|
document.querySelectorAll(".tab").forEach(btn => {
|
||||||
btn.classList.toggle("is-active", btn.dataset.tab === tab);
|
btn.classList.toggle("is-active", btn.dataset.tab === tab);
|
||||||
});
|
});
|
||||||
document.getElementById("content").innerHTML = `<p class="muted">Загрузка...</p>`;
|
refresh({ stage: tabLoadingStage(tab) });
|
||||||
refresh().catch(showError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySecurityMode(tab) {
|
function applySecurityMode(tab) {
|
||||||
@@ -2326,7 +2518,22 @@ function consumePendingScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(error) {
|
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 => {
|
document.querySelectorAll(".tab").forEach(btn => {
|
||||||
@@ -2335,8 +2542,7 @@ document.querySelectorAll(".tab").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);
|
||||||
document.getElementById("content").innerHTML = `<p class="muted">Обновление периода...</p>`;
|
refresh({ stage: "Обновление периода отчета" });
|
||||||
refresh().catch(showError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", event => {
|
document.addEventListener("click", event => {
|
||||||
@@ -2482,7 +2688,7 @@ async function incidentAction(button) {
|
|||||||
}
|
}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
await postJson("/incidents/action", payload);
|
await postJson("/incidents/action", payload);
|
||||||
await refresh();
|
await refresh({ stage: "Обновление статуса инцидента" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function candidateReviewAction(button) {
|
async function candidateReviewAction(button) {
|
||||||
@@ -2499,7 +2705,7 @@ async function candidateReviewAction(button) {
|
|||||||
reviewer,
|
reviewer,
|
||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh({ stage: "Обновление проверки кандидата" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCaseAction(button) {
|
async function createCaseAction(button) {
|
||||||
@@ -2518,7 +2724,7 @@ async function createCaseAction(button) {
|
|||||||
summary,
|
summary,
|
||||||
});
|
});
|
||||||
state.tab = "incidents";
|
state.tab = "incidents";
|
||||||
await refresh();
|
await refresh({ stage: "Создание дела и обновление расследований" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function caseStatusAction(button) {
|
async function caseStatusAction(button) {
|
||||||
@@ -2531,9 +2737,10 @@ async function caseStatusAction(button) {
|
|||||||
status,
|
status,
|
||||||
decision,
|
decision,
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh({ stage: "Обновление статуса дела" });
|
||||||
}
|
}
|
||||||
|
|
||||||
applySecurityMode(state.tab);
|
applySecurityMode(state.tab);
|
||||||
refresh().catch(showError);
|
renderLoadStatus();
|
||||||
setInterval(() => refresh().catch(showError), 60000);
|
refresh({ stage: "Первичная загрузка портала" });
|
||||||
|
setInterval(() => refresh({ background: true, stage: "Фоновое обновление данных" }), 60000);
|
||||||
|
|||||||
@@ -59,6 +59,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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="content" class="content-panel" aria-live="polite"></section>
|
||||||
<section id="summary" class="summary-grid service-summary"></section>
|
<section id="summary" class="summary-grid service-summary"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -19,6 +19,28 @@
|
|||||||
- `Отчеты` - Markdown/PDF/JSON и управленческий текст.
|
- `Отчеты` - Markdown/PDF/JSON и управленческий текст.
|
||||||
- `Настройки` - read-only параметры расчета.
|
- `Настройки` - read-only параметры расчета.
|
||||||
|
|
||||||
|
## Загрузка и актуальность данных
|
||||||
|
|
||||||
|
В верхней части портала есть глобальная полоса `Data Loading & Refresh
|
||||||
|
Status`. Она показывает, что сейчас происходит с данными, и не смешивает
|
||||||
|
состояния `нет данных` и `данные еще загружаются`.
|
||||||
|
|
||||||
|
Статусы:
|
||||||
|
|
||||||
|
- `LOADING` - портал загружает сводку, отчеты или выбранный раздел;
|
||||||
|
- `READY` - данные успешно загружены и экран актуален;
|
||||||
|
- `EMPTY` - источники ответили, но полезных записей для выбранного раздела
|
||||||
|
или периода нет;
|
||||||
|
- `STALE` - показаны ранее загруженные данные, новое фоновое обновление не
|
||||||
|
завершилось;
|
||||||
|
- `ERROR` - первичная загрузка не удалась, актуальных данных на экране нет.
|
||||||
|
|
||||||
|
Во время `LOADING` портал показывает skeleton placeholders и текст `Данные
|
||||||
|
загружаются`; пустые таблицы и сообщения `Нет данных` не выводятся до
|
||||||
|
завершения загрузки. После успешного обновления отображается время последнего
|
||||||
|
обновления. При `STALE` старый экран остается доступным, но сверху появляется
|
||||||
|
предупреждение о неудачном обновлении.
|
||||||
|
|
||||||
## Executive Dashboard
|
## Executive Dashboard
|
||||||
|
|
||||||
Показывает:
|
Показывает:
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
||||||
await page.waitForSelector(".tab", { 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) {
|
for (const item of expectedTabs) {
|
||||||
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
|
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
|
|||||||
Reference in New Issue
Block a user