feat: expose safe source health status
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-07 16:57:14 +07:00
parent 0701383c28
commit 486016d977
12 changed files with 90 additions and 11 deletions
+2
View File
@@ -18,6 +18,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения. Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных. Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
+13 -2
View File
@@ -16,6 +16,15 @@ from .logging_config import configure_logging
from .models import CommunityImportRun, DataSource from .models import CommunityImportRun, DataSource
logger = logging.getLogger("rf4.community_scheduler") logger = logging.getLogger("rf4.community_scheduler")
MAX_BACKOFF_SECONDS = 24 * 60 * 60
def retry_delay(statuses: list[str]) -> int:
failures = 0
for status in statuses:
if status != "failed":
break
failures += 1
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
def configured_sources(): def configured_sources():
return { return {
@@ -33,8 +42,10 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
source = session.get(DataSource, source_system) source = session.get(DataSource, source_system)
if source is None or not source.enabled: if source is None or not source.enabled:
return False return False
latest = session.scalar(select(CommunityImportRun.started_at).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(1)) recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=settings.community_import_interval_seconds): latest = recent[0].started_at if recent else None
delay = retry_delay([run.status for run in recent])
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
return False return False
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}): if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
return False return False
+26 -2
View File
@@ -24,9 +24,9 @@ from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
from .logging_config import configure_logging from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report from .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
@@ -209,6 +209,30 @@ def community_observations(
return result return result
@app.get("/api/v1/source-status", response_model=list[SourceStatusOut])
def source_status(db: Db) -> list[SourceStatusOut]:
now = datetime.now(timezone.utc)
result = []
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
runs = list(db.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source.key).order_by(CommunityImportRun.started_at.desc()).limit(20)))
latest = runs[0] if runs else None
success = next((run for run in runs if run.status == "success"), None)
if not source.enabled:
state = "disabled"
elif latest is None:
state = "waiting"
elif latest.status == "failed":
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
elif _aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
state = "stale"
else:
state = "healthy"
result.append(SourceStatusOut(source_system=source.key, name=source.name, status=state,
last_started_at=latest.started_at if latest else None, last_success_at=success.started_at if success else None,
observations=db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.source_system == source.key)) or 0))
return result
@app.get("/api/v1/records", response_model=list[OfficialRecordOut]) @app.get("/api/v1/records", response_model=list[OfficialRecordOut])
def records( def records(
db: Db, fish: str | None = None, waterbody: str | None = None, db: Db, fish: str | None = None, waterbody: str | None = None,
+9
View File
@@ -221,3 +221,12 @@ class ExternalObservationPublished(BaseModel):
observation_id: UUID observation_id: UUID
catch_report_id: UUID catch_report_id: UUID
status: str status: str
class SourceStatusOut(BaseModel):
source_system: str
name: str
status: str
last_started_at: datetime | None
last_success_at: datetime | None
observations: int
+12 -1
View File
@@ -12,7 +12,7 @@ from app.database import Base, get_session
from app.community_importer import stage_observations from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning from app.importer import ImportAlreadyRunning
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -66,6 +66,17 @@ def test_list_pagination_and_filter_validation() -> None:
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422 assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
def test_public_source_status_hides_internal_details() -> None:
with Session(engine) as db:
if db.get(DataSource, "rf4db") is None:
db.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
db.commit()
response = client.get("/api/v1/source-status")
assert response.status_code == 200
assert response.json()
assert all("error_summary" not in item and "source_url" not in item for item in response.json())
def test_liveness_does_not_probe_dependencies() -> None: def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
+8 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.community_scheduler import configured_sources from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, retry_delay
from app.config import Settings from app.config import Settings
@@ -12,3 +12,10 @@ def test_all_authorized_sources_are_scheduled() -> None:
def test_community_interval_cannot_be_less_than_30_minutes() -> None: def test_community_interval_cannot_be_less_than_30_minutes() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Settings(community_import_interval_seconds=1799) Settings(community_import_interval_seconds=1799)
def test_failed_runs_back_off_but_success_resets_delay() -> None:
assert retry_delay(["failed"]) == 1800
assert retry_delay(["failed", "failed", "failed"]) == 7200
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
assert retry_delay(["success", "failed"]) == 1800
+2 -1
View File
@@ -1,6 +1,7 @@
--- ---
import "../styles/global.css"; import "../styles/global.css";
import "../styles/catalog.css"; import "../styles/catalog.css";
import "../styles/source-status.css";
import FishingIcon from "../components/FishingIcon.astro"; import FishingIcon from "../components/FishingIcon.astro";
const { const {
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4", title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
@@ -56,6 +57,6 @@ const jsonLd = JSON.stringify({
<p class="live-badge"><span></span> Свежие данные и честная оценка</p> <p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header> </header>
<main id="main-content" tabindex="-1"><slot /></main> <main id="main-content" tabindex="-1"><slot /></main>
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>НХНЧ · 2026</span></footer> <footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/status">Статус</a> · <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>НХНЧ · 2026</span></footer>
</body> </body>
</html> </html>
+1
View File
@@ -12,6 +12,7 @@ export type DictionaryItem = { id: string; slug: string; name_ru: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string }; export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" }; export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null }; export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
+1 -1
View File
@@ -5,7 +5,7 @@ const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru"; const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
const paths = new Set(["/", "/records", "/report", "/rules", "/privacy"]); const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]);
try { try {
const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]); const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]);
paths.add("/fish"); paths.add("/waterbodies"); paths.add("/fish"); paths.add("/waterbodies");
+12
View File
@@ -0,0 +1,12 @@
---
import Layout from "../layouts/Layout.astro";
import SourceBadge from "../components/SourceBadge.astro";
import { ago, api, plural, type SourceStatus } from "../lib/api";
let sources: SourceStatus[] = [], unavailable = false;
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); } catch { unavailable = true; }
const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" };
---
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter.">
<section class="catalog-hero content-grid"><span class="overline">Прозрачность данных</span><h1>Статус источников</h1><p>Пауза или изменение страницы не удаляют уже опубликованные данные.</p></section>
{unavailable ? <div class="state content-grid"><h2>Статус временно недоступен</h2></div> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{labels[source.status]}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
</Layout>
+1
View File
@@ -0,0 +1 @@
.source-health-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px;padding:34px 0 100px}.source-health-grid article{padding:21px;border:1px solid #d3ddd5;border-radius:14px;background:#fff}.source-health-grid article>div{display:flex;justify-content:space-between;gap:8px}.source-health-grid h2{font:400 23px Georgia,serif;margin:18px 0 6px}.source-health-grid strong{font-size:13px}.source-health-grid p{color:#526662;font-size:12px}.health-state{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:750;color:#526662}.health-state i{width:8px;height:8px;border-radius:50%;background:#c59a36}.source-health-grid [data-status="healthy"] .health-state i{background:#79aa36}.source-health-grid [data-status="source_changed"] .health-state i{background:#bc5748}.source-health-grid [data-status="disabled"]{opacity:.65}@media(max-width:900px){.source-health-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:620px){.source-health-grid{grid-template-columns:1fr}}
+3 -3
View File
@@ -136,8 +136,8 @@
### Производительность, данные и эксплуатация ### Производительность, данные и эксплуатация
- [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026). - [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026).
- [ ] Добавить экспоненциальный backoff community scheduler после повторных ошибок, не уменьшая базовый интервал 30 минут. - [x] Добавить экспоненциальный backoff community scheduler после повторных ошибок от 30 минут до 24 часов со сбросом после успеха (7 сентября 2026).
- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM. - [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026).
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации. - [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
- [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon. - [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon.
- [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов. - [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов.
@@ -146,7 +146,7 @@
- [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений. - [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений.
- [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников. - [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников.
- [ ] Показывать commit SHA/версию в readiness и административной диагностике. - [ ] Показывать commit SHA/версию в readiness и административной диагностике.
- [ ] Добавить публичную `/status` без внутренних адресов, секретов и лишних деталей. - [x] Добавить публичную `/status` без внутренних адресов, секретов и текстов ошибок (7 сентября 2026).
- [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики. - [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики.
- [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере. - [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере.