SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
5.3 KB · 109 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { ActionButton, AdminFilters, AdminTitle, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';3import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';4import { LiveAgo } from '@/components/ui/live';5import { Unavailable } from '@/components/ui/unavailable';6import { requeueDeadAction, retryJobAction } from '@/lib/admin/actions';7import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';8import { fmtDateTime, fmtInt } from '@/lib/format';910export const metadata: Metadata = { title: 'Jobs', robots: { index: false, follow: false } };11export const dynamic = 'force-dynamic';1213const STATUSES = ['queued', 'running', 'done', 'failed', 'dead'];1415function payloadSummary(p: Record<string, unknown>): string {16  return Object.entries(p ?? {})17    .filter(([, v]) => v !== null && v !== undefined && v !== '')18    .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)19    .join(' · ');20}2122export default async function AdminJobsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {23  await requireAdmin();24  const sp = await searchParams;25  const ret = `/admin/jobs${sp.status || sp.kind ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ status: sp.status, kind: sp.kind }).filter(([, v]) => v)) as Record<string, string>).toString()}` : ''}`;26  const res = await load(adminApi.jobs({ status: sp.status, kind: sp.kind, limit: 200 }));27  const kinds = res.ok ? Object.keys(res.data.depth ?? {}) : [];28  return (29    <>30      <AdminTitle title="Jobs" count={res.ok ? fmtInt(res.data.total) : undefined}>31        <form action={requeueDeadAction}>32          <input type="hidden" name="return" value={ret} />33          <ActionButton tone="accent" title="Move every dead job back to the queue">Requeue dead</ActionButton>34        </form>35      </AdminTitle>36      <Notice notice={sp.notice} level={sp.level} />37      {res.ok && res.data.depth && Object.keys(res.data.depth).length > 0 && (38        <ul className="mb-4 flex flex-wrap gap-x-4 gap-y-1 text-xs">39          {Object.entries(res.data.depth).map(([kind, byStatus]) => (40            <li key={kind} className="flex flex-wrap items-center gap-1.5">41              <Mono className="text-ink">{kind}</Mono>42              {Object.entries(byStatus).map(([st, n]) => (43                <a key={st} href={`/admin/jobs?kind=${encodeURIComponent(kind)}&status=${encodeURIComponent(st)}`} className="inline-flex items-center gap-1 hover:underline">44                  <StatusChip value={st} /> <span className="tnum text-ink-2">{fmtInt(n)}</span>45                </a>46              ))}47            </li>48          ))}49        </ul>50      )}51      <AdminFilters52        action="/admin/jobs"53        className="mb-4"54        fields={[55          { kind: 'select', name: 'kind', label: 'Kind', value: sp.kind, options: (kinds.length ? kinds : ['llm_extract', 'reprocess_snapshot']).map((k) => ({ value: k, label: k })) },56          { kind: 'select', name: 'status', label: 'Status', value: sp.status, options: STATUSES.map((s) => ({ value: s, label: s })) },57        ]}58      />59      {!res.ok ? (60        <Unavailable what="Jobs" reason={res.error} />61      ) : (62        <DataTable compact scroll caption="Job queue">63          <thead>64            <tr>65              <Th>Job</Th>66              <Th>Kind</Th>67              <Th>Status</Th>68              <Th num>Prio</Th>69              <Th num>Attempts</Th>70              <Th>Run after</Th>71              <Th>Locked by</Th>72              <Th>Started</Th>73              <Th>Finished</Th>74              <Th>Error</Th>75              <Th>Action</Th>76            </tr>77          </thead>78          <tbody>79            {res.data.items.length === 0 && <EmptyRow cols={11}>No jobs match.</EmptyRow>}80            {res.data.items.map((j) => (81              <tr key={j.id}>82                <Td><Mono title={payloadSummary(j.payload)}>{j.id}</Mono></Td>83                <Td><Mono className="text-ink">{j.kind}</Mono></Td>84                <Td><StatusChip value={j.status} /></Td>85                <Td num className="tnum text-xs">{fmtInt(j.priority)}</Td>86                <Td num className="tnum text-xs">{fmtInt(j.attempts)} / {fmtInt(j.max_attempts)}</Td>87                <Td className="text-xs text-ink-2" title={fmtDateTime(j.run_after)}>{j.run_after ? <LiveAgo at={j.run_after} /> : '—'}</Td>88                <Td className="text-xs text-ink-2"><Trunc text={j.locked_by} max={28} /></Td>89                <Td className="text-xs" title={fmtDateTime(j.started_at)}>{j.started_at ? <LiveAgo at={j.started_at} /> : <span className="text-ink-3">—</span>}</Td>90                <Td className="text-xs" title={fmtDateTime(j.finished_at)}>{j.finished_at ? <LiveAgo at={j.finished_at} /> : <span className="text-ink-3">—</span>}</Td>91                <Td className="text-xs text-danger"><Trunc text={j.error} max={60} /></Td>92                <Td>93                  {(j.status === 'failed' || j.status === 'dead') && (94                    <form action={retryJobAction}>95                      <input type="hidden" name="id" value={j.id} />96                      <input type="hidden" name="return" value={ret} />97                      <ActionButton tone="accent">Retry</ActionButton>98                    </form>99                  )}100                </Td>101              </tr>102            ))}103          </tbody>104        </DataTable>105      )}106    </>107  );108}109