feat(admin): DB CRUD panel + /api/admin endpoints (#38)
CI / build + unit + e2e (pull_request) Successful in 3m47s
CI / build + unit + e2e (pull_request) Successful in 3m47s
- ADMIN button in the mission-control header opens a table picker + row grid with add/edit/delete (components/admin-panel.tsx), deliberately unauthenticated by explicit request
- /api/admin/tables and /api/admin/tables/{name}/rows driven off a name→table registry built from lib/db/schema.ts (lib/db/admin.ts) — no per-table code; 404 unknown table, 400 malformed payload, 409 constraint violation
- OpenAPI spec updated for all four admin operations + Admin* schemas
- unit: lib/db/admin.test.ts (registry, CRUD round-trips, composite PKs, error mapping); e2e: read-only admin.spec.ts
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
|
||||
// #38 — direct DB CRUD for the admin. ponytail: deliberately unauthenticated (explicit request);
|
||||
// add auth before this is reachable beyond the dev host.
|
||||
interface AdminColumnInfo { name: string; type: string; pk: number }
|
||||
interface AdminTableInfo { name: string; columns: AdminColumnInfo[]; count: number }
|
||||
interface AdminRows { rows: Record<string, unknown>[]; total: number }
|
||||
|
||||
// Form state is all strings (one input per column); parsed on save against the column type.
|
||||
function parseValue(raw: string, col: AdminColumnInfo): number | string | null {
|
||||
const v = raw.trim()
|
||||
if (v === '' || v.toLowerCase() === 'null') return null
|
||||
if (/INT|REAL|FLOA|DOUB/.test(col.type)) {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) throw new Error(`'${col.name}' must be a number, got '${v}'`)
|
||||
return col.type.includes('INT') ? Math.trunc(n) : n
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function cellText(value: unknown): string {
|
||||
return value === null || value === undefined ? '—' : String(value)
|
||||
}
|
||||
|
||||
export function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||
const [tables, setTables] = useState<AdminTableInfo[] | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [rows, setRows] = useState<AdminRows | null>(null)
|
||||
// null = form closed; { key: null } = add row; { key: {...} } = edit that row.
|
||||
const [editing, setEditing] = useState<{ key: Record<string, number> | null } | null>(null)
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const table = tables?.find((t) => t.name === selected) ?? null
|
||||
|
||||
const refreshTables = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/tables')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setTables((await res.json()) as AdminTableInfo[])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshRows = useCallback(async (name: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setRows((await res.json()) as AdminRows)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTables()
|
||||
}, [refreshTables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
setRows(null)
|
||||
void refreshRows(selected)
|
||||
}, [selected, refreshRows])
|
||||
|
||||
const pkColumns = (t: AdminTableInfo): AdminColumnInfo[] => t.columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
|
||||
|
||||
const rowKey = (t: AdminTableInfo, row: Record<string, unknown>): Record<string, number> =>
|
||||
Object.fromEntries(pkColumns(t).map((c) => [c.name, Number(row[c.name])]))
|
||||
|
||||
const openAdd = () => {
|
||||
if (!table) return
|
||||
setEditing({ key: null })
|
||||
setForm(Object.fromEntries(table.columns.map((c) => [c.name, ''])))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const openEdit = (row: Record<string, unknown>) => {
|
||||
if (!table) return
|
||||
setEditing({ key: rowKey(table, row) })
|
||||
setForm(Object.fromEntries(table.columns.map((c) => [c.name, row[c.name] == null ? '' : String(row[c.name])])))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!table || !editing) return
|
||||
try {
|
||||
const payload: Record<string, number | string | null> = {}
|
||||
for (const col of table.columns) payload[col.name] = parseValue(form[col.name] ?? '', col)
|
||||
const url = `/api/admin/tables/${encodeURIComponent(table.name)}/rows`
|
||||
const res = await fetch(url, {
|
||||
method: editing.key === null ? 'POST' : 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editing.key === null ? payload : { key: editing.key, values: payload }),
|
||||
})
|
||||
const body = (await res.json()) as { error?: string } & Record<string, unknown>
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
setEditing(null)
|
||||
setError(null)
|
||||
await Promise.all([refreshRows(table.name), refreshTables()])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (row: Record<string, unknown>) => {
|
||||
if (!table) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table.name)}/rows`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: rowKey(table, row) }),
|
||||
})
|
||||
const body = (await res.json()) as { error?: string }
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
await Promise.all([refreshRows(table.name), refreshTables()])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
background: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.68rem',
|
||||
padding: '0.3rem 0.4rem',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<Panel notch="md" style={{ width: 'min(980px, 100%)', height: 'min(640px, 100%)', display: 'flex', flexDirection: 'column' }}>
|
||||
<PanelHeader>
|
||||
<PanelTitle>ADMIN // DB CRUD</PanelTitle>
|
||||
<Badge variant="WARNING">NO AUTH</Badge>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Button size="SM" onClick={onClose}>CLOSE</Button>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '200px 1fr', gap: '1px', background: 'var(--border)', minHeight: 0 }}>
|
||||
{/* ── TABLE LIST ── */}
|
||||
<div style={{ background: 'var(--surface)', overflowY: 'auto', padding: '0.5rem' }}>
|
||||
{!tables ? (
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
) : (
|
||||
tables.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
onClick={() => setSelected(t.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: '0.5rem',
|
||||
padding: '0.4rem 0.5rem',
|
||||
marginBottom: '2px',
|
||||
background: selected === t.name ? 'var(--surface-raised)' : 'transparent',
|
||||
border: `1px solid ${selected === t.name ? 'var(--color-green)' : 'var(--border)'}`,
|
||||
color: selected === t.name ? 'var(--color-green)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.68rem',
|
||||
letterSpacing: '0.06em',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span>{t.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{t.count}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── ROWS / EDITOR ── */}
|
||||
<div style={{ background: 'var(--surface)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{!table ? (
|
||||
<PanelContent>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SELECT A TABLE</span>
|
||||
</PanelContent>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>{table.name}</span>
|
||||
{rows && (
|
||||
<Badge variant="ACTIVE">
|
||||
{rows.rows.length} / {rows.total}
|
||||
</Badge>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
|
||||
{!editing && <Button size="SM" variant="EXEC" onClick={openAdd}>ADD ROW</Button>}
|
||||
{error && (
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', alignSelf: 'center' }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div style={{ padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.4rem' }}>
|
||||
{table.columns.map((c) => (
|
||||
<label key={c.name} style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.58rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>
|
||||
{c.name.toUpperCase()}
|
||||
{c.pk > 0 ? ` (PK${c.pk})` : ''} · {c.type}
|
||||
</span>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={form[c.name] ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [c.name]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
|
||||
<Button size="SM" variant="EXEC" onClick={() => void save()}>SAVE</Button>
|
||||
<Button size="SM" onClick={() => setEditing(null)}>CANCEL</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{!rows ? (
|
||||
<PanelContent>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
</PanelContent>
|
||||
) : rows.rows.length === 0 ? (
|
||||
<PanelContent>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY TABLE</span>
|
||||
</PanelContent>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{table.columns.map((c) => (
|
||||
<th
|
||||
key={c.name}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--surface-raised)',
|
||||
color: 'var(--text-muted)',
|
||||
textAlign: 'left',
|
||||
padding: '0.35rem 0.5rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
letterSpacing: '0.08em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
{c.pk > 0 ? ` (PK${c.pk})` : ''}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ position: 'sticky', top: 0, background: 'var(--surface-raised)', padding: '0.35rem 0.5rem', borderBottom: '1px solid var(--border)' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.rows.map((row) => (
|
||||
<tr key={JSON.stringify(rowKey(table, row))}>
|
||||
{table.columns.map((c) => (
|
||||
<td
|
||||
key={c.name}
|
||||
style={{
|
||||
padding: '0.3rem 0.5rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
color: row[c.name] === null ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '220px',
|
||||
}}
|
||||
>
|
||||
{cellText(row[c.name])}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ padding: '0.3rem 0.5rem', borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', gap: '0.3rem' }}>
|
||||
<Button size="SM" onClick={() => openEdit(row)}>EDIT</Button>
|
||||
<Button size="SM" variant="ABORT" onClick={() => void remove(row)}>DEL</Button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AdminPanel } from '@/components/admin-panel'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/ui/stat-card'
|
||||
@@ -57,6 +59,8 @@ export function MissionControl() {
|
||||
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
|
||||
// then so SSR and the first client render agree on the default tab (no hydration mismatch).
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||
// #38 — admin DB CRUD overlay, opened from the header.
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -210,6 +214,9 @@ export function MissionControl() {
|
||||
|
||||
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
||||
|
||||
{/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */}
|
||||
<Button size="SM" onClick={() => setAdminOpen(true)}>ADMIN</Button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--color-green)',
|
||||
@@ -225,6 +232,8 @@ export function MissionControl() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
|
||||
|
||||
{/* ── BODY ── */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user