feat(dbal): add login gate + CLI mode to Query Console

- Login screen with admin token (default pre-configured for local dev)
- CLI mode: type dbal commands directly (list, read, create, update, delete, rest, ping)
- Clickable example commands for quick start
- CLI/GUI mode toggle
- Token persisted in localStorage, forwarded as Bearer auth
- History shows CLI commands with $ prefix
- Disconnect button to logout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:33:57 +00:00
parent b3962d742e
commit e51c7be9ba
3 changed files with 533 additions and 172 deletions

View File

@@ -3,14 +3,22 @@ import { NextRequest, NextResponse } from 'next/server'
const DBAL_DAEMON_URL = process.env.DBAL_DAEMON_URL ?? 'http://localhost:8080'
export async function POST(request: NextRequest) {
const { method, path, body } = await request.json()
const { method, path, body, token } = await request.json()
const url = `${DBAL_DAEMON_URL}${path}`
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const fetchOptions: RequestInit = {
method: method,
headers: { 'Content-Type': 'application/json' },
headers,
signal: AbortSignal.timeout(10000),
}

View File

@@ -344,6 +344,194 @@
padding: 2rem 0;
}
/* Login */
.loginContainer {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 100px);
}
.loginCard {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 1rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
text-align: center;
}
.loginIcon {
width: 56px;
height: 56px;
border-radius: 14px;
background: #3b82f6;
color: #fff;
font-size: 1.25rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.25rem;
}
.loginTitle {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 0.5rem;
}
.loginSub {
font-size: 0.875rem;
color: #888;
margin: 0 0 1.5rem;
line-height: 1.4;
}
.loginBtn {
display: block;
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
&:hover {
background: #2563eb;
}
}
.loginHint {
font-size: 0.75rem;
color: #555;
margin-top: 1rem;
}
/* Mode bar */
.modeBar {
display: flex;
align-items: center;
justify-content: space-between;
}
.modeTabs {
display: flex;
gap: 0;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 0.5rem;
overflow: hidden;
}
.modeTab {
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
color: #888;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: #ccc;
}
}
.modeTabActive {
background: #333;
color: #fff;
}
.logoutBtn {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid #333;
border-radius: 0.375rem;
color: #888;
font-size: 0.75rem;
cursor: pointer;
&:hover {
border-color: #f87171;
color: #f87171;
}
}
/* CLI */
.cliRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cliPrompt {
color: #4ade80;
font-family: 'IBM Plex Mono', monospace;
font-size: 1.125rem;
font-weight: 700;
flex-shrink: 0;
}
.cliInput {
flex: 1;
padding: 0.625rem 0.75rem;
background: #111;
border: 1px solid #333;
border-radius: 0.375rem;
color: var(--foreground);
font-size: 0.9375rem;
font-family: 'IBM Plex Mono', monospace;
outline: none;
&:focus {
border-color: #4ade80;
}
&::placeholder {
color: #555;
}
}
.cliHelp {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.75rem;
align-items: center;
}
.cliHelpLabel {
font-size: 0.6875rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.cliExample {
padding: 0.25rem 0.5rem;
background: #111;
border: 1px solid #262626;
border-radius: 0.25rem;
color: #888;
font-size: 0.6875rem;
font-family: 'IBM Plex Mono', monospace;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #4ade80;
color: #4ade80;
}
}
.clearBtn {
padding: 0.375rem 0.75rem;
background: transparent;

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import styles from './QueryConsole.module.scss'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
@@ -14,6 +14,7 @@ interface QueryHistoryEntry {
timestamp: string
body?: unknown
queryParams?: string
cli?: string
}
interface QueryResponse {
@@ -32,7 +33,88 @@ const METHODS: { value: HttpMethod; className: string }[] = [
]
const HISTORY_KEY = 'dbal-query-history'
const TOKEN_KEY = 'dbal-admin-token'
const MAX_HISTORY = 20
const DEFAULT_TOKEN = '069e6487a710300381cd52120eab95d56d7f53beee21479cbeba9128217cbea9'
const CLI_EXAMPLES = [
'dbal list Snippet',
'dbal read Snippet <id>',
'dbal create Snippet title="Hello" content="world"',
'dbal delete Snippet <id>',
'dbal rest pastebin pastebin Snippet',
'dbal rest pastebin pastebin User',
'dbal ping',
]
function parseCli(input: string): { method: HttpMethod; path: string; body?: Record<string, unknown> } | null {
const parts = input.trim().split(/\s+/)
if (parts.length < 2) return null
const cmd = parts[0].toLowerCase()
if (cmd !== 'dbal') return null
const sub = parts[1].toLowerCase()
if (sub === 'ping') {
return { method: 'GET', path: '/health' }
}
if (sub === 'rest' && parts.length >= 5) {
const [, , tenant, pkg, entity, ...rest] = parts
let path = `/${tenant}/${pkg}/${entity}`
const id = rest.find(r => !r.includes('=') && !['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase()))
const methodOverride = rest.find(r => ['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase()))
if (id) path += `/${id}`
const method = (methodOverride?.toUpperCase() as HttpMethod) || 'GET'
const bodyParts = rest.filter(r => r.includes('=') && !['GET', 'POST', 'PUT', 'DELETE'].includes(r.toUpperCase()))
const body: Record<string, unknown> = {}
for (const bp of bodyParts) {
const [k, ...v] = bp.split('=')
const val = v.join('=').replace(/^"|"$/g, '')
body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val)
}
return { method, path, body: Object.keys(body).length > 0 ? body : undefined }
}
// dbal list/read/create/update/delete <entity> [id] [field=value...]
const entity = parts[2] || 'Snippet'
const tenant = 'pastebin'
const pkg = 'pastebin'
switch (sub) {
case 'list':
return { method: 'GET', path: `/${tenant}/${pkg}/${entity}` }
case 'read':
return { method: 'GET', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}` }
case 'create': {
const body: Record<string, unknown> = {}
for (const p of parts.slice(3)) {
if (p.includes('=')) {
const [k, ...v] = p.split('=')
const val = v.join('=').replace(/^"|"$/g, '')
body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val)
}
}
return { method: 'POST', path: `/${tenant}/${pkg}/${entity}`, body }
}
case 'update': {
const body: Record<string, unknown> = {}
for (const p of parts.slice(4)) {
if (p.includes('=')) {
const [k, ...v] = p.split('=')
const val = v.join('=').replace(/^"|"$/g, '')
body[k] = val === 'true' ? true : val === 'false' ? false : isNaN(Number(val)) ? val : Number(val)
}
}
return { method: 'PUT', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}`, body }
}
case 'delete':
return { method: 'DELETE', path: `/${tenant}/${pkg}/${entity}/${parts[3] || ''}` }
default:
return null
}
}
function syntaxHighlight(json: string): string {
return json.replace(
@@ -64,12 +146,29 @@ function loadHistory(): QueryHistoryEntry[] {
function saveHistory(entries: QueryHistoryEntry[]) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries))
} catch {
/* quota exceeded — silently ignore */
}
} catch { /* quota exceeded */ }
}
function loadToken(): string {
if (typeof window === 'undefined') return ''
return localStorage.getItem(TOKEN_KEY) || ''
}
function saveToken(token: string) {
try {
localStorage.setItem(TOKEN_KEY, token)
} catch { /* ignore */ }
}
export function QueryConsole() {
const [token, setToken] = useState('')
const [authed, setAuthed] = useState(false)
const [tokenInput, setTokenInput] = useState('')
const [mode, setMode] = useState<'gui' | 'cli'>('cli')
const [cliInput, setCliInput] = useState('')
const cliRef = useRef<HTMLInputElement>(null)
const [method, setMethod] = useState<HttpMethod>('GET')
const [tenant, setTenant] = useState('pastebin')
const [pkg, setPkg] = useState('pastebin')
@@ -83,60 +182,56 @@ export function QueryConsole() {
useEffect(() => {
setHistory(loadHistory())
const saved = loadToken()
if (saved) {
setToken(saved)
setAuthed(true)
}
}, [])
const handleLogin = useCallback(() => {
const t = tokenInput.trim() || DEFAULT_TOKEN
setToken(t)
saveToken(t)
setAuthed(true)
}, [tokenInput])
const handleLogout = useCallback(() => {
setToken('')
setAuthed(false)
localStorage.removeItem(TOKEN_KEY)
}, [])
const buildPath = useCallback(() => {
let path = `/${tenant}/${pkg}/${entity}`
if (entityId.trim()) {
path += `/${entityId.trim()}`
}
if (queryParams.trim() && method === 'GET') {
path += `?${queryParams.trim()}`
}
if (entityId.trim()) path += `/${entityId.trim()}`
if (queryParams.trim() && method === 'GET') path += `?${queryParams.trim()}`
return path
}, [tenant, pkg, entity, entityId, queryParams, method])
const execute = useCallback(async () => {
const executeQuery = useCallback(async (m: HttpMethod, path: string, reqBody?: unknown, cliCmd?: string) => {
setLoading(true)
setResponse(null)
const path = buildPath()
let parsedBody: unknown = undefined
if (body.trim() && (method === 'POST' || method === 'PUT')) {
try {
parsedBody = JSON.parse(body)
} catch {
setResponse({
status: 0,
statusText: 'Client Error',
data: { error: 'Invalid JSON in request body' },
url: path,
timestamp: new Date().toISOString(),
})
setLoading(false)
return
}
}
try {
const basePath = process.env.__NEXT_ROUTER_BASEPATH || '/dbal'
const res = await fetch(`${basePath}/api/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method, path, body: parsedBody }),
body: JSON.stringify({ method: m, path, body: reqBody, token }),
})
const data: QueryResponse = await res.json()
setResponse(data)
const entry: QueryHistoryEntry = {
id: crypto.randomUUID(),
method,
method: m,
path,
status: data.status,
statusText: data.statusText,
timestamp: data.timestamp,
body: parsedBody,
queryParams: queryParams.trim() || undefined,
body: reqBody,
cli: cliCmd,
}
const updated = [entry, ...history].slice(0, MAX_HISTORY)
setHistory(updated)
@@ -152,20 +247,53 @@ export function QueryConsole() {
} finally {
setLoading(false)
}
}, [method, body, buildPath, history, queryParams])
}, [token, history])
const executeGui = useCallback(async () => {
const path = buildPath()
let parsedBody: unknown = undefined
if (body.trim() && (method === 'POST' || method === 'PUT')) {
try {
parsedBody = JSON.parse(body)
} catch {
setResponse({
status: 0, statusText: 'Client Error',
data: { error: 'Invalid JSON in request body' },
url: path, timestamp: new Date().toISOString(),
})
return
}
}
executeQuery(method, path, parsedBody)
}, [method, body, buildPath, executeQuery])
const executeCli = useCallback(() => {
const parsed = parseCli(cliInput)
if (!parsed) {
setResponse({
status: 0, statusText: 'Parse Error',
data: { error: `Unknown command. Try: ${CLI_EXAMPLES[0]}` },
url: '', timestamp: new Date().toISOString(),
})
return
}
executeQuery(parsed.method, parsed.path, parsed.body, cliInput)
}, [cliInput, executeQuery])
const restoreFromHistory = useCallback((entry: QueryHistoryEntry) => {
setMethod(entry.method)
const parts = entry.path.replace(/\?.*$/, '').split('/').filter(Boolean)
if (parts.length >= 1) setTenant(parts[0])
if (parts.length >= 2) setPkg(parts[1])
if (parts.length >= 3) setEntity(parts[2])
if (parts.length >= 4) setEntityId(parts[3])
else setEntityId('')
if (entry.queryParams) setQueryParams(entry.queryParams)
else setQueryParams('')
if (entry.body) setBody(JSON.stringify(entry.body, null, 2))
else setBody('')
if (entry.cli) {
setMode('cli')
setCliInput(entry.cli)
} else {
setMode('gui')
setMethod(entry.method)
const parts = entry.path.replace(/\?.*$/, '').split('/').filter(Boolean)
if (parts.length >= 1) setTenant(parts[0])
if (parts.length >= 2) setPkg(parts[1])
if (parts.length >= 3) setEntity(parts[2])
if (parts.length >= 4) setEntityId(parts[3])
else setEntityId('')
}
}, [])
const clearHistory = useCallback(() => {
@@ -182,116 +310,163 @@ export function QueryConsole() {
}
}
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleTimeString()
} catch {
return ts
}
// Login screen
if (!authed) {
return (
<div className={styles.root}>
<div className={styles.loginContainer}>
<div className={styles.loginCard}>
<div className={styles.loginIcon}>DB</div>
<h2 className={styles.loginTitle}>DBAL Query Console</h2>
<p className={styles.loginSub}>Enter your admin token to connect to the DBAL daemon.</p>
<div className={styles.field}>
<label className={styles.label}>Admin Token</label>
<input
className={styles.input}
type="password"
value={tokenInput}
onChange={e => setTokenInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
placeholder="Leave blank for default token"
autoFocus
/>
</div>
<button className={styles.loginBtn} onClick={handleLogin} type="button">
Connect
</button>
<p className={styles.loginHint}>Default token is pre-configured for local development</p>
</div>
</div>
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.layout}>
<div className={styles.main}>
<div className={styles.panel}>
<h2 className={styles.title}>Query Console</h2>
<div className={styles.methodGroup}>
{METHODS.map(m => (
<button
key={m.value}
className={`${styles.methodBtn} ${m.className} ${method === m.value ? styles.methodBtnActive : ''}`}
onClick={() => setMethod(m.value)}
type="button"
>
{m.value}
</button>
))}
</div>
<div className={styles.formGrid}>
<div className={styles.field}>
<label className={styles.label}>Tenant</label>
<input
className={styles.input}
value={tenant}
onChange={e => setTenant(e.target.value)}
placeholder="pastebin"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Package</label>
<input
className={styles.input}
value={pkg}
onChange={e => setPkg(e.target.value)}
placeholder="pastebin"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Entity</label>
<input
className={styles.input}
value={entity}
onChange={e => setEntity(e.target.value)}
placeholder="Snippet"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>ID (optional)</label>
<input
className={styles.input}
value={entityId}
onChange={e => setEntityId(e.target.value)}
placeholder="abc-123"
/>
</div>
{method === 'GET' && (
<div className={styles.fieldFull}>
<label className={styles.label}>Query Parameters</label>
<input
className={styles.input}
value={queryParams}
onChange={e => setQueryParams(e.target.value)}
placeholder="limit=10&offset=0"
/>
</div>
)}
{(method === 'POST' || method === 'PUT') && (
<div className={styles.fieldFull}>
<label className={styles.label}>JSON Body</label>
<textarea
className={styles.bodyEditor}
value={body}
onChange={e => setBody(e.target.value)}
placeholder={'{\n "title": "Hello World",\n "content": "..."\n}'}
/>
</div>
)}
</div>
<div className={styles.executeRow}>
{/* Mode tabs + logout */}
<div className={styles.modeBar}>
<div className={styles.modeTabs}>
<button
className={styles.executeBtn}
onClick={execute}
disabled={loading || !tenant || !pkg || !entity}
className={`${styles.modeTab} ${mode === 'cli' ? styles.modeTabActive : ''}`}
onClick={() => { setMode('cli'); setTimeout(() => cliRef.current?.focus(), 50) }}
type="button"
>
{loading ? 'Executing...' : 'Execute'}
CLI
</button>
<button
className={`${styles.modeTab} ${mode === 'gui' ? styles.modeTabActive : ''}`}
onClick={() => setMode('gui')}
type="button"
>
GUI
</button>
<span className={styles.pathPreview}>
{method} {buildPath()}
</span>
</div>
<button className={styles.logoutBtn} onClick={handleLogout} type="button">
Disconnect
</button>
</div>
{/* CLI mode */}
{mode === 'cli' && (
<div className={styles.panel}>
<div className={styles.cliRow}>
<span className={styles.cliPrompt}>$</span>
<input
ref={cliRef}
className={styles.cliInput}
value={cliInput}
onChange={e => setCliInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !loading && executeCli()}
placeholder="dbal list Snippet"
autoFocus
/>
<button
className={styles.executeBtn}
onClick={executeCli}
disabled={loading || !cliInput.trim()}
type="button"
>
{loading ? '...' : 'Run'}
</button>
</div>
<div className={styles.cliHelp}>
<span className={styles.cliHelpLabel}>Examples:</span>
{CLI_EXAMPLES.map(ex => (
<button
key={ex}
className={styles.cliExample}
onClick={() => { setCliInput(ex); cliRef.current?.focus() }}
type="button"
>
{ex}
</button>
))}
</div>
</div>
)}
{/* GUI mode */}
{mode === 'gui' && (
<div className={styles.panel}>
<div className={styles.methodGroup}>
{METHODS.map(m => (
<button
key={m.value}
className={`${styles.methodBtn} ${m.className} ${method === m.value ? styles.methodBtnActive : ''}`}
onClick={() => setMethod(m.value)}
type="button"
>
{m.value}
</button>
))}
</div>
<div className={styles.formGrid}>
<div className={styles.field}>
<label className={styles.label}>Tenant</label>
<input className={styles.input} value={tenant} onChange={e => setTenant(e.target.value)} placeholder="pastebin" />
</div>
<div className={styles.field}>
<label className={styles.label}>Package</label>
<input className={styles.input} value={pkg} onChange={e => setPkg(e.target.value)} placeholder="pastebin" />
</div>
<div className={styles.field}>
<label className={styles.label}>Entity</label>
<input className={styles.input} value={entity} onChange={e => setEntity(e.target.value)} placeholder="Snippet" />
</div>
<div className={styles.field}>
<label className={styles.label}>ID (optional)</label>
<input className={styles.input} value={entityId} onChange={e => setEntityId(e.target.value)} placeholder="abc-123" />
</div>
{method === 'GET' && (
<div className={styles.fieldFull}>
<label className={styles.label}>Query Parameters</label>
<input className={styles.input} value={queryParams} onChange={e => setQueryParams(e.target.value)} placeholder="limit=10&offset=0" />
</div>
)}
{(method === 'POST' || method === 'PUT') && (
<div className={styles.fieldFull}>
<label className={styles.label}>JSON Body</label>
<textarea className={styles.bodyEditor} value={body} onChange={e => setBody(e.target.value)} placeholder={'{\n "title": "Hello World",\n "content": "..."\n}'} />
</div>
)}
</div>
<div className={styles.executeRow}>
<button className={styles.executeBtn} onClick={executeGui} disabled={loading || !tenant || !pkg || !entity} type="button">
{loading ? 'Executing...' : 'Execute'}
</button>
<span className={styles.pathPreview}>{method} {buildPath()}</span>
</div>
</div>
)}
{/* Response */}
{loading && (
<div className={styles.panel}>
<div className={styles.loading}>
<span className={styles.spinner} />
Sending request...
</div>
<div className={styles.loading}><span className={styles.spinner} /> Sending request...</div>
</div>
)}
@@ -299,54 +474,44 @@ export function QueryConsole() {
<div className={styles.panel}>
<div className={styles.responseHeader}>
<h3 className={styles.responseTitle}>Response</h3>
<span
className={`${styles.statusBadge} ${response.status >= 200 && response.status < 300 ? styles.statusSuccess : styles.statusError}`}
>
<span className={`${styles.statusBadge} ${response.status >= 200 && response.status < 300 ? styles.statusSuccess : styles.statusError}`}>
{response.status} {response.statusText}
</span>
</div>
<pre
className={styles.responsePre}
dangerouslySetInnerHTML={{
__html: syntaxHighlight(JSON.stringify(response.data, null, 2)),
}}
dangerouslySetInnerHTML={{ __html: syntaxHighlight(JSON.stringify(response.data, null, 2)) }}
/>
</div>
)}
</div>
{/* History sidebar */}
<div className={styles.sidebar}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className={styles.sidebarTitle}>History</h3>
{history.length > 0 && (
<button className={styles.clearBtn} onClick={clearHistory} type="button">
Clear
</button>
<button className={styles.clearBtn} onClick={clearHistory} type="button">Clear</button>
)}
</div>
<div className={styles.historyList}>
{history.length === 0 && (
<p className={styles.emptyHistory}>No queries yet</p>
)}
{history.length === 0 && <p className={styles.emptyHistory}>No queries yet</p>}
{history.map(entry => (
<div
key={entry.id}
className={styles.historyItem}
onClick={() => restoreFromHistory(entry)}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className={styles.historyMethod} style={{ color: methodColor(entry.method) }}>
{entry.method}
</span>
<span
className={styles.historyStatus}
style={{ color: entry.status >= 200 && entry.status < 300 ? '#4ade80' : '#f87171' }}
>
{entry.status}
</span>
</div>
<span className={styles.historyPath}>{entry.path}</span>
<span className={styles.historyTime}>{formatTimestamp(entry.timestamp)}</span>
<div key={entry.id} className={styles.historyItem} onClick={() => restoreFromHistory(entry)}>
{entry.cli ? (
<span className={styles.historyPath}>$ {entry.cli}</span>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className={styles.historyMethod} style={{ color: methodColor(entry.method) }}>{entry.method}</span>
<span className={styles.historyStatus} style={{ color: entry.status >= 200 && entry.status < 300 ? '#4ade80' : '#f87171' }}>{entry.status}</span>
</div>
<span className={styles.historyPath}>{entry.path}</span>
</>
)}
<span className={styles.historyTime}>
{(() => { try { return new Date(entry.timestamp).toLocaleTimeString() } catch { return entry.timestamp } })()}
</span>
</div>
))}
</div>