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 -1
View File
@@ -1,6 +1,7 @@
---
import "../styles/global.css";
import "../styles/catalog.css";
import "../styles/source-status.css";
import FishingIcon from "../components/FishingIcon.astro";
const {
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
@@ -56,6 +57,6 @@ const jsonLd = JSON.stringify({
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header>
<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>
</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 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 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}`;
+1 -1
View File
@@ -5,7 +5,7 @@ const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({
export const GET: APIRoute = async ({ site }) => {
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 {
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");
+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}}