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
323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
'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>
|
|
)
|
|
}
|