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 router = useRouter()
|
||||
|
||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
|
||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
|
||||
|
||||
onMounted(async () => {
|
||||
// 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"/>
|
||||
</svg>
|
||||
</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 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',
|
||||
name: 'themes',
|
||||
component: () => import('../pages/ThemesPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/database',
|
||||
name: 'database',
|
||||
component: () => import('../pages/DatabasePage.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -24,8 +24,13 @@ import {
|
||||
unregisterProjectCanvasTools,
|
||||
PROJECT_CANVAS_TOOLS
|
||||
} 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 {
|
||||
register: () => void
|
||||
@@ -85,6 +90,11 @@ const pageTools: Record<PageName, PageToolSet> = {
|
||||
register: registerThemeTools,
|
||||
unregister: unregisterThemeTools,
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user