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

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