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:
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user