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:
142
server/index.ts
142
server/index.ts
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user