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

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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')
} }
] ]
}) })

View File

@@ -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
} }
} }

View 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)
}

View File

@@ -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 })
} }
}) })