SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
7.2 KB · 160 lines tsx
Raw Blame History
1'use client';23import Link from 'next/link';4import { useRouter, useSearchParams } from 'next/navigation';5import { useEffect, useMemo, useRef, useState } from 'react';6import { PNum } from '@/components/ui/primitives';7import { fmtInt, fmtMs, fmtPct } from '@/lib/format';8import type { Target } from '@/lib/types';910type SortKey = 'pressure' | 'name' | 'ok_ratio_1h' | 'ttfb_ms_median_1h' | 'importance';1112export 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 }) {13  const [q, setQ] = useState(initialQuery);14  const [cat, setCat] = useState(initialCategory);15  const router = useRouter();16  const sp = useSearchParams();17  const first = useRef(true);18  // filters are applied server-side (the registry is paginated): push them to the URL, debounced19  useEffect(() => {20    if (first.current) {21      first.current = false;22      return;23    }24    const t = setTimeout(() => {25      const next = new URLSearchParams(sp.toString());26      if (q) next.set('q', q);27      else next.delete('q');28      if (cat) next.set('category', cat);29      else next.delete('category');30      next.delete('page');31      router.replace(`/targets?${next.toString()}`, { scroll: false });32    }, 350);33    return () => clearTimeout(t);34    // eslint-disable-next-line react-hooks/exhaustive-deps35  }, [q, cat]);36  const pageHref = (n: number) => {37    const next = new URLSearchParams(sp.toString());38    if (n > 1) next.set('page', String(n));39    else next.delete('page');40    return `/targets?${next.toString()}`;41  };42  const [sort, setSort] = useState<SortKey>('pressure');43  const [dir, setDir] = useState<1 | -1>(-1);4445  const rows = useMemo(() => {46    const s = q.trim().toLowerCase();47    return targets48      .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)))49      .sort((a, b) => {50        const av = a[sort];51        const bv = b[sort];52        if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir;53        return ((av as number) - (bv as number)) * dir;54      });55  }, [targets, q, cat, sort, dir]);5657  const th = (key: SortKey, label: string, right = false) => (58    <th className={right ? 'r' : ''} aria-sort={sort === key ? (dir === 1 ? 'ascending' : 'descending') : 'none'}>59      <button60        type="button"61        onClick={() => {62          if (sort === key) setDir((d) => (d === 1 ? -1 : 1));63          else {64            setSort(key);65            setDir(key === 'name' ? 1 : -1);66          }67        }}68        className={`uppercase tracking-[0.1em] hover:text-ink ${sort === key ? 'text-ink' : ''}`}69      >70        {label}71        {sort === key ? (dir === 1 ? ' ↑' : ' ↓') : ''}72      </button>73    </th>74  );7576  return (77    <div>78      <div className="flex flex-wrap items-center gap-2">79        <input value={q} onChange={(e) => 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" />80        <div className="scroll-x flex gap-1" role="radiogroup" aria-label="Category">81          <button type="button" role="radio" aria-checked={!cat} onClick={() => setCat('')} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${!cat ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}>82            all83          </button>84          {categories.map((c) => (85            <button key={c} type="button" role="radio" aria-checked={cat === c} onClick={() => setCat(c)} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${cat === c ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}>86              {c}87            </button>88          ))}89        </div>90        <span className="num ml-auto text-[11px] text-ink-3">{fmtInt(rows.length)} shown · {fmtInt(total)} match · page {page}/{pages}</span>91      </div>92      <div className="scroll-x -mx-3 mt-3 px-3">93        <table className="tbl">94          <thead>95            <tr>96              {th('name', 'Target')}97              <th className="hidden md:table-cell">Hostname</th>98              <th className="hidden sm:table-cell">Category</th>99              <th className="hidden lg:table-cell">Service</th>100              <th className="hidden sm:table-cell">Anchor</th>101              {th('importance', 'Imp.', true)}102              <th className="r hidden md:table-cell">Tier</th>103              {th('pressure', 'Pressure', true)}104              {th('ok_ratio_1h', 'OK 1h', true)}105              {th('ttfb_ms_median_1h', 'TTFB', true)}106            </tr>107          </thead>108          <tbody>109            {rows.map((t) => (110              <tr key={t.target_id}>111                <td>112                  <span className="text-ink">{t.name}</span>113                </td>114                <td className="num hidden text-ink-2 md:table-cell">{t.hostname}</td>115                <td className="hidden text-ink-2 sm:table-cell">{t.category}</td>116                <td className="hidden lg:table-cell">117                  {t.service_id ? (118                    <Link href={`/service/${t.service_id}`} className="text-ink-2 hover:text-accent">119                      {t.service_id}120                    </Link>121                  ) : (122                    <span className="text-ink-3">—</span>123                  )}124                </td>125                <td className="hidden sm:table-cell">126                  {t.country ? (127                    <Link href={`/country/${t.country.toLowerCase()}`} className="text-ink-2 hover:text-accent">128                      {t.country}129                    </Link>130                  ) : (131                    <span className="text-ink-3">global</span>132                  )}133                </td>134                <td className="num r text-ink-2">{t.importance}</td>135                <td className="num r hidden text-ink-2 md:table-cell">{t.tier}</td>136                <td className="r">137                  <PNum value={t.pressure} />138                </td>139                <td className="num r" style={{ color: t.ok_ratio_1h != null && t.ok_ratio_1h < 0.98 ? 'var(--p-high)' : undefined }}>140                  {fmtPct(t.ok_ratio_1h, 1)}141                </td>142                <td className="num r text-ink-2">{fmtMs(t.ttfb_ms_median_1h)}</td>143              </tr>144            ))}145          </tbody>146        </table>147      </div>148      {pages > 1 && (149        <nav className="mt-3 flex items-center gap-2 text-[12px]" aria-label="Pagination">150          {page > 1 ? <Link href={pageHref(page - 1)} className="text-ink-2 hover:text-accent">← previous</Link> : <span className="text-ink-3">← previous</span>}151          <span className="num text-ink-3">152            {fmtInt((page - 1) * pageSize + 1)}–{fmtInt(Math.min(page * pageSize, total))} of {fmtInt(total)}153          </span>154          {page < pages ? <Link href={pageHref(page + 1)} className="text-ink-2 hover:text-accent">next →</Link> : <span className="text-ink-3">next →</span>}155        </nav>156      )}157    </div>158  );159}160