SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
11.4 KB · 208 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { fmtDuration, fmtInterval, fmtUntil } from '@/components/meta/connectors-table';4import { ATTRIBUTION, FIELD_PRIORITY } from '@/components/meta/legal';5import { Callout } from '@/components/meta/prose';6import { FreshnessBadge } from '@/components/ui/badges';7import { Container, PageHeader, Section } from '@/components/ui/section';8import { Unavailable } from '@/components/ui/unavailable';9import { api, safe } from '@/lib/api';10import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';11import { SITE_URL, routes } from '@/lib/site';12import type { SourceRow } from '@/lib/types';1314export const metadata: Metadata = {15  title: 'Data sources — provenance, licenses and freshness',16  description: 'Every upstream source behind SatelliteIndex: CelesTrak GP and SATCAT today, planned government and scientific catalogs, with license, attribution, sync schedule and freshness.',17  alternates: { canonical: `${SITE_URL}/sources` },18  openGraph: { title: 'Data sources | SatelliteIndex', description: 'Provenance, licenses, attribution and live freshness of every source feeding SatelliteIndex.', url: `${SITE_URL}/sources` },19  twitter: { card: 'summary', title: 'Data sources | SatelliteIndex', description: 'Provenance, licenses, attribution and live freshness of every source feeding SatelliteIndex.' },20};2122const AUTHORITY_LABEL: Record<string, string> = { government: 'Government', intergovernmental: 'Intergovernmental', scientific: 'Scientific', secondary: 'Derived / secondary', commercial: 'Commercial' };23const AUTHORITY_TONE: Record<string, string> = { government: 'var(--accent-2)', intergovernmental: 'var(--accent-2)', scientific: 'var(--accent)', secondary: 'var(--ink-3)', commercial: 'var(--warn)' };2425function AuthorityBadge({ type, official }: { type: string | null; official: boolean }) {26  const t = type ?? 'unknown';27  const color = AUTHORITY_TONE[t] ?? 'var(--inactive)';28  return (29    <span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ color, borderColor: `color-mix(in oklab, ${color} 35%, transparent)`, background: `color-mix(in oklab, ${color} 10%, transparent)` }}>30      {AUTHORITY_LABEL[t] ?? t}31      {official && <span className="text-ink-3">· official</span>}32    </span>33  );34}3536function SourceCard({ s, now }: { s: SourceRow; now: number }) {37  const planned = !s.enabled;38  return (39    <article id={s.id} className="scroll-mt-[calc(var(--header-h)+1rem)] border-t border-rule py-6 md:py-8">40      <div className="grid gap-6 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">41        <div className="min-w-0">42          <div className="flex flex-wrap items-center gap-2">43            <h2 className="text-lg font-semibold tracking-tight md:text-xl">{s.name}</h2>44            <AuthorityBadge type={s.authority_type} official={s.official} />45            <span className="rounded border border-rule px-1.5 py-0.5 text-[11px] uppercase tracking-wider text-ink-3">{s.type}</span>46            {planned ? <span className="rounded border border-warn/40 bg-warn-soft px-1.5 py-0.5 text-[11px] text-warn">Planned connector</span> : <FreshnessBadge status={s.freshness} />}47          </div>48          <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">49            <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Website</dt>50            <dd className="min-w-0 break-all">51              {s.base_url ? (52                <a href={s.base_url} rel="noopener noreferrer" target="_blank" className="link">53                  {s.base_url.replace(/^https?:\/\//, '')}54                </a>55              ) : (56                '—'57              )}58            </dd>59            <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">License</dt>60            <dd className="text-ink-2">{s.license ?? 'Unavailable'}</dd>61            <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Attribution</dt>62            <dd className="text-ink-2">63              {s.attribution_text ? <q className="italic">{s.attribution_text}</q> : '—'}64              {s.attribution_required && <span className="ml-2 text-xs text-ink-3">(required)</span>}65            </dd>66            <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Cadence</dt>67            <dd className="text-ink-2">{s.update_frequency_seconds ? `Upstream refresh about every ${fmtInterval(s.update_frequency_seconds)}` : 'On demand (derived after each ingestion)'}</dd>68            <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Priority</dt>69            <dd className="tnum text-ink-2">{s.priority}</dd>70          </dl>71          {planned && <p className="mt-3 text-sm text-ink-3">Registered in the source catalog with its license and attribution, but no connector is running yet. Nothing on the site is sourced from it today.</p>}72        </div>7374        <div className="min-w-0">75          <p className="eyebrow">Connectors</p>76          {!s.connectors || s.connectors.length === 0 ? (77            <p className="mt-2 text-sm text-ink-3">No connector registered.</p>78          ) : (79            <ul className="mt-2 divide-y divide-rule border-y border-rule">80              {s.connectors.map((c) => (81                <li key={c.name} className="py-2.5">82                  <div className="flex flex-wrap items-center justify-between gap-2">83                    <span className="mono text-sm text-ink">{c.name}</span>84                    <span className="text-xs text-ink-3">every {fmtInterval(c.interval_seconds)}</span>85                  </div>86                  {c.description && <p className="mt-0.5 text-xs text-ink-3">{c.description}</p>}87                  <dl className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs sm:grid-cols-3">88                    <dt className="text-ink-3">Last sync</dt>89                    <dd className="sm:col-span-2" title={fmtDateTime(c.last_success_at)}>90                      {fmtAgo(c.last_success_at, now)}91                    </dd>92                    <dt className="text-ink-3">Next run</dt>93                    <dd className="sm:col-span-2">{c.enabled ? fmtUntil(c.next_run_at, now) : 'disabled'}</dd>94                    <dt className="text-ink-3">Duration</dt>95                    <dd className="sm:col-span-2">{fmtDuration(c.last_duration_ms)}</dd>96                    {(c.consecutive_failures > 0 || c.circuit_open_until) && (97                      <>98                        <dt className="text-ink-3">Failures</dt>99                        <dd className="text-warn sm:col-span-2">100                          {fmtInt(c.consecutive_failures)} consecutive{c.circuit_open_until ? ` · circuit open until ${fmtDateTime(c.circuit_open_until)}` : ''}101                        </dd>102                      </>103                    )}104                  </dl>105                </li>106              ))}107            </ul>108          )}109          <div className="mt-4 grid grid-cols-3 gap-3">110            <div>111              <p className="eyebrow">Raw snapshots</p>112              <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(s.raw_snapshots)}</p>113            </div>114            <div>115              <p className="eyebrow">Provenance rows</p>116              <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(s.provenance_rows)}</p>117            </div>118            <div>119              <p className="eyebrow">Last snapshot</p>120              <p className="mt-0.5 text-sm" title={fmtDateTime(s.last_snapshot_at)}>121                {fmtAgo(s.last_snapshot_at, now)}122              </p>123            </div>124          </div>125        </div>126      </div>127    </article>128  );129}130131export default async function SourcesPage() {132  const res = await safe(api.sources());133  const now = Date.now();134  const sources = res?.data ?? null;135  const active = sources?.filter((s) => s.enabled) ?? [];136  const planned = sources?.filter((s) => !s.enabled) ?? [];137  return (138    <Container>139      <PageHeader eyebrow="Transparency" title="Data sources" lede="Where every number on SatelliteIndex comes from: the upstream catalogs, their licenses and attribution requirements, how often we sync them and how fresh each feed is right now. Nothing is presented without provenance.">140        <p className="mt-4 text-sm text-ink-3">141          Live connector health: <Link href={routes.statusData()} className="link">/status/data</Link> · How data is processed: <Link href={routes.methodology()} className="link">/methodology</Link>142          {res && <span className="ml-2">· generated {fmtDateTime(res.meta.generated_at)}</span>}143        </p>144      </PageHeader>145146      {sources === null ? (147        <Unavailable what="Source catalog" />148      ) : (149        <>150          <Section eyebrow="Active" title={`${active.length} source${active.length === 1 ? '' : 's'} feeding the index`} className="pt-0">151            <div>152              {active.map((s) => (153                <SourceCard key={s.id} s={s} now={now} />154              ))}155            </div>156          </Section>157158          <Section eyebrow="Source priority" title="Which source wins for each field">159            <p className="max-w-3xl text-sm leading-relaxed text-ink-2">160              Sources are ordered by priority; a higher-priority source overrides a lower one for the fields it covers, and every accepted value is recorded in <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-[13px]">field_provenance</code> with the source and observation time. Objects that disappear from a feed are never deleted.161            </p>162            <div className="mt-4 overflow-x-auto">163              <table className="data-table stack md:min-w-[760px]">164                <thead>165                  <tr>166                    <th>Field</th>167                    <th>Source</th>168                    <th>Note</th>169                  </tr>170                </thead>171                <tbody>172                  {FIELD_PRIORITY.map((f) => (173                    <tr key={f.field}>174                      <td className="primary text-sm text-ink">{f.field}</td>175                      <td data-label="Source" className="text-sm">176                        <a href={`#${f.sourceId}`} className="link">177                          {f.source}178                        </a>179                      </td>180                      <td data-label="Note" className="text-sm text-ink-2">181                        {f.note}182                      </td>183                    </tr>184                  ))}185                </tbody>186              </table>187            </div>188          </Section>189190          {planned.length > 0 && (191            <Section eyebrow="Planned" title={`${planned.length} planned connectors`}>192              <Callout className="mt-0 mb-2">These catalogs are registered with their license and attribution so the schema is ready for them, but they are not ingested yet. They are listed here for honesty about coverage — no field on the site is currently attributed to them.</Callout>193              {planned.map((s) => (194                <SourceCard key={s.id} s={s} now={now} />195              ))}196            </Section>197          )}198199          <Section eyebrow="Attribution" title="How we credit sources">200            <p className="max-w-3xl text-sm leading-relaxed text-ink-2">{ATTRIBUTION}</p>201            <p className="mt-3 max-w-3xl text-sm leading-relaxed text-ink-3">If you reuse data from this site or its API, carry the attribution texts above forward — see the <Link href={routes.developers()} className="link">API page</Link>.</p>202          </Section>203        </>204      )}205    </Container>206  );207}208