Backend (quarter_dump_lookup.py): - _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst N concurrent analyze, ставящих N одинаковых harvest task в очередь - _trigger_harvest: использует lock перед apply_async, возвращает False если lock уже взят (другой запрос триггернул раньше) - make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в nspd_dump dict, типичный harvest_quarter = 60с - /analyze: пробрасывает поле через nspd_dump dict (нет typed schema — response_model=None для /analyze endpoint, dict уходит как есть) Frontend (NspdFreshnessBadge, NspdZoningBlock): - Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера - После остановки countdown (remaining=0) NspdZoningBlock показывает «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass) Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged).
137 lines
3.4 KiB
TypeScript
137 lines
3.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
import type { NspdDumpMeta } from "@/types/nspd";
|
||
|
||
interface Props {
|
||
dump: NspdDumpMeta | null | undefined;
|
||
}
|
||
|
||
export function NspdFreshnessBadge({ dump }: Props) {
|
||
// Issue #234: countdown пока harvest идёт. Сбрасывается на ETA при каждом
|
||
// новом triggered=true response (или ETA значении).
|
||
const eta = dump?.harvest_eta_seconds ?? null;
|
||
const [remaining, setRemaining] = useState<number | null>(eta);
|
||
|
||
useEffect(() => {
|
||
setRemaining(eta);
|
||
}, [eta]);
|
||
|
||
useEffect(() => {
|
||
if (remaining === null || remaining <= 0) return;
|
||
const id = setInterval(() => {
|
||
setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
|
||
}, 1000);
|
||
return () => clearInterval(id);
|
||
}, [remaining]);
|
||
|
||
if (!dump) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
background: "#f3f4f6",
|
||
color: "#6b7280",
|
||
borderRadius: 6,
|
||
padding: "1px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}
|
||
title="Данные НСПД недоступны"
|
||
>
|
||
НСПД: нет данных
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (dump.harvest_triggered && !dump.available) {
|
||
const countdownLabel =
|
||
remaining !== null && remaining > 0
|
||
? `НСПД: загрузка ~${remaining}с`
|
||
: "НСПД: загрузка…";
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
background: "#dbeafe",
|
||
color: "#1e40af",
|
||
borderRadius: 6,
|
||
padding: "1px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}
|
||
title={
|
||
eta !== null
|
||
? `Загрузка данных НСПД, осталось ~${remaining ?? 0} сек`
|
||
: "Загрузка данных НСПД запущена"
|
||
}
|
||
>
|
||
{countdownLabel}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (!dump.available) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
background: "#f3f4f6",
|
||
color: "#6b7280",
|
||
borderRadius: 6,
|
||
padding: "1px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}
|
||
title="Дамп НСПД для квартала не загружен"
|
||
>
|
||
НСПД: нет дампа
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const dateStr = dump.fetched_at_utc
|
||
? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")
|
||
: null;
|
||
|
||
if (dump.stale) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
background: "#fef3c7",
|
||
color: "#92400e",
|
||
borderRadius: 6,
|
||
padding: "1px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}
|
||
title={
|
||
dateStr
|
||
? `Данные от ${dateStr} (устаревшие)`
|
||
: "Устаревшие данные НСПД"
|
||
}
|
||
>
|
||
НСПД: устарело{dateStr ? ` (${dateStr})` : ""}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
background: "#dcfce7",
|
||
color: "#15803d",
|
||
borderRadius: 6,
|
||
padding: "1px 8px",
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}
|
||
title={dateStr ? `Данные НСПД от ${dateStr}` : "Данные НСПД актуальны"}
|
||
>
|
||
НСПД: актуально{dateStr ? ` (${dateStr})` : ""}
|
||
</span>
|
||
);
|
||
}
|