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)}
+Портал не получил актуальные данные. Подробности ниже.
+${escapeHtml(message)}
+ Загрузка...
`; - 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 @@ +