feat: Add database explorer page with SQLite management

- Add /database page with table explorer, query executor and stats
- Implement MCP tools: list_tables, get_table_schema, get_table_data,
  get_database_stats, execute_query
- Add database API endpoints with security (SELECT only)
- Add database icon to toolbar
This commit is contained in:
2026-02-13 06:43:52 -06:00
parent 8a017db777
commit 97ef49aea4
7 changed files with 1357 additions and 2 deletions

View File

@@ -861,6 +861,148 @@ Bun.serve({
}, { headers: corsHeaders })
}
// =====================
// API de Database Explorer
// =====================
// GET /api/database/tables - Lista todas las tablas con conteo
if (url.pathname === '/api/database/tables') {
if (req.method === 'GET') {
const tables = db.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`).all() as { name: string }[]
const result = tables.map(t => {
const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number }
return { name: t.name, count: countResult.count }
})
return Response.json(result, { headers: corsHeaders })
}
}
// GET /api/database/stats - Estadisticas de la BD
if (url.pathname === '/api/database/stats') {
if (req.method === 'GET') {
// Get database file size
const file = Bun.file('agent-ui.db')
const size = file.size
const sizeStr = size < 1024 ? `${size} B`
: size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB`
: `${(size / (1024 * 1024)).toFixed(2)} MB`
// Get tables and counts
const tables = db.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`).all() as { name: string }[]
let totalRecords = 0
const breakdown = tables.map(t => {
const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number }
totalRecords += countResult.count
return { name: t.name, count: countResult.count }
})
return Response.json({
size: sizeStr,
tables: tables.length,
totalRecords,
breakdown
}, { headers: corsHeaders })
}
}
// GET /api/database/tables/:name/schema - Esquema de una tabla
const tableSchemaMatch = url.pathname.match(/^\/api\/database\/tables\/([^/]+)\/schema$/)
if (tableSchemaMatch && req.method === 'GET') {
const tableName = decodeURIComponent(tableSchemaMatch[1])
// Verify table exists
const tableExists = db.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name = ?
`).get(tableName)
if (!tableExists) {
return Response.json({ error: 'Table not found' }, { status: 404, headers: corsHeaders })
}
const schema = db.query(`PRAGMA table_info("${tableName}")`).all() as any[]
const result = schema.map(col => ({
name: col.name,
type: col.type,
notnull: !!col.notnull,
pk: !!col.pk
}))
return Response.json(result, { headers: corsHeaders })
}
// GET /api/database/tables/:name/data - Datos de una tabla con paginacion
const tableDataMatch = url.pathname.match(/^\/api\/database\/tables\/([^/]+)\/data$/)
if (tableDataMatch && req.method === 'GET') {
const tableName = decodeURIComponent(tableDataMatch[1])
// Verify table exists
const tableExists = db.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name = ?
`).get(tableName)
if (!tableExists) {
return Response.json({ error: 'Table not found' }, { status: 404, headers: corsHeaders })
}
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 500)
const offset = parseInt(url.searchParams.get('offset') || '0')
const countResult = db.query(`SELECT COUNT(*) as count FROM "${tableName}"`).get() as { count: number }
const rows = db.query(`SELECT * FROM "${tableName}" LIMIT ? OFFSET ?`).all(limit, offset)
return Response.json({
total: countResult.count,
limit,
offset,
rows
}, { headers: corsHeaders })
}
// POST /api/database/query - Ejecutar consulta SELECT
if (url.pathname === '/api/database/query') {
if (req.method === 'POST') {
const body = await req.json()
const query = (body.query || '').trim()
// Security: Only allow SELECT statements
const normalizedQuery = query.toLowerCase()
if (!normalizedQuery.startsWith('select')) {
return Response.json({
error: 'Only SELECT queries are allowed for security reasons'
}, { status: 403, headers: corsHeaders })
}
// Block dangerous keywords
const dangerousKeywords = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate', 'replace']
for (const keyword of dangerousKeywords) {
if (normalizedQuery.includes(keyword)) {
return Response.json({
error: `Query contains forbidden keyword: ${keyword.toUpperCase()}`
}, { status: 403, headers: corsHeaders })
}
}
try {
const rows = db.query(query).all()
return Response.json({ rows }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
}
return new Response('Not Found', { status: 404, headers: corsHeaders })
}
})