mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user