Merge branch 'feature/discovery-admin-stats' into 'main'

feat(discovery): admin stats strip + sidebar nav link

See merge request vikingowl/marktvogt.de!6
This commit is contained in:
2026-04-18 06:35:10 +00:00
9 changed files with 189 additions and 8 deletions

View File

@@ -30,6 +30,17 @@ func (h *Handler) Tick(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": summary})
}
func (h *Handler) Stats(c *gin.Context) {
s, err := h.service.Stats(c.Request.Context())
if err != nil {
slog.ErrorContext(c.Request.Context(), "discovery stats failed", "error", err)
apiErr := apierror.Internal("stats failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": s})
}
func (h *Handler) ListQueue(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))

View File

@@ -80,3 +80,6 @@ func (m *mockRepo) InsertRejection(ctx context.Context, tx pgx.Tx, r RejectedDis
}
return nil
}
func (m *mockRepo) Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error) {
return Stats{}, nil
}

View File

@@ -84,3 +84,21 @@ const (
StatusAccepted = "accepted"
StatusRejected = "rejected"
)
// Stats is the discovery health snapshot used by the admin dashboard strip.
type Stats struct {
LastTickAt *time.Time `json:"last_tick_at"`
DueNow int `json:"due_now"`
ErrorsLast24h int `json:"errors_last_24h"`
QueuePending int `json:"queue_pending"`
RecentErrors []BucketError `json:"recent_errors"`
}
// BucketError is a single bucket that failed its most recent tick.
type BucketError struct {
Land string `json:"land"`
Region string `json:"region"`
YearMonth string `json:"year_month"`
LastError string `json:"last_error"`
LastQueriedAt time.Time `json:"last_queried_at"`
}

View File

@@ -25,6 +25,7 @@ type Repository interface {
MarkRejected(ctx context.Context, tx pgx.Tx, id uuid.UUID, reviewer uuid.UUID) error
InsertRejection(ctx context.Context, tx pgx.Tx, r RejectedDiscovery) error
BeginTx(ctx context.Context) (pgx.Tx, error)
Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error)
}
// SeriesCandidate is a minimal projection used for name-normalization comparison in Go.
@@ -214,3 +215,46 @@ ON CONFLICT (name_normalized, stadt, year) DO NOTHING`,
func (r *pgRepository) BeginTx(ctx context.Context) (pgx.Tx, error) {
return r.pool.BeginTx(ctx, pgx.TxOptions{})
}
func (r *pgRepository) Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error) {
var s Stats
if err := r.pool.QueryRow(ctx, `SELECT max(last_queried_at) FROM discovery_buckets`).Scan(&s.LastTickAt); err != nil {
return Stats{}, fmt.Errorf("stats last_tick_at: %w", err)
}
if err := r.pool.QueryRow(ctx, `
SELECT count(*) FROM discovery_buckets
WHERE year_month >= to_char(date_trunc('month', now()), 'YYYY-MM')
AND year_month <= to_char(date_trunc('month', now()) + ($1 * interval '1 month'), 'YYYY-MM')
AND (last_queried_at IS NULL OR last_queried_at < now() - interval '7 days')`,
forwardMonths).Scan(&s.DueNow); err != nil {
return Stats{}, fmt.Errorf("stats due_now: %w", err)
}
if err := r.pool.QueryRow(ctx, `
SELECT count(*) FROM discovery_buckets
WHERE last_error IS NOT NULL
AND last_queried_at > now() - interval '24 hours'`).Scan(&s.ErrorsLast24h); err != nil {
return Stats{}, fmt.Errorf("stats errors_last_24h: %w", err)
}
if err := r.pool.QueryRow(ctx, `
SELECT count(*) FROM discovered_markets WHERE status = 'pending'`).Scan(&s.QueuePending); err != nil {
return Stats{}, fmt.Errorf("stats queue_pending: %w", err)
}
rows, err := r.pool.Query(ctx, `
SELECT land, region, year_month, last_error, last_queried_at
FROM discovery_buckets
WHERE last_error IS NOT NULL
ORDER BY last_queried_at DESC NULLS LAST
LIMIT $1`, recentErrorsLimit)
if err != nil {
return Stats{}, fmt.Errorf("stats recent_errors: %w", err)
}
defer rows.Close()
for rows.Next() {
var e BucketError
if err := rows.Scan(&e.Land, &e.Region, &e.YearMonth, &e.LastError, &e.LastQueriedAt); err != nil {
return Stats{}, err
}
s.RecentErrors = append(s.RecentErrors, e)
}
return s, rows.Err()
}

View File

@@ -15,6 +15,7 @@ func RegisterRoutes(
// Admin-session queue routes.
admin := rg.Group("/admin/discovery", requireAuth, requireAdmin)
{
admin.GET("/stats", h.Stats)
admin.GET("/queue", h.ListQueue)
admin.POST("/queue/:id/accept", h.Accept)
admin.POST("/queue/:id/reject", h.Reject)

View File

@@ -299,6 +299,12 @@ func (s *Service) ListPendingQueue(ctx context.Context, limit, offset int) ([]Di
return s.repo.ListQueue(ctx, StatusPending, limit, offset)
}
// Stats returns a health snapshot for the admin dashboard.
// Shows up to 5 most-recent error buckets inline.
func (s *Service) Stats(ctx context.Context) (Stats, error) {
return s.repo.Stats(ctx, s.forwardMonths, 5)
}
// findSeriesMatch returns the ID of the first candidate whose normalized name matches
// incomingName after normalization. Candidates are expected to be pre-filtered by city.
func findSeriesMatch(incomingName string, candidates []SeriesCandidate) *uuid.UUID {

View File

@@ -8,7 +8,10 @@
let { children }: Props = $props();
const navItems = [{ href: '/admin/maerkte', label: 'Märkte' }];
const navItems = [
{ href: '/admin/maerkte', label: 'Märkte' },
{ href: '/admin/discovery', label: 'Discovery' }
];
function isActive(href: string): boolean {
return $page.url.pathname.startsWith(href);

View File

@@ -18,14 +18,33 @@ type DiscoveredMarket = {
discovered_at: string;
};
type BucketError = {
land: string;
region: string;
year_month: string;
last_error: string;
last_queried_at: string;
};
type Stats = {
last_tick_at: string | null;
due_now: number;
errors_last_24h: number;
queue_pending: number;
recent_errors: BucketError[] | null;
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const limit = Number(url.searchParams.get('limit') ?? 50);
const offset = Number(url.searchParams.get('offset') ?? 0);
const res = await serverFetch<DiscoveredMarket[]>(
`/admin/discovery/queue?limit=${limit}&offset=${offset}`,
cookies
);
return { queue: res.data, limit, offset };
const [queueRes, statsRes] = await Promise.all([
serverFetch<DiscoveredMarket[]>(
`/admin/discovery/queue?limit=${limit}&offset=${offset}`,
cookies
),
serverFetch<Stats>(`/admin/discovery/stats`, cookies)
]);
return { queue: queueRes.data, stats: statsRes.data, limit, offset };
};
export const actions: Actions = {

View File

@@ -1,14 +1,90 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
const lastTickLabel = $derived.by(() => {
if (!data.stats.last_tick_at) return 'nie';
const ts = new Date(data.stats.last_tick_at).getTime();
const diffMs = Date.now() - ts;
const mins = Math.round(diffMs / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins} min`;
const hours = Math.round(mins / 60);
if (hours < 48) return `vor ${hours} h`;
return `vor ${Math.round(hours / 24)} Tagen`;
});
const tickStale = $derived(
!data.stats.last_tick_at ||
Date.now() - new Date(data.stats.last_tick_at).getTime() > 6 * 60 * 60 * 1000
);
</script>
<svelte:head>
<title>Admin · Discovery Queue</title>
<title>Admin · Discovery</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-8">
<h1 class="text-2xl font-bold">Discovery Queue</h1>
<h1 class="text-2xl font-bold">Discovery</h1>
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div
class="rounded-lg border px-3 py-2 {tickStale
? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950'
: 'border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-900'}"
>
<div class="text-xs text-stone-500 dark:text-stone-400">Last tick</div>
<div class="font-mono text-sm">
{lastTickLabel}
{#if tickStale && data.stats.last_tick_at}<span
class="ml-1 text-amber-700 dark:text-amber-300"
title="stale"></span
>{/if}
</div>
</div>
<div
class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 dark:border-stone-700 dark:bg-stone-900"
>
<div class="text-xs text-stone-500 dark:text-stone-400">Due now</div>
<div class="font-mono text-sm">{data.stats.due_now}</div>
</div>
<div
class="rounded-lg border px-3 py-2 {data.stats.errors_last_24h > 0
? 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950'
: 'border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-900'}"
>
<div class="text-xs text-stone-500 dark:text-stone-400">Errors (24h)</div>
<div class="font-mono text-sm">{data.stats.errors_last_24h}</div>
</div>
<div
class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 dark:border-stone-700 dark:bg-stone-900"
>
<div class="text-xs text-stone-500 dark:text-stone-400">Queue pending</div>
<div class="font-mono text-sm">{data.stats.queue_pending}</div>
</div>
</div>
{#if data.stats.recent_errors && data.stats.recent_errors.length > 0}
<details
class="mt-3 rounded border border-red-200 bg-red-50 p-2 text-sm dark:border-red-800 dark:bg-red-950"
>
<summary class="cursor-pointer text-red-700 dark:text-red-300">
{data.stats.recent_errors.length} bucket{data.stats.recent_errors.length === 1 ? '' : 's'}
with errors
</summary>
<ul class="mt-2 space-y-1 text-xs">
{#each data.stats.recent_errors as e}
<li class="font-mono">
<span class="text-stone-500">{e.last_queried_at?.slice(0, 16)}</span>
<span class="ml-2">{e.land} / {e.region} / {e.year_month}</span>
<span class="ml-2 text-red-700 dark:text-red-300">{e.last_error}</span>
</li>
{/each}
</ul>
</details>
{/if}
<h2 class="mt-8 text-lg font-semibold">Queue</h2>
<p class="mt-1 text-sm text-stone-500">
{data.queue.length} pending · showing from offset {data.offset}
</p>