SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
13 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
7.6 KB · 136 lines tsx
Raw Blame History
1import Link from 'next/link';2import { t } from '@/i18n';3import { adminApi, adminErrorMessage } from '@/lib/admin-api';4import { ApiError } from '@/lib/api';5import { grouped } from '@/lib/format';6import { routes } from '@/lib/site';7import type { AdminOverview } from '@/lib/types-explore';8import { AdminSection, AdminTable, Kv, Pill, bytes, statusTone, ts } from '@/components/admin/admin-ui';9import { clearCacheAction, refreshAction } from '../actions';1011export const dynamic = 'force-dynamic';1213function hb(o: AdminOverview, key: string): string | null {14  const h = o.scheduler.heartbeat;15  if (!h) return null;16  const v = h[key];17  return v == null ? null : String(v);18}1920export default async function AdminOverviewPage({ searchParams }: { searchParams: Promise<{ ok?: string; err?: string }> }) {21  const sp = await searchParams;22  let o: AdminOverview | null = null;23  let error: string | null = null;24  try {25    o = await adminApi.overview();26  } catch (e) {27    error = e instanceof ApiError ? t('admin.unavailable', { status: e.status || 'network' }) + ` ${adminErrorMessage(e)}` : adminErrorMessage(e);28  }2930  const actions = (31    <>32      <form action={refreshAction}>33        <button type="submit" className="inline-flex min-h-[36px] items-center rounded-sm bg-ink px-3 text-xs font-medium text-paper hover:bg-accent hover:text-accent-ink" title={t('admin.actions.refreshHint')}>34          {t('admin.actions.refresh')}35        </button>36      </form>37      <form action={clearCacheAction}>38        <button type="submit" className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-3 text-xs text-ink hover:bg-surface-2">39          {t('admin.actions.clearCache')}40        </button>41      </form>42    </>43  );4445  return (46    <>47      {sp.ok ? <p className="mt-3 rounded-sm border border-up/40 px-3 py-2 text-xs text-up">{t('admin.actions.done', { msg: sp.ok })}</p> : null}48      {sp.err ? <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{t('admin.actions.failed', { msg: sp.err })}</p> : null}49      {error ? <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{error}</p> : null}5051      {o ? (52        <>53          <AdminSection title={t('admin.overview.title')} actions={actions}>54            <div className="grid gap-6 md:grid-cols-3">55              <div>56                <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.snapshot')}</h3>57                <Kv58                  rows={[59                    ['status', <Pill key="s" tone={o.status === 'ok' ? 'ok' : 'warn'}>{o.status}</Pill>],60                    ['run_id', o.meta?.run_id ?? null],61                    ['built_at', ts(o.meta?.built_at ?? null)],62                    ['schema', o.meta?.schema_version != null ? String(o.meta.schema_version) : null],63                    ['build_duration_s', o.meta?.build_duration_s != null ? String(o.meta.build_duration_s) : null],64                  ]}65                />66              </div>67              <div>68                <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.db')}</h3>69                <Kv rows={[['path', o.db.path], [t('admin.db.size'), o.db.exists ? bytes(o.db.size_bytes) : t('admin.db.missing')], ['data_dir', o.settings.data_dir], ['api', `${o.settings.api_host}:${o.settings.api_port}`]]} />70              </div>71              <div>72                <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.scheduler')}</h3>73                <Kv74                  rows={[75                    ['state', o.scheduler.pid == null ? t('admin.scheduler.unknown') : o.scheduler.alive ? <Pill key="a" tone="ok">{t('admin.scheduler.alive', { pid: o.scheduler.pid })}</Pill> : <Pill key="d" tone="bad">{t('admin.scheduler.dead', { pid: o.scheduler.pid })}</Pill>],76                    [t('admin.scheduler.last'), ts(hb(o, 'last_run_at') ?? hb(o, 'last_run') ?? hb(o, 'finished_at'))],77                    [t('admin.scheduler.next'), ts(hb(o, 'next_run_at') ?? hb(o, 'next_run'))],78                    ['heartbeat', ts(hb(o, 'heartbeat_at') ?? hb(o, 'updated_at') ?? hb(o, 'at'))],79                    ['refresh', t('admin.scheduler.refreshAt', { time: o.settings.refresh_at })],80                  ]}81                />82              </div>83            </div>84          </AdminSection>8586          <div className="grid gap-x-8 md:grid-cols-3">87            <AdminSection title={t('admin.counts')}>88              <Kv rows={Object.entries(o.counts ?? {}).map(([k, v]) => [k, grouped(v)] as [string, string])} />89            </AdminSection>90            <AdminSection title={t('admin.statuses')}>91              <Kv rows={Object.entries(o.observation_statuses ?? {}).map(([k, v]) => [k, grouped(v)] as [string, string])} />92            </AdminSection>93            <AdminSection title={t('admin.cache')}>94              <Kv rows={Object.entries(o.cache ?? {}).map(([k, v]) => [k, String(v)] as [string, string])} />95            </AdminSection>96          </div>9798          <AdminSection title={t('admin.connectors.title')} sub={t('admin.connectors.sub')}>99            <AdminTable100              rows={o.connectors ?? []}101              rowKey={(r) => r.connector}102              columns={[103                { key: 'c', header: t('admin.connectors.connector'), cell: (r) => <Link href={`${routes.admin('runs')}?connector=${r.connector}`} className="font-medium text-ink hover:text-accent">{r.connector}</Link> },104                { key: 'n', header: t('admin.connectors.runs'), numeric: true, cell: (r) => grouped(r.n_runs) },105                { key: 'last', header: t('admin.connectors.last'), cell: (r) => ts(r.last_finished_at) },106                { key: 's', header: t('admin.connectors.status'), cell: (r) => <Pill tone={statusTone(r.last_status)}>{r.last_status ?? t('admin.na')}</Pill> },107                { key: 'ok', header: t('admin.connectors.ok'), numeric: true, cell: (r) => grouped(r.n_ok) },108                { key: 'f', header: t('admin.connectors.failed'), numeric: true, cell: (r) => (r.n_failed ? <span className="text-down">{grouped(r.n_failed)}</span> : '0') },109                { key: 'rows', header: t('admin.connectors.rows'), numeric: true, cell: (r) => (r.rows_valid != null ? grouped(r.rows_valid) : t('admin.na')) },110                { key: 'w', header: t('admin.connectors.warnings'), numeric: true, cell: (r) => (r.warnings != null ? grouped(r.warnings) : t('admin.na')) },111                { key: 'e', header: t('admin.connectors.errors'), numeric: true, cell: (r) => (r.errors ? <span className="text-down">{grouped(r.errors)}</span> : '0') },112              ]}113            />114          </AdminSection>115116          <AdminSection title={t('admin.stale.title')} sub={t('admin.stale.sub')}>117            <AdminTable118              rows={o.sources ?? []}119              rowKey={(r) => r.source_id}120              columns={[121                { key: 's', header: t('admin.stale.source'), cell: (r) => <Link href={routes.source(r.source_id)} className="font-medium text-ink hover:text-accent">{r.source_id}</Link> },122                { key: 'n', header: t('admin.stale.observations'), numeric: true, cell: (r) => grouped(r.n_observations) },123                { key: 'u', header: t('admin.stale.updated'), cell: (r) => ts(r.source_updated_at) },124                { key: 'r', header: t('admin.stale.retrieved'), cell: (r) => ts(r.retrieved_at) },125                { key: 'st', header: t('admin.stale.n'), numeric: true, cell: (r) => (r.n_stale ? <span className="text-warn">{grouped(r.n_stale)}</span> : '0') },126              ]}127            />128          </AdminSection>129        </>130      ) : !error ? (131        <p className="py-6 text-xs text-ink-3">{t('admin.empty')}</p>132      ) : null}133    </>134  );135}136