'use client'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { PNum } from '@/components/ui/primitives'; import { fmtInt, fmtMs, fmtPct } from '@/lib/format'; import type { Target } from '@/lib/types'; type SortKey = 'pressure' | 'name' | 'ok_ratio_1h' | 'ttfb_ms_median_1h' | 'importance'; export function TargetsRegistry({ targets, categories, initialQuery, initialCategory, total, page, pages, pageSize }: { targets: Target[]; categories: string[]; initialQuery: string; initialCategory: string; total: number; page: number; pages: number; pageSize: number }) { const [q, setQ] = useState(initialQuery); const [cat, setCat] = useState(initialCategory); const router = useRouter(); const sp = useSearchParams(); const first = useRef(true); // filters are applied server-side (the registry is paginated): push them to the URL, debounced useEffect(() => { if (first.current) { first.current = false; return; } const t = setTimeout(() => { const next = new URLSearchParams(sp.toString()); if (q) next.set('q', q); else next.delete('q'); if (cat) next.set('category', cat); else next.delete('category'); next.delete('page'); router.replace(`/targets?${next.toString()}`, { scroll: false }); }, 350); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [q, cat]); const pageHref = (n: number) => { const next = new URLSearchParams(sp.toString()); if (n > 1) next.set('page', String(n)); else next.delete('page'); return `/targets?${next.toString()}`; }; const [sort, setSort] = useState('pressure'); const [dir, setDir] = useState<1 | -1>(-1); const rows = useMemo(() => { const s = q.trim().toLowerCase(); return targets .filter((t) => (!cat || t.category === cat) && (!s || t.hostname.toLowerCase().includes(s) || t.name.toLowerCase().includes(s) || (t.provider ?? '').toLowerCase().includes(s) || t.target_id.includes(s))) .sort((a, b) => { const av = a[sort]; const bv = b[sort]; if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir; return ((av as number) - (bv as number)) * dir; }); }, [targets, q, cat, sort, dir]); const th = (key: SortKey, label: string, right = false) => ( ); return (
setQ(e.target.value)} placeholder="hostname, provider, id…" className="h-8 w-full max-w-[320px] rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink placeholder:text-ink-3" aria-label="Filter targets" />
{categories.map((c) => ( ))}
{fmtInt(rows.length)} shown · {fmtInt(total)} match · page {page}/{pages}
{th('name', 'Target')} {th('importance', 'Imp.', true)} {th('pressure', 'Pressure', true)} {th('ok_ratio_1h', 'OK 1h', true)} {th('ttfb_ms_median_1h', 'TTFB', true)} {rows.map((t) => ( ))}
Hostname Category Service AnchorTier
{t.name} {t.hostname} {t.category} {t.service_id ? ( {t.service_id} ) : ( — )} {t.country ? ( {t.country} ) : ( global )} {t.importance} {t.tier} {fmtPct(t.ok_ratio_1h, 1)} {fmtMs(t.ttfb_ms_median_1h)}
{pages > 1 && ( )}
); }