feat(portal): add loading refresh status
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user