From b7670b615283eddd0eda7afed7021140b4d55529 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Apr 2026 08:34:34 +0200 Subject: [PATCH] feat(discovery): admin stats strip + sidebar nav link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces CronJob health signals without needing kubectl: last tick time (stale-amber if > 6h), buckets due now, errors in the last 24h (with an expandable list of the most recent failing buckets), and queue size. Also wires the previously-orphaned /admin/discovery route into the admin sidebar next to Märkte. - backend: new GET /admin/discovery/stats endpoint; Stats + BucketError types; repository Stats() aggregates four counters + top 5 failing buckets. - web: +page.server.ts fetches stats in parallel with queue; +page.svelte renders a 4-card strip above the queue table. --- backend/internal/domain/discovery/handler.go | 11 +++ .../domain/discovery/mock_repo_test.go | 3 + backend/internal/domain/discovery/model.go | 18 +++++ .../internal/domain/discovery/repository.go | 44 ++++++++++ backend/internal/domain/discovery/routes.go | 1 + backend/internal/domain/discovery/service.go | 6 ++ web/src/routes/admin/+layout.svelte | 5 +- .../routes/admin/discovery/+page.server.ts | 29 +++++-- web/src/routes/admin/discovery/+page.svelte | 80 ++++++++++++++++++- 9 files changed, 189 insertions(+), 8 deletions(-) diff --git a/backend/internal/domain/discovery/handler.go b/backend/internal/domain/discovery/handler.go index 90e978c..ecde49a 100644 --- a/backend/internal/domain/discovery/handler.go +++ b/backend/internal/domain/discovery/handler.go @@ -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")) diff --git a/backend/internal/domain/discovery/mock_repo_test.go b/backend/internal/domain/discovery/mock_repo_test.go index e90a7e2..6e1df7b 100644 --- a/backend/internal/domain/discovery/mock_repo_test.go +++ b/backend/internal/domain/discovery/mock_repo_test.go @@ -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 +} diff --git a/backend/internal/domain/discovery/model.go b/backend/internal/domain/discovery/model.go index df4809a..00847be 100644 --- a/backend/internal/domain/discovery/model.go +++ b/backend/internal/domain/discovery/model.go @@ -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"` +} diff --git a/backend/internal/domain/discovery/repository.go b/backend/internal/domain/discovery/repository.go index 4425ab9..c4c4617 100644 --- a/backend/internal/domain/discovery/repository.go +++ b/backend/internal/domain/discovery/repository.go @@ -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() +} diff --git a/backend/internal/domain/discovery/routes.go b/backend/internal/domain/discovery/routes.go index 6aba4ce..d3fbe91 100644 --- a/backend/internal/domain/discovery/routes.go +++ b/backend/internal/domain/discovery/routes.go @@ -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) diff --git a/backend/internal/domain/discovery/service.go b/backend/internal/domain/discovery/service.go index c42f09c..51c22cc 100644 --- a/backend/internal/domain/discovery/service.go +++ b/backend/internal/domain/discovery/service.go @@ -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 { diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 217d122..b7c6998 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -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); diff --git a/web/src/routes/admin/discovery/+page.server.ts b/web/src/routes/admin/discovery/+page.server.ts index cf0427e..3e8db44 100644 --- a/web/src/routes/admin/discovery/+page.server.ts +++ b/web/src/routes/admin/discovery/+page.server.ts @@ -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( - `/admin/discovery/queue?limit=${limit}&offset=${offset}`, - cookies - ); - return { queue: res.data, limit, offset }; + const [queueRes, statsRes] = await Promise.all([ + serverFetch( + `/admin/discovery/queue?limit=${limit}&offset=${offset}`, + cookies + ), + serverFetch(`/admin/discovery/stats`, cookies) + ]); + return { queue: queueRes.data, stats: statsRes.data, limit, offset }; }; export const actions: Actions = { diff --git a/web/src/routes/admin/discovery/+page.svelte b/web/src/routes/admin/discovery/+page.svelte index 85622de..f028760 100644 --- a/web/src/routes/admin/discovery/+page.svelte +++ b/web/src/routes/admin/discovery/+page.svelte @@ -1,14 +1,90 @@ - Admin · Discovery Queue + Admin · Discovery
-

Discovery Queue

+

Discovery

+ +
+
+
Last tick
+
+ {lastTickLabel} + {#if tickStale && data.stats.last_tick_at}{/if} +
+
+
+
Due now
+
{data.stats.due_now}
+
+
+
Errors (24h)
+
{data.stats.errors_last_24h}
+
+
+
Queue pending
+
{data.stats.queue_pending}
+
+
+ + {#if data.stats.recent_errors && data.stats.recent_errors.length > 0} +
+ + {data.stats.recent_errors.length} bucket{data.stats.recent_errors.length === 1 ? '' : 's'} + with errors + +
    + {#each data.stats.recent_errors as e} +
  • + {e.last_queried_at?.slice(0, 16)} + {e.land} / {e.region} / {e.year_month} + {e.last_error} +
  • + {/each} +
+
+ {/if} + +

Queue

{data.queue.length} pending · showing from offset {data.offset}