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:
@@ -10,7 +10,7 @@ import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './servi
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
|
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Initialize WebMCP connection
|
// Initialize WebMCP connection
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ onMounted(() => {
|
|||||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.82-.13 2.67-.36"/>
|
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.82-.13 2.67-.36"/>
|
||||||
</svg>
|
</svg>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink to="/database" class="toolbar-btn" :class="{ active: route.path === '/database' }" title="Database">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
||||||
|
</svg>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
|
|||||||
959
frontend/src/pages/DatabasePage.vue
Normal file
959
frontend/src/pages/DatabasePage.vue
Normal file
@@ -0,0 +1,959 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableSchema {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
notnull: boolean
|
||||||
|
pk: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbStats {
|
||||||
|
size: string
|
||||||
|
tables: number
|
||||||
|
totalRecords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = ref<TableInfo[]>([])
|
||||||
|
const selectedTable = ref<string | null>(null)
|
||||||
|
const tableSchema = ref<TableSchema[]>([])
|
||||||
|
const tableData = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const dbStats = ref<DbStats | null>(null)
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const totalRecords = ref(0)
|
||||||
|
|
||||||
|
// Query executor
|
||||||
|
const queryText = ref('')
|
||||||
|
const queryResult = ref<any[] | null>(null)
|
||||||
|
const queryError = ref<string | null>(null)
|
||||||
|
const queryLoading = ref(false)
|
||||||
|
|
||||||
|
// Active tab
|
||||||
|
const activeTab = ref<'tables' | 'query' | 'stats'>('tables')
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(totalRecords.value / pageSize.value))
|
||||||
|
|
||||||
|
async function fetchTables() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:4101/api/database/tables')
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch tables')
|
||||||
|
tables.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDbStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:4101/api/database/stats')
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch stats')
|
||||||
|
dbStats.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error fetching stats:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTable(tableName: string) {
|
||||||
|
selectedTable.value = tableName
|
||||||
|
currentPage.value = 1
|
||||||
|
await Promise.all([fetchTableSchema(tableName), fetchTableData(tableName)])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTableSchema(tableName: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:4101/api/database/tables/${tableName}/schema`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch schema')
|
||||||
|
tableSchema.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error fetching schema:', e)
|
||||||
|
tableSchema.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTableData(tableName: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const offset = (currentPage.value - 1) * pageSize.value
|
||||||
|
const res = await fetch(
|
||||||
|
`http://localhost:4101/api/database/tables/${tableName}/data?limit=${pageSize.value}&offset=${offset}`
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch data')
|
||||||
|
const result = await res.json()
|
||||||
|
tableData.value = result.rows
|
||||||
|
totalRecords.value = result.total
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error fetching data:', e)
|
||||||
|
tableData.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page: number) {
|
||||||
|
if (page >= 1 && page <= totalPages.value && selectedTable.value) {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchTableData(selectedTable.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeQuery() {
|
||||||
|
if (!queryText.value.trim()) return
|
||||||
|
|
||||||
|
queryLoading.value = true
|
||||||
|
queryError.value = null
|
||||||
|
queryResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:4101/api/database/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: queryText.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
queryError.value = result.error || 'Query failed'
|
||||||
|
} else {
|
||||||
|
queryResult.value = result.rows
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
queryError.value = e.message
|
||||||
|
} finally {
|
||||||
|
queryLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: any): string {
|
||||||
|
if (value === null) return 'NULL'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
if (typeof value === 'string' && value.length > 100) {
|
||||||
|
return value.substring(0, 100) + '...'
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnKeys(data: any[]): string[] {
|
||||||
|
if (data.length === 0) return []
|
||||||
|
return Object.keys(data[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTables()
|
||||||
|
fetchDbStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="database-page">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>Database</h2>
|
||||||
|
<button class="btn-icon" @click="fetchTables" title="Refresh">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
|
||||||
|
<path d="M21 3v5h-5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-list">
|
||||||
|
<div v-if="loading && tables.length === 0" class="loading">Loading tables...</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-for="table in tables"
|
||||||
|
:key="table.name"
|
||||||
|
class="table-item"
|
||||||
|
:class="{ active: selectedTable === table.name }"
|
||||||
|
@click="selectTable(table.name)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M3 9h18"/>
|
||||||
|
<path d="M3 15h18"/>
|
||||||
|
<path d="M9 3v18"/>
|
||||||
|
</svg>
|
||||||
|
<span class="table-name">{{ table.name }}</span>
|
||||||
|
<span class="table-count">{{ table.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats summary in sidebar -->
|
||||||
|
<div v-if="dbStats" class="sidebar-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Size</span>
|
||||||
|
<span class="stat-value">{{ dbStats.size }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Tables</span>
|
||||||
|
<span class="stat-value">{{ dbStats.tables }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Records</span>
|
||||||
|
<span class="stat-value">{{ dbStats.totalRecords }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'tables' }"
|
||||||
|
@click="activeTab = 'tables'"
|
||||||
|
>
|
||||||
|
Explorer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'query' }"
|
||||||
|
@click="activeTab = 'query'"
|
||||||
|
>
|
||||||
|
Query
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'stats' }"
|
||||||
|
@click="activeTab = 'stats'"
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tables Tab -->
|
||||||
|
<div v-if="activeTab === 'tables'" class="tab-content">
|
||||||
|
<div v-if="!selectedTable" class="placeholder">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
||||||
|
</svg>
|
||||||
|
<p>Select a table to view data</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-view">
|
||||||
|
<!-- Schema info -->
|
||||||
|
<div class="schema-info">
|
||||||
|
<h3>{{ selectedTable }}</h3>
|
||||||
|
<div class="schema-columns">
|
||||||
|
<span
|
||||||
|
v-for="col in tableSchema"
|
||||||
|
:key="col.name"
|
||||||
|
class="schema-col"
|
||||||
|
:class="{ pk: col.pk }"
|
||||||
|
>
|
||||||
|
{{ col.name }}
|
||||||
|
<small>{{ col.type }}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data table -->
|
||||||
|
<div class="data-table-container">
|
||||||
|
<table v-if="tableData.length > 0" class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="key in getColumnKeys(tableData)" :key="key">{{ key }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, idx) in tableData" :key="idx">
|
||||||
|
<td v-for="key in getColumnKeys(tableData)" :key="key">
|
||||||
|
{{ formatValue(row[key]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="no-data">No records found</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="totalPages > 1" class="pagination">
|
||||||
|
<button @click="changePage(1)" :disabled="currentPage === 1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="changePage(currentPage - 1)" :disabled="currentPage === 1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
<button @click="changePage(currentPage + 1)" :disabled="currentPage === totalPages">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="changePage(totalPages)" :disabled="currentPage === totalPages">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Query Tab -->
|
||||||
|
<div v-if="activeTab === 'query'" class="tab-content query-tab">
|
||||||
|
<div class="query-editor">
|
||||||
|
<textarea
|
||||||
|
v-model="queryText"
|
||||||
|
placeholder="SELECT * FROM themes LIMIT 10;"
|
||||||
|
@keydown.ctrl.enter="executeQuery"
|
||||||
|
></textarea>
|
||||||
|
<div class="query-actions">
|
||||||
|
<span class="hint">Ctrl+Enter to execute. Only SELECT queries allowed.</span>
|
||||||
|
<button class="btn-primary" @click="executeQuery" :disabled="queryLoading">
|
||||||
|
{{ queryLoading ? 'Running...' : 'Execute' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="query-results">
|
||||||
|
<div v-if="queryError" class="query-error">
|
||||||
|
{{ queryError }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="queryResult" class="data-table-container">
|
||||||
|
<table v-if="queryResult.length > 0" class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="key in getColumnKeys(queryResult)" :key="key">{{ key }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, idx) in queryResult" :key="idx">
|
||||||
|
<td v-for="key in getColumnKeys(queryResult)" :key="key">
|
||||||
|
{{ formatValue(row[key]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="no-data">Query returned no results</div>
|
||||||
|
<div class="result-count">{{ queryResult.length }} row(s)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Tab -->
|
||||||
|
<div v-if="activeTab === 'stats'" class="tab-content stats-tab">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-title">Database Size</span>
|
||||||
|
<span class="stat-value-lg">{{ dbStats?.size || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M3 9h18"/>
|
||||||
|
<path d="M9 3v18"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-title">Total Tables</span>
|
||||||
|
<span class="stat-value-lg">{{ dbStats?.tables || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-title">Total Records</span>
|
||||||
|
<span class="stat-value-lg">{{ dbStats?.totalRecords || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tables-breakdown">
|
||||||
|
<h3>Tables Breakdown</h3>
|
||||||
|
<div class="breakdown-list">
|
||||||
|
<div v-for="table in tables" :key="table.name" class="breakdown-item">
|
||||||
|
<span class="breakdown-name">{{ table.name }}</span>
|
||||||
|
<div class="breakdown-bar">
|
||||||
|
<div
|
||||||
|
class="breakdown-fill"
|
||||||
|
:style="{ width: `${(table.count / (dbStats?.totalRecords || 1)) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="breakdown-count">{{ table.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.database-page {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.active {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.active .table-count {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-stats {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table view */
|
||||||
|
.table-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-info {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-info h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-col {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-col.pk {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-col small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover td {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Query tab */
|
||||||
|
.query-tab {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-editor textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-editor textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-error {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats tab */
|
||||||
|
.stats-tab {
|
||||||
|
padding: 1.5rem;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-lg {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-breakdown {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-breakdown h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-name {
|
||||||
|
width: 150px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-count {
|
||||||
|
width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.database-page {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-stats {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -33,6 +33,11 @@ const router = createRouter({
|
|||||||
path: '/themes',
|
path: '/themes',
|
||||||
name: 'themes',
|
name: 'themes',
|
||||||
component: () => import('../pages/ThemesPage.vue')
|
component: () => import('../pages/ThemesPage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/database',
|
||||||
|
name: 'database',
|
||||||
|
component: () => import('../pages/DatabasePage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ import {
|
|||||||
unregisterProjectCanvasTools,
|
unregisterProjectCanvasTools,
|
||||||
PROJECT_CANVAS_TOOLS
|
PROJECT_CANVAS_TOOLS
|
||||||
} from './tools/projectCanvasTools'
|
} from './tools/projectCanvasTools'
|
||||||
|
import {
|
||||||
|
registerDatabaseTools,
|
||||||
|
unregisterDatabaseTools,
|
||||||
|
DATABASE_TOOLS
|
||||||
|
} from './tools/databaseTools'
|
||||||
|
|
||||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
|
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
|
||||||
|
|
||||||
interface PageToolSet {
|
interface PageToolSet {
|
||||||
register: () => void
|
register: () => void
|
||||||
@@ -85,6 +90,11 @@ const pageTools: Record<PageName, PageToolSet> = {
|
|||||||
register: registerThemeTools,
|
register: registerThemeTools,
|
||||||
unregister: unregisterThemeTools,
|
unregister: unregisterThemeTools,
|
||||||
toolNames: THEME_TOOLS
|
toolNames: THEME_TOOLS
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
register: registerDatabaseTools,
|
||||||
|
unregister: unregisterDatabaseTools,
|
||||||
|
toolNames: DATABASE_TOOLS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
231
frontend/src/services/tools/databaseTools.ts
Normal file
231
frontend/src/services/tools/databaseTools.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { registerTool, unregisterTools } from '../webmcp'
|
||||||
|
|
||||||
|
export const DATABASE_TOOLS = [
|
||||||
|
'list_tables',
|
||||||
|
'get_table_schema',
|
||||||
|
'get_table_data',
|
||||||
|
'get_database_stats',
|
||||||
|
'execute_query'
|
||||||
|
]
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:4101'
|
||||||
|
|
||||||
|
export function registerDatabaseTools() {
|
||||||
|
// list_tables
|
||||||
|
registerTool(
|
||||||
|
'list_tables',
|
||||||
|
'Lista todas las tablas de la base de datos SQLite con su conteo de registros',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/database/tables`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch tables')
|
||||||
|
const tables = await res.json()
|
||||||
|
|
||||||
|
if (tables.length === 0) {
|
||||||
|
return 'No hay tablas en la base de datos'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
|
||||||
|
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
|
||||||
|
|
||||||
|
return `Tablas en la base de datos (${tables.length}):\n\n${tableList}\n\nTotal de registros: ${total}`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_table_schema
|
||||||
|
registerTool(
|
||||||
|
'get_table_schema',
|
||||||
|
'Obtiene el esquema (columnas y tipos) de una tabla',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
table: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nombre de la tabla'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['table']
|
||||||
|
},
|
||||||
|
async (args: { table: string }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
|
||||||
|
throw new Error('Failed to fetch schema')
|
||||||
|
}
|
||||||
|
const schema = await res.json()
|
||||||
|
|
||||||
|
if (schema.length === 0) {
|
||||||
|
return `La tabla "${args.table}" no tiene columnas definidas`
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = schema.map((col: any) => {
|
||||||
|
const flags = []
|
||||||
|
if (col.pk) flags.push('PRIMARY KEY')
|
||||||
|
if (col.notnull) flags.push('NOT NULL')
|
||||||
|
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
|
||||||
|
return ` - ${col.name}: ${col.type}${flagStr}`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
return `Esquema de la tabla "${args.table}":\n\n${columns}`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_table_data
|
||||||
|
registerTool(
|
||||||
|
'get_table_data',
|
||||||
|
'Obtiene los datos de una tabla con paginacion',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
table: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nombre de la tabla'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Numero de registros a retornar (default: 20, max: 100)'
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Registros a saltar para paginacion (default: 0)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['table']
|
||||||
|
},
|
||||||
|
async (args: { table: string; limit?: number; offset?: number }) => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(args.limit || 20, 100)
|
||||||
|
const offset = args.offset || 0
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
|
||||||
|
throw new Error('Failed to fetch data')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return `La tabla "${args.table}" no tiene registros`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as readable table
|
||||||
|
const rows = result.rows.map((row: any, idx: number) => {
|
||||||
|
const entries = Object.entries(row).map(([k, v]) => {
|
||||||
|
let value = v
|
||||||
|
if (typeof v === 'string' && v.length > 50) {
|
||||||
|
value = v.substring(0, 50) + '...'
|
||||||
|
} else if (typeof v === 'object') {
|
||||||
|
value = JSON.stringify(v).substring(0, 50) + '...'
|
||||||
|
}
|
||||||
|
return `${k}: ${value}`
|
||||||
|
}).join(', ')
|
||||||
|
return `[${offset + idx + 1}] ${entries}`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}\n\nUsa offset=${offset + limit} para ver mas registros`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_database_stats
|
||||||
|
registerTool(
|
||||||
|
'get_database_stats',
|
||||||
|
'Obtiene estadisticas generales de la base de datos',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/database/stats`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch stats')
|
||||||
|
const stats = await res.json()
|
||||||
|
|
||||||
|
return `Estadisticas de la base de datos:\n\n` +
|
||||||
|
` Tamano: ${stats.size}\n` +
|
||||||
|
` Tablas: ${stats.tables}\n` +
|
||||||
|
` Registros totales: ${stats.totalRecords}\n\n` +
|
||||||
|
`Desglose por tabla:\n` +
|
||||||
|
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// execute_query
|
||||||
|
registerTool(
|
||||||
|
'execute_query',
|
||||||
|
'Ejecuta una consulta SQL SELECT en la base de datos (solo lectura)',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Consulta SQL (solo SELECT permitido)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['query']
|
||||||
|
},
|
||||||
|
async (args: { query: string }) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/database/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: args.query })
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return `Error en la consulta: ${result.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return 'La consulta no retorno resultados'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
const columns = Object.keys(result.rows[0])
|
||||||
|
const header = columns.join(' | ')
|
||||||
|
const separator = columns.map(() => '---').join(' | ')
|
||||||
|
const rows = result.rows.slice(0, 50).map((row: any) => {
|
||||||
|
return columns.map(col => {
|
||||||
|
let value = row[col]
|
||||||
|
if (value === null) return 'NULL'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
if (typeof value === 'string' && value.length > 40) {
|
||||||
|
return value.substring(0, 40) + '...'
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}).join(' | ')
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
|
||||||
|
|
||||||
|
return `Resultados (${result.rows.length} filas):\n\n${header}\n${separator}\n${rows}${truncated}`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterDatabaseTools() {
|
||||||
|
unregisterTools(DATABASE_TOOLS)
|
||||||
|
}
|
||||||
142
server/index.ts
142
server/index.ts
@@ -861,6 +861,148 @@ Bun.serve({
|
|||||||
}, { headers: corsHeaders })
|
}, { 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 })
|
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user